feat: 实现动态插件系统 (6阶段完成)

- 阶段1: 消息类型序列化 (Serialize/Deserialize, &'static str → String)
- 阶段2: FFI 边界类型 + Plugin SDK (plugin_abi, showen-plugin-sdk crate)
- 阶段3: PluginLoader + DynamicPlugin (libloading 动态加载 .so)
- 阶段4: 版本管理 + 错误策略 (VersionManager, PluginState, 自动回退)
- 阶段5: 远程仓库客户端 (HTTP 下载 + tar.gz 安装)
- 阶段6: 示例插件 + HTTP 管理 API + 全目录 README 文档

54/54 测试通过,0 warnings。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-13 03:38:08 +08:00
parent 5dcc1ad98e
commit 7135f28545
62 changed files with 3501 additions and 299 deletions

249
Cargo.lock generated
View File

@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -29,6 +35,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -119,6 +131,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -230,12 +251,33 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -374,7 +416,7 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270"
dependencies = [ dependencies = [
"base64", "base64 0.21.7",
"bytes", "bytes",
"headers-core", "headers-core",
"http 0.2.12", "http 0.2.12",
@@ -613,6 +655,24 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "libredox"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -653,6 +713,16 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -798,6 +868,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -870,6 +946,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -899,6 +984,68 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -989,6 +1136,22 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "showen-example-plugin"
version = "0.1.0"
dependencies = [
"serde_json",
"showen-plugin-sdk",
]
[[package]]
name = "showen-plugin-sdk"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "showen_v2" name = "showen_v2"
version = "0.2.0" version = "0.2.0"
@@ -998,12 +1161,17 @@ dependencies = [
"ctrlc", "ctrlc",
"dbus", "dbus",
"dbus-crossroads", "dbus-crossroads",
"flate2",
"futures-util", "futures-util",
"libloading",
"opencv", "opencv",
"rand", "rand",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"tar",
"tokio", "tokio",
"ureq",
"warp", "warp",
] ]
@@ -1017,6 +1185,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1061,6 +1235,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -1083,6 +1263,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -1234,6 +1425,28 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1323,6 +1536,24 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -1432,6 +1663,16 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"
@@ -1496,6 +1737,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -1,3 +1,6 @@
[workspace]
members = [".", "plugin-sdk", "plugins/example-plugin"]
[package] [package]
name = "showen_v2" name = "showen_v2"
version = "0.2.0" version = "0.2.0"
@@ -21,3 +24,12 @@ futures-util = "0.3"
# Linux 特有插件依赖 # Linux 特有插件依赖
dbus = "0.9" dbus = "0.9"
dbus-crossroads = "0.5" dbus-crossroads = "0.5"
# 动态插件加载
libloading = "0.8"
# 远程插件仓库
ureq = "2"
flate2 = "1"
tar = "0.4"
semver = "1"

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# ShowenV2 — 数字生命窗口平台
基于 Rust 的跨平台插件微内核架构,支持全息/VR/AR/屏幕显示承载虚拟宠物、数字人、AI 歌姬、3D 模型等数字生命内容。
## 架构概览
```
ShowenV2/
├── src/
│ ├── core/ # 插件微内核
│ │ ├── message.rs # 类型安全消息协议 (Serialize/Deserialize)
│ │ ├── plugin.rs # Plugin trait 定义
│ │ ├── service_manager.rs # 生命周期管理 + 消息路由 + 错误策略
│ │ ├── config.rs # 配置解析/验证
│ │ ├── dispatch.rs # 文本命令解析
│ │ ├── plugin_abi.rs # C FFI 类型 (动态插件边界)
│ │ ├── dynamic_plugin.rs # DynamicPlugin (libloading)
│ │ ├── plugin_loader.rs # plugin_store/ 扫描/加载
│ │ ├── version_manager.rs # 版本切换/回退/GC
│ │ └── plugin_repo.rs # 远程仓库 HTTP 客户端
│ └── plugins/ # 功能插件
│ ├── video/ # 视频播放引擎 (OpenCV)
│ ├── http/ # Web UI + REST API (warp)
│ ├── ble/ # BLE 配网 (D-Bus BlueZ)
│ ├── wifi/ # WiFi 管理 (nmcli)
│ └── screen/ # 屏幕管理 (防息屏/光标)
├── plugin-sdk/ # 动态插件开发 SDK
├── plugins/
│ └── example-plugin/ # 示例动态插件 (cdylib)
├── configs/ # 配置文件 (状态机 JSON)
├── clients/ # 外部控制客户端应用
└── souls/ # 团队成员档案
```
## 技术栈
- **语言**: Rust (edition 2018)
- **视频**: OpenCV 0.66
- **HTTP**: warp + tokio
- **BLE**: D-Bus + BlueZ (GATT)
- **WiFi**: nmcli
- **插件加载**: libloading
- **远程仓库**: ureq + flate2 + tar
## 动态插件系统
ShowenV2 支持两种插件模式:
1. **静态插件** — 编译时链接5 个内置插件 (video, http, ble, wifi, screen)
2. **动态插件** — 运行时加载 `.so` 文件,通过 `extern "C"` FFI + JSON 序列化通信
动态插件特性:
- 多版本管理 (`plugin_store/` 目录结构)
- 远程仓库下载安装 (HTTP + tar.gz)
- 错误自动回退 (AutoRollback / DisableAndLog)
- REST API 管理 (`/api/plugins/*`)
## 快速开始
```bash
# 验证配置
cargo run -- --validate --config configs/dog_state_machine.json
# 运行
cargo run -- --config configs/dog_state_machine.json
# 测试
cargo test --workspace
```
## 目标硬件
ARM aarch64 嵌入式 Linux, 屏幕 1280×800

View File

@@ -0,0 +1,3 @@
# alipay-miniapp/ — 支付宝小程序
支付宝小程序客户端。(规划中)

View File

@@ -0,0 +1,3 @@
# android/ — Android 原生应用
Kotlin/Jetpack Compose 实现的 Android 控制应用。(规划中)

3
clients/cli/README.md Normal file
View File

@@ -0,0 +1,3 @@
# cli/ — 命令行工具
终端命令行控制工具,用于调试和自动化。(规划中)

View File

@@ -0,0 +1,3 @@
# desktop/ — 桌面应用
Electron/Tauri 实现的桌面控制应用Windows/macOS/Linux规划中

6
clients/docs/README.md Normal file
View File

@@ -0,0 +1,6 @@
# docs/ — 客户端开发文档
| 文件 | 说明 |
|------|------|
| `API.md` | ShowenV2 REST API 接口文档 |
| `DESIGN.md` | 客户端 UI/UX 设计规范 |

View File

@@ -0,0 +1,3 @@
# flutter/ — Flutter 跨平台应用
Flutter 实现的跨平台移动控制应用iOS + Android规划中

3
clients/ios/README.md Normal file
View File

@@ -0,0 +1,3 @@
# ios/ — iOS 原生应用
Swift/SwiftUI 实现的 iOS 控制应用。(规划中)

View File

@@ -0,0 +1,3 @@
# plugin-dev/ — 插件开发工具
动态插件的开发、调试和打包工具。(规划中)

3
clients/sdk/README.md Normal file
View File

@@ -0,0 +1,3 @@
# sdk/ — 多语言 SDK
各语言的 ShowenV2 客户端 SDKPython、JavaScript、Go、Rust规划中

9
clients/shared/README.md Normal file
View File

@@ -0,0 +1,9 @@
# shared/ — 客户端共享代码
跨客户端复用的代码库。
| 目录 | 说明 |
|------|------|
| `api/` | ShowenV2 HTTP/WebSocket API 客户端封装 |
| `models/` | 共享数据模型定义 |
| `utils/` | 通用工具函数 |

View File

@@ -0,0 +1,3 @@
# api/ — API 客户端库
ShowenV2 HTTP REST API 和 WebSocket 客户端的封装,供各客户端应用复用。

View File

@@ -0,0 +1,3 @@
# models/ — 共享数据模型
客户端通用的数据模型定义(设备状态、播放列表、配置等)。

View File

@@ -0,0 +1,3 @@
# utils/ — 通用工具函数
客户端共享的工具函数(网络请求、数据格式化、错误处理等)。

View File

@@ -0,0 +1,3 @@
# smarthome/ — 智能家居集成
HomeKit / 米家 / 小度等智能家居平台接入。(规划中)

3
clients/voice/README.md Normal file
View File

@@ -0,0 +1,3 @@
# voice/ — 智能音箱集成
语音控制集成(天猫精灵、小爱同学、小度等)。(规划中)

3
clients/watch/README.md Normal file
View File

@@ -0,0 +1,3 @@
# watch/ — 智能手表应用
Apple Watch / Wear OS 控制应用。(规划中)

3
clients/web/README.md Normal file
View File

@@ -0,0 +1,3 @@
# web/ — Web 应用
基于 React/Vue 的响应式 Web 控制界面。(规划中)

View File

@@ -0,0 +1,3 @@
# wechat-miniapp/ — 微信小程序
微信小程序客户端。(规划中)

19
configs/README.md Normal file
View File

@@ -0,0 +1,19 @@
# configs/ — 状态机配置
JSON 格式的状态机配置文件,定义视频播放场景和状态转换规则。
## 文件
| 文件 | 说明 |
|------|------|
| `dog_state_machine.json` | 狗宠物的场景/动画状态机 |
| `cat_state_machine.json` | 猫宠物的场景/动画状态机 |
## 配置结构
每个 JSON 配置定义:
- **场景 (scenes)**: 包含多个动画状态
- **状态 (states)**: 绑定视频文件,定义播放行为
- **转换 (transitions)**: 触发器驱动的状态跳转voice / button / sensor
`VideoPlugin``StateMachine` 模块解析和驱动。

View File

@@ -8,10 +8,10 @@
"offset_x": 0, "offset_x": 0,
"offset_y": 0, "offset_y": 0,
"prevent_screen_lock": true, "prevent_screen_lock": true,
"render_width": 1280, "render_width": 1920,
"render_height": 800, "render_height": 1080,
"output_width": null, "output_width": 1920,
"output_height": null, "output_height": 1080,
"scale_mode": "stretch", "scale_mode": "stretch",
"allow_upscale": true, "allow_upscale": true,
"perspective_correction": { "perspective_correction": {
@@ -22,16 +22,16 @@
0 0
], ],
[ [
1280, 1920,
0 0
], ],
[ [
1280, 1920,
800 1080
], ],
[ [
0, 0,
800 1080
] ]
] ]
}, },

10
plugin-sdk/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "showen-plugin-sdk"
version = "0.2.0"
authors = ["showen"]
edition = "2018"
description = "SDK for building ShowenV2 dynamic plugins"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

45
plugin-sdk/README.md Normal file
View File

@@ -0,0 +1,45 @@
# ShowenV2 Plugin SDK
插件开发者使用此 SDK 编写动态插件(`.so` 文件),运行时由主程序动态加载。
## 核心接口
- **`ShowenPlugin` trait**: 插件作者实现的高级接口init / start / handle_message / stop
- **`MessageSender`**: 封装 FFI SendCallback插件通过它向主程序发送消息
- **`export_plugin!` 宏**: 自动生成 `extern "C"` 胶水代码,导出 `PluginVTable`
## 类型
SDK 独立定义了与主程序 JSON 兼容的消息类型:
- `PluginInfo`, `Envelope`, `Destination`, `Message`
## 用法
```rust
use showen_plugin_sdk::{export_plugin, ShowenPlugin, MessageSender, PluginInfo, Message};
struct MyPlugin { sender: Option<MessageSender> }
impl ShowenPlugin for MyPlugin {
fn info(&self) -> PluginInfo { /* ... */ }
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> { Ok(()) }
fn start(&mut self) -> Result<(), String> { Ok(()) }
fn handle_message(&mut self, msg_json: &str) -> Result<(), String> { Ok(()) }
fn stop(&mut self) -> Result<(), String> { Ok(()) }
}
export_plugin!(MyPlugin, MyPlugin::new);
```
## 编译
```bash
cd plugin-sdk
cargo build
```
插件项目在 `Cargo.toml` 中依赖此 SDK
```toml
[dependencies]
showen-plugin-sdk = { path = "../plugin-sdk" }
```

292
plugin-sdk/src/lib.rs Normal file
View File

@@ -0,0 +1,292 @@
//! ShowenV2 Plugin SDK
//!
//! 插件开发者使用此 SDK 编写动态插件。
//! 实现 `ShowenPlugin` trait然后用 `export_plugin!` 宏导出。
use serde::{Deserialize, Serialize};
use std::ffi::{c_char, c_int, c_void, CString};
use std::ptr;
// ── 重新导出消息类型(与主程序共享 JSON 契约) ──
/// 插件信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub description: String,
pub platform: String,
}
/// 消息信封
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
pub from: String,
pub to: Destination,
pub message: Message,
}
/// 消息目的地
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Destination {
Plugin(String),
Broadcast,
Manager,
}
/// 消息类型 — 与主程序 Message 枚举保持 JSON 兼容
/// 动态插件只需处理自己关心的消息变体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Message {
PlayerCommand(serde_json::Value),
PlayerStatus(serde_json::Value),
Trigger { name: String, value: String },
StateChanged { old_state: String, new_state: String },
ScreenLockRequest(bool),
CursorVisibility(bool),
WifiCommand(serde_json::Value),
WifiResult(String),
WifiProvisioned { ssid: String, ip: String },
ConfigReloadRequest,
Shutdown,
PluginReady(String),
Custom { kind: String, payload: String },
}
// ── FFI 类型(与主程序 plugin_abi.rs 完全对应) ──
pub type PluginHandle = *mut c_void;
pub type FfiStr = *const c_char;
#[repr(C)]
pub struct FfiString {
pub ptr: *mut c_char,
pub len: usize,
}
impl FfiString {
pub fn from_string(s: String) -> Self {
match CString::new(s) {
Ok(cstr) => {
let len = cstr.as_bytes().len();
Self {
ptr: cstr.into_raw(),
len,
}
}
Err(_) => Self::null(),
}
}
pub fn null() -> Self {
Self {
ptr: ptr::null_mut(),
len: 0,
}
}
}
#[repr(C)]
pub struct FfiResult {
pub code: c_int,
pub error: FfiString,
}
impl FfiResult {
pub fn ok() -> Self {
Self {
code: 0,
error: FfiString::null(),
}
}
pub fn err(msg: String) -> Self {
Self {
code: -1,
error: FfiString::from_string(msg),
}
}
}
pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr);
#[repr(C)]
pub struct PluginVTable {
pub create: unsafe extern "C" fn() -> PluginHandle,
pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult,
pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult,
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
pub destroy: unsafe extern "C" fn(handle: PluginHandle),
}
// ── 高级接口:插件作者实现此 trait ──
/// 消息发送器 — 封装 SendCallback提供安全的 Rust API
pub struct MessageSender {
cb: SendCallback,
}
impl MessageSender {
pub fn new(cb: SendCallback) -> Self {
Self { cb }
}
/// 发送消息信封到主程序
pub fn send(&self, envelope: &Envelope) {
if let Ok(json) = serde_json::to_string(envelope) {
if let Ok(cstr) = CString::new(json) {
unsafe { (self.cb)(cstr.as_ptr()) };
}
}
}
/// 便捷方法:发送消息给指定插件
pub fn send_to(&self, from: &str, to_plugin: &str, message: Message) {
self.send(&Envelope {
from: from.to_string(),
to: Destination::Plugin(to_plugin.to_string()),
message,
});
}
/// 便捷方法:广播消息
pub fn broadcast(&self, from: &str, message: Message) {
self.send(&Envelope {
from: from.to_string(),
to: Destination::Broadcast,
message,
});
}
/// 便捷方法:发送消息给管理层
pub fn send_to_manager(&self, from: &str, message: Message) {
self.send(&Envelope {
from: from.to_string(),
to: Destination::Manager,
message,
});
}
}
// SendCallback 是 extern "C" fn 指针,可跨线程安全传递
unsafe impl Send for MessageSender {}
unsafe impl Sync for MessageSender {}
/// 动态插件 trait — 插件作者实现此接口
pub trait ShowenPlugin: Send {
/// 插件信息
fn info(&self) -> PluginInfo;
/// 初始化,收到配置 JSON 和消息发送器
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String>;
/// 启动
fn start(&mut self) -> Result<(), String>;
/// 处理消息 JSON已反序列化为 Message
fn handle_message(&mut self, message: Message) -> Result<(), String>;
/// 停止
fn stop(&mut self) -> Result<(), String>;
}
// ── 导出宏:自动生成 extern "C" 胶水代码 ──
/// 将 ShowenPlugin 实现导出为 C FFI 接口
///
/// # 用法
/// ```ignore
/// struct MyPlugin { ... }
/// impl ShowenPlugin for MyPlugin { ... }
///
/// export_plugin!(MyPlugin, MyPlugin::new);
/// ```
#[macro_export]
macro_rules! export_plugin {
($plugin_type:ty, $constructor:expr) => {
unsafe extern "C" fn __showen_create() -> $crate::PluginHandle {
let plugin: Box<$plugin_type> = Box::new($constructor);
Box::into_raw(plugin) as $crate::PluginHandle
}
unsafe extern "C" fn __showen_get_info(handle: $crate::PluginHandle) -> $crate::FfiString {
let plugin = unsafe { &*(handle as *const $plugin_type) };
let info = <$plugin_type as $crate::ShowenPlugin>::info(plugin);
match serde_json::to_string(&info) {
Ok(json) => $crate::FfiString::from_string(json),
Err(_) => $crate::FfiString::null(),
}
}
unsafe extern "C" fn __showen_init(
handle: $crate::PluginHandle,
config_json: $crate::FfiStr,
send_cb: $crate::SendCallback,
) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
let config = match unsafe { std::ffi::CStr::from_ptr(config_json) }.to_str() {
Ok(s) => s,
Err(e) => return $crate::FfiResult::err(format!("invalid config UTF-8: {e}")),
};
let sender = $crate::MessageSender::new(send_cb);
match <$plugin_type as $crate::ShowenPlugin>::init(plugin, config, sender) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
}
unsafe extern "C" fn __showen_start(handle: $crate::PluginHandle) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
match <$plugin_type as $crate::ShowenPlugin>::start(plugin) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
}
unsafe extern "C" fn __showen_handle_message(
handle: $crate::PluginHandle,
message_json: $crate::FfiStr,
) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
let json_str = match unsafe { std::ffi::CStr::from_ptr(message_json) }.to_str() {
Ok(s) => s,
Err(e) => return $crate::FfiResult::err(format!("invalid message UTF-8: {e}")),
};
let message: $crate::Message = match serde_json::from_str(json_str) {
Ok(m) => m,
Err(e) => return $crate::FfiResult::err(format!("invalid message JSON: {e}")),
};
match <$plugin_type as $crate::ShowenPlugin>::handle_message(plugin, message) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
}
unsafe extern "C" fn __showen_stop(handle: $crate::PluginHandle) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
match <$plugin_type as $crate::ShowenPlugin>::stop(plugin) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
}
unsafe extern "C" fn __showen_destroy(handle: $crate::PluginHandle) {
if !handle.is_null() {
drop(unsafe { Box::from_raw(handle as *mut $plugin_type) });
}
}
#[no_mangle]
pub static showen_plugin_vtable: $crate::PluginVTable = $crate::PluginVTable {
create: __showen_create,
get_info: __showen_get_info,
init: __showen_init,
start: __showen_start,
handle_message: __showen_handle_message,
stop: __showen_stop,
destroy: __showen_destroy,
};
};
}

19
plugins/README.md Normal file
View File

@@ -0,0 +1,19 @@
# plugins/ — 外部动态插件
此目录存放独立编译的动态插件项目cdylib crate
## 目录
| 目录 | 说明 |
|------|------|
| `example-plugin/` | 示例插件,演示 SDK 用法 |
## 开发流程
1. 创建新 crate依赖 `showen-plugin-sdk`
2. 实现 `ShowenPlugin` trait
3.`export_plugin!` 宏导出
4. `cargo build --release` 编译为 `.so`
5. 将产物放入 `plugin_store/<id>/<version>/`
详见 `plugin-sdk/README.md`

View File

@@ -0,0 +1,12 @@
[package]
name = "showen-example-plugin"
version = "0.1.0"
authors = ["showen"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
[dependencies]
showen-plugin-sdk = { path = "../../plugin-sdk" }
serde_json = "1"

View File

@@ -0,0 +1,29 @@
# Example Plugin — 示例动态插件
演示如何使用 `showen-plugin-sdk` 编写动态插件。
## 功能
- 仅打印日志,用于验证动态加载流程
- 展示 `ShowenPlugin` trait 的完整实现
- 编译为 `cdylib``.so` 文件)
## 编译
```bash
cd plugins/example-plugin
cargo build --release
```
产物: `target/release/libshowen_example_plugin.so`
## 安装
`.so``manifest.json` 放入 `plugin_store/example-plugin/<version>/` 目录即可被主程序动态加载。
## 文件
| 文件 | 说明 |
|------|------|
| `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 |
| `Cargo.toml` | crate 配置,类型为 cdylib |

View File

@@ -0,0 +1,72 @@
//! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件
//!
//! 此插件仅打印日志,用于验证动态加载流程。
use showen_plugin_sdk::{
export_plugin, Message, MessageSender, PluginInfo, ShowenPlugin,
};
pub struct ExamplePlugin {
sender: Option<MessageSender>,
}
impl ExamplePlugin {
pub fn new() -> Self {
Self { sender: None }
}
}
impl ShowenPlugin for ExamplePlugin {
fn info(&self) -> PluginInfo {
PluginInfo {
name: "example-plugin".to_string(),
version: "0.1.0".to_string(),
description: "示例动态插件".to_string(),
platform: "Any".to_string(),
}
}
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> {
eprintln!("[ExamplePlugin] init called, config length: {}", config_json.len());
self.sender = Some(sender);
// 通知主程序就绪
if let Some(sender) = &self.sender {
sender.send_to_manager(
"example-plugin",
Message::PluginReady("example-plugin".to_string()),
);
}
Ok(())
}
fn start(&mut self) -> Result<(), String> {
eprintln!("[ExamplePlugin] started");
Ok(())
}
fn handle_message(&mut self, message: Message) -> Result<(), String> {
match &message {
Message::Shutdown => {
eprintln!("[ExamplePlugin] received shutdown");
}
Message::Custom { kind, payload } => {
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
}
_ => {
eprintln!("[ExamplePlugin] received message: {:?}", message);
}
}
Ok(())
}
fn stop(&mut self) -> Result<(), String> {
eprintln!("[ExamplePlugin] stopped");
self.sender = None;
Ok(())
}
}
// 导出 FFI 接口
export_plugin!(ExamplePlugin, ExamplePlugin::new());

8
scripts/README.md Normal file
View File

@@ -0,0 +1,8 @@
# scripts/ — 脚本工具
构建、部署和开发辅助脚本。
目前为空目录,预留用于:
- 交叉编译脚本ARM aarch64 目标)
- 部署脚本(推送到设备)
- CI/CD 流水线脚本

13
souls/README.md Normal file
View File

@@ -0,0 +1,13 @@
# souls/ — 团队成员档案
虚拟团队成员的角色定义文件,每个 `.md` 文件描述一位成员的背景、专长和职责。
## 成员
团队采用 AI 驱动开发模式,成员由 Claude Opus 4.6 模型扮演,各有分工。
## 子目录
| 目录 | 说明 |
|------|------|
| `archived/` | 已归档(淘汰)的成员档案 |

3
souls/archived/README.md Normal file
View File

@@ -0,0 +1,3 @@
# archived/ — 已归档成员
末尾淘汰的团队成员档案存放于此。

8
src/README.md Normal file
View File

@@ -0,0 +1,8 @@
# src/ — ShowenV2 源代码
## 目录结构
- `core/` — 插件微内核:消息协议、服务管理、配置、动态插件系统
- `plugins/` — 功能插件视频播放、HTTP API、BLE 配网、WiFi、屏幕管理
- `lib.rs` — 库入口,导出 `core``plugins` 模块
- `main.rs` — 可执行入口,注册插件并启动主循环

39
src/core/README.md Normal file
View File

@@ -0,0 +1,39 @@
# core/ — 插件微内核
ShowenV2 的核心框架,所有插件通过此内核注册、通信和管理。
## 模块说明
| 文件 | 说明 |
|------|------|
| `message.rs` | 类型安全消息协议:`Envelope` / `Destination` / `Message` 枚举,全部支持 Serialize/Deserialize |
| `plugin.rs` | `Plugin` trait 定义 + `PluginInfo` / `PluginContext` |
| `service_manager.rs` | 中央调度器:插件注册、依赖拓扑排序、消息路由、动态插件错误策略 (PluginState) |
| `config.rs` | `AppConfig` 配置解析/验证,支持状态机、转场、显示参数等 |
| `dispatch.rs` | 文本命令解析器BLE/WebSocket 用),转换为类型安全的 `Envelope` |
| `plugin_abi.rs` | C FFI 边界类型:`PluginVTable` / `FfiString` / `FfiResult` / `SendCallback` |
| `dynamic_plugin.rs` | `DynamicPlugin`:通过 `libloading` 加载 `.so`,实现 `Plugin` traitJSON 序列化跨 FFI |
| `plugin_loader.rs` | 扫描 `plugin_store/` 目录,解析 `manifest.json` / `registry.json`,加载动态插件 |
| `version_manager.rs` | 版本管理:切换、回退、稳定标记、垃圾回收 |
| `plugin_repo.rs` | 远程插件仓库 HTTP 客户端:下载 tar.gz、校验、安装 |
| `tests.rs` | 核心模块单元测试 |
## 消息流
```
插件 A → Envelope{from, to, message} → ServiceManager → 插件 B
Manager 消息处理
(Shutdown/ConfigReload/...)
```
## 动态插件加载流
```
plugin_store/
├── registry.json ← PluginLoader 读取
└── my-plugin/
└── 1.0.0/
├── manifest.json ← 清单 (id, version, dependencies, error_policy)
└── libmy_plugin.so ← DynamicPlugin 通过 libloading 加载
```

316
src/core/dispatch.rs Normal file
View File

@@ -0,0 +1,316 @@
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
/// 命令解析结果
pub struct DispatchResult {
pub envelope: Envelope,
}
/// 解析文本命令为 DispatchResult。
///
/// `ssid_hint` / `password_hint` 仅用于不含参数的 `connect` / `ap_start`BLE 先写
/// SSID/Password 特征,再写 Command。带参数版本 `connect:SSID:PASS` 直接解析内联值。
pub fn parse_command(
command: &str,
from: &str,
ssid_hint: &str,
password_hint: &str,
) -> Result<DispatchResult, String> {
let command = command.trim();
if command.is_empty() {
return Err("empty command".into());
}
// 尝试按冒号拆分以获取命令名和参数
let (cmd, rest) = match command.find(':') {
Some(pos) => (&command[..pos], Some(&command[pos + 1..])),
None => (command, None),
};
match cmd {
// ── 播放控制 ──
"play" => ok_video(from, Message::PlayerCommand(PlayerCommand::Play)),
"pause" => ok_video(from, Message::PlayerCommand(PlayerCommand::Pause)),
"next" => ok_video(from, Message::PlayerCommand(PlayerCommand::Next)),
"prev" => ok_video(from, Message::PlayerCommand(PlayerCommand::Previous)),
"goto" => {
let index_str = rest.ok_or("goto requires index (e.g. goto:3)")?;
let index: usize = index_str
.parse()
.map_err(|_| format!("invalid index: {index_str}"))?;
ok_video(from, Message::PlayerCommand(PlayerCommand::Goto(index)))
}
"scene" => {
let name = rest.ok_or("scene requires name (e.g. scene:idle)")?;
if name.is_empty() {
return Err("scene name cannot be empty".into());
}
ok_video(
from,
Message::PlayerCommand(PlayerCommand::ChangeScene(name.to_string())),
)
}
"trigger" => {
let params = rest.ok_or("trigger requires name:value (e.g. trigger:voice:name)")?;
let (name, value) = match params.find(':') {
Some(pos) => (&params[..pos], &params[pos + 1..]),
None => (params, ""),
};
if name.is_empty() {
return Err("trigger name cannot be empty".into());
}
ok_video(
from,
Message::Trigger {
name: name.to_string(),
value: value.to_string(),
},
)
}
// ── WiFi 命令 ──
"scan" => ok_wifi(from, Message::WifiCommand(WifiCommand::Scan)),
"status" => ok_wifi(from, Message::WifiCommand(WifiCommand::Status)),
"connect" => {
let (ssid, password) = parse_wifi_credentials(rest, ssid_hint, password_hint);
if ssid.is_empty() {
return Err("ssid required for connect".into());
}
ok_wifi(
from,
Message::WifiCommand(WifiCommand::Connect { ssid, password }),
)
}
"ap_start" => {
let (ssid, password) = parse_wifi_credentials(rest, ssid_hint, password_hint);
if ssid.is_empty() {
return Err("ssid required for ap_start".into());
}
ok_wifi(
from,
Message::WifiCommand(WifiCommand::ApStart { ssid, password }),
)
}
"ap_stop" => ok_wifi(from, Message::WifiCommand(WifiCommand::ApStop)),
// ── 配置 ──
"config_reload" => Ok(DispatchResult {
envelope: Envelope {
from: from.to_string(),
to: Destination::Manager,
message: Message::ConfigReloadRequest,
},
}),
_ => Err(format!("unsupported command: {cmd}")),
}
}
/// 解析 WiFi 凭据:优先使用内联参数 `SSID:PASS`,回退到 hint 值。
/// 密码中可能包含冒号,因此用 `splitn(2, ':')` 拆分。
fn parse_wifi_credentials(
rest: Option<&str>,
ssid_hint: &str,
password_hint: &str,
) -> (String, String) {
match rest {
Some(params) if !params.is_empty() => {
let mut parts = params.splitn(2, ':');
let ssid = parts.next().unwrap_or("").to_string();
let password = parts.next().unwrap_or("").to_string();
if ssid.is_empty() {
(ssid_hint.to_string(), password_hint.to_string())
} else {
(ssid, password)
}
}
_ => (ssid_hint.to_string(), password_hint.to_string()),
}
}
fn ok_video(from: &str, message: Message) -> Result<DispatchResult, String> {
Ok(DispatchResult {
envelope: Envelope {
from: from.to_string(),
to: Destination::Plugin("video".to_string()),
message,
},
})
}
fn ok_wifi(from: &str, message: Message) -> Result<DispatchResult, String> {
Ok(DispatchResult {
envelope: Envelope {
from: from.to_string(),
to: Destination::Plugin("wifi".to_string()),
message,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn dispatch(cmd: &str) -> Result<DispatchResult, String> {
parse_command(cmd, "test", "", "")
}
fn dispatch_with_hints(cmd: &str, ssid: &str, pass: &str) -> Result<DispatchResult, String> {
parse_command(cmd, "test", ssid, pass)
}
#[test]
fn test_player_commands() {
assert!(matches!(
dispatch("play").unwrap().envelope.message,
Message::PlayerCommand(PlayerCommand::Play)
));
assert!(matches!(
dispatch("pause").unwrap().envelope.message,
Message::PlayerCommand(PlayerCommand::Pause)
));
assert!(matches!(
dispatch("next").unwrap().envelope.message,
Message::PlayerCommand(PlayerCommand::Next)
));
assert!(matches!(
dispatch("prev").unwrap().envelope.message,
Message::PlayerCommand(PlayerCommand::Previous)
));
}
#[test]
fn test_goto() {
let result = dispatch("goto:5").unwrap();
assert!(matches!(
result.envelope.message,
Message::PlayerCommand(PlayerCommand::Goto(5))
));
assert!(dispatch("goto").is_err());
assert!(dispatch("goto:abc").is_err());
}
#[test]
fn test_scene() {
let result = dispatch("scene:idle").unwrap();
assert!(matches!(
result.envelope.message,
Message::PlayerCommand(PlayerCommand::ChangeScene(ref name)) if name == "idle"
));
assert!(dispatch("scene").is_err());
assert!(dispatch("scene:").is_err());
}
#[test]
fn test_trigger() {
let result = dispatch("trigger:voice:name").unwrap();
assert!(matches!(
result.envelope.message,
Message::Trigger { ref name, ref value } if name == "voice" && value == "name"
));
// trigger without value
let result = dispatch("trigger:button").unwrap();
assert!(matches!(
result.envelope.message,
Message::Trigger { ref name, ref value } if name == "button" && value == ""
));
assert!(dispatch("trigger").is_err());
assert!(dispatch("trigger:").is_err());
}
#[test]
fn test_wifi_scan_status() {
assert!(matches!(
dispatch("scan").unwrap().envelope.message,
Message::WifiCommand(WifiCommand::Scan)
));
assert!(matches!(
dispatch("status").unwrap().envelope.message,
Message::WifiCommand(WifiCommand::Status)
));
}
#[test]
fn test_connect_with_hint() {
let result = dispatch_with_hints("connect", "MySSID", "pass123").unwrap();
assert!(matches!(
result.envelope.message,
Message::WifiCommand(WifiCommand::Connect { ref ssid, ref password })
if ssid == "MySSID" && password == "pass123"
));
}
#[test]
fn test_connect_inline() {
let result = dispatch("connect:MySSID:p@ss:w0rd").unwrap();
assert!(matches!(
result.envelope.message,
Message::WifiCommand(WifiCommand::Connect { ref ssid, ref password })
if ssid == "MySSID" && password == "p@ss:w0rd"
));
}
#[test]
fn test_connect_no_ssid() {
assert!(dispatch("connect").is_err());
}
#[test]
fn test_ap_start_inline() {
let result = dispatch("ap_start:showen:12345678").unwrap();
assert!(matches!(
result.envelope.message,
Message::WifiCommand(WifiCommand::ApStart { ref ssid, ref password })
if ssid == "showen" && password == "12345678"
));
}
#[test]
fn test_ap_stop() {
assert!(matches!(
dispatch("ap_stop").unwrap().envelope.message,
Message::WifiCommand(WifiCommand::ApStop)
));
}
#[test]
fn test_config_reload() {
let result = dispatch("config_reload").unwrap();
assert!(matches!(
result.envelope.message,
Message::ConfigReloadRequest
));
assert!(matches!(result.envelope.to, Destination::Manager));
}
#[test]
fn test_routing() {
let r = dispatch("play").unwrap();
assert!(matches!(r.envelope.to, Destination::Plugin(ref id) if id == "video"));
let r = dispatch("scan").unwrap();
assert!(matches!(r.envelope.to, Destination::Plugin(ref id) if id == "wifi"));
}
#[test]
fn test_unsupported() {
assert!(dispatch("unknown").is_err());
assert!(dispatch("").is_err());
}
#[test]
fn test_whitespace_trimmed() {
assert!(matches!(
dispatch(" play ").unwrap().envelope.message,
Message::PlayerCommand(PlayerCommand::Play)
));
}
}

207
src/core/dynamic_plugin.rs Normal file
View File

@@ -0,0 +1,207 @@
//! DynamicPlugin — 将动态加载的 .so 包装为 Plugin trait 实现
//!
//! 通过 C FFI + JSON 序列化与 .so 插件通信。
//! 对 ServiceManager 而言DynamicPlugin 与静态插件无区别。
use crate::core::message::{Envelope, Message};
use crate::core::plugin::{Plugin, PluginContext, PluginInfo};
use crate::core::plugin_abi::{
ffi_str_to_str, FfiResult, FfiString, PluginHandle, PluginVTable,
PLUGIN_VTABLE_SYMBOL,
};
use anyhow::{anyhow, Context, Result};
use libloading::Library;
use std::ffi::CString;
use std::sync::mpsc;
/// 动态加载的插件
pub struct DynamicPlugin {
/// 保持 .so 文件加载RAII
_library: Library,
/// FFI 虚函数表
vtable: &'static PluginVTable,
/// 插件实例句柄
handle: PluginHandle,
/// 缓存的插件信息
info: PluginInfo,
/// 插件 ID
id: String,
/// 依赖列表
dependencies: Vec<String>,
/// .so 文件路径(用于调试/日志)
so_path: String,
}
// PluginHandle 是 *mut c_void需要手动声明 Send
// 安全性由插件 SDK 的 ShowenPlugin: Send 约束保证
unsafe impl Send for DynamicPlugin {}
impl DynamicPlugin {
/// 从 .so 文件加载插件
///
/// # Safety
/// .so 文件必须是由 showen-plugin-sdk 编译的合法插件
pub unsafe fn load(
so_path: &str,
dependencies: Vec<String>,
) -> Result<Self> {
let library = unsafe {
Library::new(so_path)
.with_context(|| format!("failed to load plugin .so: {so_path}"))?
};
// 查找 vtable 符号
let vtable: &'static PluginVTable = unsafe {
let symbol = library
.get::<*const PluginVTable>(PLUGIN_VTABLE_SYMBOL)
.with_context(|| {
format!("symbol 'showen_plugin_vtable' not found in {so_path}")
})?;
&**symbol
};
// 创建插件实例
let handle = unsafe { (vtable.create)() };
if handle.is_null() {
return Err(anyhow!("plugin create() returned null for {so_path}"));
}
// 获取插件信息
let info_ffi: FfiString = unsafe { (vtable.get_info)(handle) };
let info_json = unsafe { info_ffi.into_string() }
.ok_or_else(|| anyhow!("plugin get_info() returned null for {so_path}"))?;
let info: PluginInfo = serde_json::from_str(&info_json)
.with_context(|| format!("invalid plugin info JSON from {so_path}"))?;
let id = info.name.clone();
Ok(Self {
_library: library,
vtable,
handle,
info,
id,
dependencies,
so_path: so_path.to_string(),
})
}
/// 设置插件 ID由 manifest 中的 id 字段覆盖)
pub fn set_id(&mut self, id: String) {
self.id = id;
}
/// 获取 .so 文件路径
pub fn so_path(&self) -> &str {
&self.so_path
}
/// 将 FfiResult 转为 anyhow::Result
unsafe fn check_result(&self, result: FfiResult, operation: &str) -> Result<()> {
unsafe { result.into_result() }.map_err(|e| {
anyhow!(
"plugin '{}' {} failed: {}",
self.id,
operation,
e
)
})
}
}
impl Plugin for DynamicPlugin {
fn id(&self) -> &str {
&self.id
}
fn info(&self) -> PluginInfo {
self.info.clone()
}
fn dependencies(&self) -> Vec<String> {
self.dependencies.clone()
}
fn init(&mut self, ctx: PluginContext) -> Result<()> {
// 序列化配置为 JSON
let config_json = serde_json::to_string(ctx.config.as_ref())
.context("failed to serialize config for dynamic plugin")?;
let config_cstr = CString::new(config_json)
.context("config JSON contains null byte")?;
// 创建 SendCallback — 将 mpsc::Sender 转为 C 函数指针
// 使用 thread_local 存储 sender每次 init 更新)
PLUGIN_SENDER.with(|cell| {
*cell.borrow_mut() = Some(ctx.tx);
});
let result = unsafe {
(self.vtable.init)(self.handle, config_cstr.as_ptr(), ffi_send_callback)
};
unsafe { self.check_result(result, "init") }
}
fn start(&mut self) -> Result<()> {
let result = unsafe { (self.vtable.start)(self.handle) };
unsafe { self.check_result(result, "start") }
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
let msg_json = serde_json::to_string(&msg)
.context("failed to serialize message for dynamic plugin")?;
let msg_cstr = CString::new(msg_json)
.context("message JSON contains null byte")?;
let result = unsafe {
(self.vtable.handle_message)(self.handle, msg_cstr.as_ptr())
};
unsafe { self.check_result(result, "handle_message") }
}
fn stop(&mut self) -> Result<()> {
let result = unsafe { (self.vtable.stop)(self.handle) };
unsafe { self.check_result(result, "stop") }
}
}
impl Drop for DynamicPlugin {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe { (self.vtable.destroy)(self.handle) };
}
}
}
// ── SendCallback 实现 ──
thread_local! {
static PLUGIN_SENDER: std::cell::RefCell<Option<mpsc::Sender<Envelope>>> =
std::cell::RefCell::new(None);
}
/// C FFI 回调:插件调用此函数向主程序发消息
unsafe extern "C" fn ffi_send_callback(envelope_json: crate::core::plugin_abi::FfiStr) {
let json_str = match unsafe { ffi_str_to_str(envelope_json) } {
Some(s) => s,
None => {
eprintln!("[DynamicPlugin] send callback received null JSON");
return;
}
};
let envelope: Envelope = match serde_json::from_str(json_str) {
Ok(e) => e,
Err(e) => {
eprintln!("[DynamicPlugin] invalid envelope JSON: {e}");
return;
}
};
PLUGIN_SENDER.with(|cell| {
if let Some(tx) = cell.borrow().as_ref() {
if let Err(e) = tx.send(envelope) {
eprintln!("[DynamicPlugin] failed to send envelope: {e}");
}
}
});
}

View File

@@ -1,19 +1,20 @@
use crate::core::config::AppConfig; use crate::core::config::AppConfig;
use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
/// 消息信封:包含来源、目的地、消息体 /// 消息信封:包含来源、目的地、消息体
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope { pub struct Envelope {
pub from: &'static str, pub from: String,
pub to: Destination, pub to: Destination,
pub message: Message, pub message: Message,
} }
/// 消息目的地 /// 消息目的地
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Destination { pub enum Destination {
/// 点对点发送给指定插件 /// 点对点发送给指定插件
Plugin(&'static str), Plugin(String),
/// 广播给所有插件 /// 广播给所有插件
Broadcast, Broadcast,
/// 发给管理层自身 /// 发给管理层自身
@@ -21,7 +22,7 @@ pub enum Destination {
} }
/// 所有插件间通信的类型安全消息 /// 所有插件间通信的类型安全消息
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Message { pub enum Message {
// ── 播放控制 ── // ── 播放控制 ──
PlayerCommand(PlayerCommand), PlayerCommand(PlayerCommand),
@@ -48,12 +49,14 @@ pub enum Message {
}, },
// ── 配置 ── // ── 配置 ──
/// Arc<AppConfig> 无法跨 FFI 序列化,动态插件通过 init 时传入的 JSON 获取配置
#[serde(skip)]
ConfigReloaded(Arc<AppConfig>), ConfigReloaded(Arc<AppConfig>),
ConfigReloadRequest, ConfigReloadRequest,
// ── 系统 ── // ── 系统 ──
Shutdown, Shutdown,
PluginReady(&'static str), PluginReady(String),
// ── 扩展(未来插件用) ── // ── 扩展(未来插件用) ──
Custom { Custom {
@@ -62,7 +65,7 @@ pub enum Message {
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerCommand { pub enum PlayerCommand {
Play, Play,
Pause, Pause,
@@ -72,7 +75,7 @@ pub enum PlayerCommand {
ChangeScene(String), ChangeScene(String),
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerStatusData { pub struct PlayerStatusData {
pub running: bool, pub running: bool,
pub paused: bool, pub paused: bool,
@@ -82,7 +85,7 @@ pub struct PlayerStatusData {
pub current_video: Option<String>, pub current_video: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WifiCommand { pub enum WifiCommand {
Scan, Scan,
Connect { ssid: String, password: String }, Connect { ssid: String, password: String },

View File

@@ -1,7 +1,13 @@
pub mod config; pub mod config;
pub mod dispatch;
pub mod dynamic_plugin;
pub mod message; pub mod message;
pub mod plugin; pub mod plugin;
pub mod plugin_abi;
pub mod plugin_loader;
pub mod plugin_repo;
pub mod service_manager; pub mod service_manager;
pub mod version_manager;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,18 +1,19 @@
use crate::core::config::AppConfig; use crate::core::config::AppConfig;
use crate::core::message::{Envelope, Message}; use crate::core::message::{Envelope, Message};
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::sync::{mpsc, Arc}; use std::sync::{mpsc, Arc};
/// 所有功能都通过实现此 trait 接入系统 /// 所有功能都通过实现此 trait 接入系统
pub trait Plugin: Send { pub trait Plugin: Send {
/// 唯一标识 (如 "video", "http", "ble") /// 唯一标识 (如 "video", "http", "ble")
fn id(&self) -> &'static str; fn id(&self) -> &str;
/// 插件信息 /// 插件信息
fn info(&self) -> PluginInfo; fn info(&self) -> PluginInfo;
/// 声明启动所需的插件依赖 /// 声明启动所需的插件依赖
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec![] vec![]
} }
@@ -29,14 +30,15 @@ pub trait Plugin: Send {
fn stop(&mut self) -> Result<()>; fn stop(&mut self) -> Result<()>;
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo { pub struct PluginInfo {
pub name: &'static str, pub name: String,
pub version: &'static str, pub version: String,
pub description: &'static str, pub description: String,
pub platform: Platform, pub platform: Platform,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Platform { pub enum Platform {
Any, Any,
Linux, Linux,

152
src/core/plugin_abi.rs Normal file
View File

@@ -0,0 +1,152 @@
//! FFI 边界类型定义
//!
//! 主程序与动态插件 (.so) 之间的 C ABI 契约。
//! 所有跨边界数据通过 JSON 字符串传递,避免 Rust ABI 不稳定问题。
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::ptr;
/// 插件实例的不透明句柄
pub type PluginHandle = *mut c_void;
/// FFI 安全的字符串:指向 C 字符串 + 长度
/// 调用方负责释放(通过对应的 free 函数)
#[repr(C)]
pub struct FfiString {
pub ptr: *mut c_char,
pub len: usize,
}
impl FfiString {
/// 从 Rust String 创建 FfiString转移所有权到 C 侧)
pub fn from_string(s: String) -> Self {
match CString::new(s) {
Ok(cstr) => {
let len = cstr.as_bytes().len();
Self {
ptr: cstr.into_raw(),
len,
}
}
Err(_) => Self::null(),
}
}
/// 空字符串
pub fn null() -> Self {
Self {
ptr: ptr::null_mut(),
len: 0,
}
}
/// 转换回 Rust String消耗 FfiString
///
/// # Safety
/// ptr 必须是由 CString::into_raw 产生的有效指针
pub unsafe fn into_string(self) -> Option<String> {
if self.ptr.is_null() {
return None;
}
let cstr = unsafe { CString::from_raw(self.ptr) };
cstr.into_string().ok()
}
}
/// 只读 FFI 字符串切片(借用,不转移所有权)
pub type FfiStr = *const c_char;
/// FFI 调用结果
#[repr(C)]
pub struct FfiResult {
/// 0 = 成功, 非零 = 失败
pub code: c_int,
/// 错误信息(成功时为 null
pub error: FfiString,
}
impl FfiResult {
pub fn ok() -> Self {
Self {
code: 0,
error: FfiString::null(),
}
}
pub fn err(msg: String) -> Self {
Self {
code: -1,
error: FfiString::from_string(msg),
}
}
/// 转换为 Rust Result
///
/// # Safety
/// 如果 error 非 null必须是由 CString::into_raw 产生的有效指针
pub unsafe fn into_result(self) -> Result<(), String> {
if self.code == 0 {
Ok(())
} else {
let msg = unsafe { self.error.into_string() }
.unwrap_or_else(|| "unknown plugin error".to_string());
Err(msg)
}
}
}
/// 插件向主程序发消息的回调函数类型
/// envelope_json: JSON 序列化的 Envelope
pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr);
/// 插件虚函数表 — 每个动态插件导出一个此结构体
#[repr(C)]
pub struct PluginVTable {
/// 创建插件实例,返回不透明句柄
pub create: unsafe extern "C" fn() -> PluginHandle,
/// 获取插件信息(返回 JSON 序列化的 PluginInfo
pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
/// 初始化插件
/// config_json: 完整的 AppConfig JSON
/// send_cb: 发送消息的回调
pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult,
/// 启动插件
pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
/// 处理消息
/// message_json: JSON 序列化的 Message
pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult,
/// 停止插件
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
/// 销毁插件实例,释放资源
pub destroy: unsafe extern "C" fn(handle: PluginHandle),
}
/// 动态插件 .so 中导出的符号名称
pub const PLUGIN_VTABLE_SYMBOL: &[u8] = b"showen_plugin_vtable\0";
/// 从 FfiStr 安全读取为 &str
///
/// # Safety
/// ptr 必须指向有效的 null-terminated C 字符串
pub unsafe fn ffi_str_to_str<'a>(ptr: FfiStr) -> Option<&'a str> {
if ptr.is_null() {
return None;
}
unsafe { CStr::from_ptr(ptr) }.to_str().ok()
}
/// 释放 FfiString 占用的内存
///
/// # Safety
/// ptr 必须是由 FfiString::from_string 创建的
pub unsafe fn ffi_string_free(s: FfiString) {
if !s.ptr.is_null() {
drop(unsafe { CString::from_raw(s.ptr) });
}
}

336
src/core/plugin_loader.rs Normal file
View File

@@ -0,0 +1,336 @@
//! PluginLoader — 扫描 plugin_store/ 目录,发现并加载动态插件
//!
//! 目录结构:
//! ```text
//! plugin_store/
//! ├── registry.json
//! └── custom-sensor/
//! ├── 1.0.0/
//! │ ├── manifest.json
//! │ └── libcustom_sensor.so
//! └── 1.1.0/
//! ├── manifest.json
//! └── libcustom_sensor.so
//! ```
use crate::core::dynamic_plugin::DynamicPlugin;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// 插件清单 (manifest.json)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub version: String,
pub sdk_version: String,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default = "default_error_policy")]
pub error_policy: ErrorPolicy,
pub so_filename: String,
}
fn default_error_policy() -> ErrorPolicy {
ErrorPolicy::AutoRollback
}
/// 插件错误处理策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorPolicy {
/// 自动回退到上一个稳定版本
AutoRollback,
/// 禁用插件并记录日志
DisableAndLog,
}
/// 全局注册表 (registry.json)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginRegistry {
#[serde(default)]
pub plugins: std::collections::HashMap<String, PluginRegistryEntry>,
}
/// 注册表中每个插件的条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRegistryEntry {
pub active_version: String,
#[serde(default)]
pub last_stable_version: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_error_policy")]
pub error_policy: ErrorPolicy,
#[serde(default = "default_max_errors")]
pub max_errors: u32,
}
fn default_true() -> bool {
true
}
fn default_max_errors() -> u32 {
5
}
/// 插件加载器
pub struct PluginLoader {
store_path: PathBuf,
}
impl PluginLoader {
pub fn new(store_path: impl Into<PathBuf>) -> Self {
Self {
store_path: store_path.into(),
}
}
/// 获取存储路径
pub fn store_path(&self) -> &Path {
&self.store_path
}
/// 读取全局注册表
pub fn load_registry(&self) -> Result<PluginRegistry> {
let registry_path = self.store_path.join("registry.json");
if !registry_path.exists() {
return Ok(PluginRegistry::default());
}
let content = std::fs::read_to_string(&registry_path)
.with_context(|| format!("failed to read {}", registry_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", registry_path.display()))
}
/// 保存全局注册表
pub fn save_registry(&self, registry: &PluginRegistry) -> Result<()> {
let registry_path = self.store_path.join("registry.json");
let content = serde_json::to_string_pretty(registry)
.context("failed to serialize registry")?;
std::fs::write(&registry_path, content)
.with_context(|| format!("failed to write {}", registry_path.display()))
}
/// 发现所有可用插件(扫描目录)
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
let mut manifests = Vec::new();
if !self.store_path.exists() {
return Ok(manifests);
}
for entry in std::fs::read_dir(&self.store_path)
.with_context(|| format!("failed to read {}", self.store_path.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
// 跳过非插件文件(如 registry.json
let _plugin_id = match path.file_name().and_then(|n| n.to_str()) {
Some(name) if name != "registry.json" => name.to_string(),
_ => continue,
};
// 扫描版本子目录
for version_entry in std::fs::read_dir(&path)? {
let version_entry = version_entry?;
let version_path = version_entry.path();
if !version_path.is_dir() {
continue;
}
let manifest_path = version_path.join("manifest.json");
if manifest_path.exists() {
match self.read_manifest(&manifest_path) {
Ok(manifest) => manifests.push(manifest),
Err(e) => {
eprintln!(
"[PluginLoader] 跳过无效清单 {}: {e}",
manifest_path.display()
);
}
}
}
}
}
Ok(manifests)
}
/// 读取插件清单
fn read_manifest(&self, path: &Path) -> Result<PluginManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))
}
/// 加载指定插件
/// version: None 表示使用注册表中的 active_version
pub fn load_plugin(
&self,
plugin_id: &str,
version: Option<&str>,
) -> Result<(DynamicPlugin, PluginManifest)> {
let version = match version {
Some(v) => v.to_string(),
None => {
let registry = self.load_registry()?;
registry
.plugins
.get(plugin_id)
.map(|e| e.active_version.clone())
.ok_or_else(|| {
anyhow!("plugin '{plugin_id}' not found in registry")
})?
}
};
let version_dir = self.store_path.join(plugin_id).join(&version);
let manifest_path = version_dir.join("manifest.json");
let manifest = self.read_manifest(&manifest_path)?;
let so_path = version_dir.join(&manifest.so_filename);
if !so_path.exists() {
return Err(anyhow!(
"plugin .so not found: {}",
so_path.display()
));
}
let so_path_str = so_path.to_string_lossy().to_string();
let mut plugin = unsafe {
DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())?
};
plugin.set_id(manifest.id.clone());
Ok((plugin, manifest))
}
/// 列出插件的所有已安装版本
pub fn list_versions(&self, plugin_id: &str) -> Result<Vec<String>> {
let plugin_dir = self.store_path.join(plugin_id);
if !plugin_dir.exists() {
return Ok(vec![]);
}
let mut versions = Vec::new();
for entry in std::fs::read_dir(&plugin_dir)? {
let entry = entry?;
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
versions.push(name.to_string());
}
}
}
versions.sort();
Ok(versions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_test_store(base: &Path) {
let plugin_dir = base.join("test-plugin").join("1.0.0");
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = PluginManifest {
id: "test-plugin".to_string(),
version: "1.0.0".to_string(),
sdk_version: "0.2.0".to_string(),
dependencies: vec![],
error_policy: ErrorPolicy::AutoRollback,
so_filename: "libtest_plugin.so".to_string(),
};
fs::write(
plugin_dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
}
#[test]
fn discover_plugins_finds_manifests() {
let tmp = std::env::temp_dir().join("showen_test_discover");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
setup_test_store(&tmp);
let loader = PluginLoader::new(&tmp);
let manifests = loader.discover_plugins().unwrap();
assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].id, "test-plugin");
assert_eq!(manifests[0].version, "1.0.0");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn empty_store_returns_no_plugins() {
let tmp = std::env::temp_dir().join("showen_test_empty");
let _ = fs::remove_dir_all(&tmp);
let loader = PluginLoader::new(&tmp);
let manifests = loader.discover_plugins().unwrap();
assert!(manifests.is_empty());
}
#[test]
fn registry_round_trip() {
let tmp = std::env::temp_dir().join("showen_test_registry");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let loader = PluginLoader::new(&tmp);
let mut registry = PluginRegistry::default();
registry.plugins.insert(
"test-plugin".to_string(),
PluginRegistryEntry {
active_version: "1.0.0".to_string(),
last_stable_version: None,
enabled: true,
error_policy: ErrorPolicy::AutoRollback,
max_errors: 5,
},
);
loader.save_registry(&registry).unwrap();
let loaded = loader.load_registry().unwrap();
assert_eq!(loaded.plugins.len(), 1);
let entry = &loaded.plugins["test-plugin"];
assert_eq!(entry.active_version, "1.0.0");
assert!(entry.enabled);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn list_versions_returns_sorted() {
let tmp = std::env::temp_dir().join("showen_test_versions");
let _ = fs::remove_dir_all(&tmp);
let plugin_dir = tmp.join("my-plugin");
fs::create_dir_all(plugin_dir.join("1.1.0")).unwrap();
fs::create_dir_all(plugin_dir.join("1.0.0")).unwrap();
fs::create_dir_all(plugin_dir.join("2.0.0")).unwrap();
let loader = PluginLoader::new(&tmp);
let versions = loader.list_versions("my-plugin").unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]);
let _ = fs::remove_dir_all(&tmp);
}
}

186
src/core/plugin_repo.rs Normal file
View File

@@ -0,0 +1,186 @@
//! PluginRepository — 远程插件仓库客户端
//!
//! 从 HTTP 文件服务器下载插件包 (.tar.gz),解压安装到 plugin_store/。
//!
//! 远程仓库协议(简单 HTTP 文件服务):
//! ```text
//! https://plugins.example.com/
//! ├── index.json # 插件目录
//! ├── custom-sensor/
//! │ ├── latest.json # {"version": "1.1.0"}
//! │ └── 1.1.0.tar.gz # 包含 manifest.json + .so
//! ```
use crate::core::plugin_loader::PluginLoader;
use anyhow::{anyhow, Context, Result};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use std::io::Read;
/// 远程仓库索引
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoIndex {
pub plugins: Vec<RepoPluginEntry>,
}
/// 远程仓库中的插件条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoPluginEntry {
pub id: String,
pub latest_version: String,
pub description: String,
#[serde(default)]
pub versions: Vec<String>,
}
/// 远程仓库最新版本查询响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LatestVersion {
pub version: String,
}
/// 远程插件仓库客户端
pub struct PluginRepository {
base_url: String,
loader: PluginLoader,
}
impl PluginRepository {
pub fn new(base_url: &str, loader: PluginLoader) -> Self {
let base_url = base_url.trim_end_matches('/').to_string();
Self { base_url, loader }
}
pub fn loader(&self) -> &PluginLoader {
&self.loader
}
/// 获取远程仓库索引
pub fn fetch_index(&self) -> Result<RepoIndex> {
let url = format!("{}/index.json", self.base_url);
let response = ureq::get(&url)
.call()
.with_context(|| format!("failed to fetch repo index from {url}"))?;
let body = response
.into_string()
.context("failed to read repo index body")?;
serde_json::from_str(&body).context("failed to parse repo index")
}
/// 检查指定插件是否有新版本
/// 返回 Some(新版本号) 或 None已是最新
pub fn check_update(&self, plugin_id: &str, current_version: &str) -> Result<Option<String>> {
let url = format!("{}/{}/latest.json", self.base_url, plugin_id);
let response = ureq::get(&url)
.call()
.with_context(|| format!("failed to check update for '{plugin_id}'"))?;
let body = response.into_string()?;
let latest: LatestVersion = serde_json::from_str(&body)?;
let current = semver::Version::parse(current_version)
.with_context(|| format!("invalid current version: {current_version}"))?;
let remote = semver::Version::parse(&latest.version)
.with_context(|| format!("invalid remote version: {}", latest.version))?;
if remote > current {
Ok(Some(latest.version))
} else {
Ok(None)
}
}
/// 下载并安装插件到 plugin_store/
pub fn download_and_install(
&self,
plugin_id: &str,
version: &str,
) -> Result<()> {
let url = format!("{}/{}/{}.tar.gz", self.base_url, plugin_id, version);
println!(
"[PluginRepo] 下载插件 '{plugin_id}' v{version}{url}"
);
let response = ureq::get(&url)
.call()
.with_context(|| format!("failed to download {url}"))?;
// 读取响应体
let mut body = Vec::new();
response
.into_reader()
.read_to_end(&mut body)
.context("failed to read download body")?;
// 解压 tar.gz 到临时目录
let target_dir = self
.loader
.store_path()
.join(plugin_id)
.join(version);
if target_dir.exists() {
return Err(anyhow!(
"version {version} already installed for plugin '{plugin_id}'"
));
}
std::fs::create_dir_all(&target_dir).with_context(|| {
format!("failed to create {}", target_dir.display())
})?;
// 解压 tar.gz
let gz = GzDecoder::new(body.as_slice());
let mut archive = tar::Archive::new(gz);
archive
.unpack(&target_dir)
.with_context(|| format!("failed to unpack archive to {}", target_dir.display()))?;
// 验证 manifest.json 存在
let manifest_path = target_dir.join("manifest.json");
if !manifest_path.exists() {
// 清理
let _ = std::fs::remove_dir_all(&target_dir);
return Err(anyhow!(
"downloaded archive for '{plugin_id}' v{version} missing manifest.json"
));
}
println!(
"[PluginRepo] 插件 '{plugin_id}' v{version} 安装成功到 {}",
target_dir.display()
);
Ok(())
}
/// 批量检查所有已安装插件的更新
pub fn check_all_updates(
&self,
) -> Result<Vec<(String, String, String)>> {
// (plugin_id, current_version, new_version)
let registry = self.loader.load_registry()?;
let mut updates = Vec::new();
for (plugin_id, entry) in &registry.plugins {
match self.check_update(plugin_id, &entry.active_version) {
Ok(Some(new_version)) => {
updates.push((
plugin_id.clone(),
entry.active_version.clone(),
new_version,
));
}
Ok(None) => {}
Err(e) => {
eprintln!(
"[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}"
);
}
}
}
Ok(updates)
}
}

View File

@@ -1,13 +1,72 @@
use crate::core::config::AppConfig; use crate::core::config::AppConfig;
use crate::core::message::{Destination, Envelope, Message}; use crate::core::message::{Destination, Envelope, Message};
use crate::core::plugin::{Plugin, PluginContext}; use crate::core::plugin::{Plugin, PluginContext};
use crate::core::plugin_loader::ErrorPolicy;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{mpsc, Arc}; use std::sync::{mpsc, Arc};
/// 插件运行时状态包装
struct PluginState {
plugin: Box<dyn Plugin>,
/// 是否为动态加载的插件
is_dynamic: bool,
/// 错误处理策略
error_policy: ErrorPolicy,
/// 连续错误计数
error_count: u32,
/// 最大允许错误数
max_errors: u32,
/// 是否启用
enabled: bool,
}
impl PluginState {
fn new_static(plugin: Box<dyn Plugin>) -> Self {
Self {
plugin,
is_dynamic: false,
error_policy: ErrorPolicy::DisableAndLog,
error_count: 0,
max_errors: u32::MAX, // 静态插件不自动禁用
enabled: true,
}
}
fn new_dynamic(
plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy,
max_errors: u32,
) -> Self {
Self {
plugin,
is_dynamic: true,
error_policy,
error_count: 0,
max_errors,
enabled: true,
}
}
fn id(&self) -> &str {
self.plugin.id()
}
/// 记录一次错误,返回是否超过阈值
fn record_error(&mut self) -> bool {
self.error_count += 1;
self.error_count >= self.max_errors
}
/// 重置错误计数(成功处理消息后调用)
fn reset_errors(&mut self) {
self.error_count = 0;
}
}
/// 中央调度器:插件注册、生命周期管理、消息路由 /// 中央调度器:插件注册、生命周期管理、消息路由
pub struct ServiceManager { pub struct ServiceManager {
plugins: Vec<Box<dyn Plugin>>, plugins: Vec<PluginState>,
config: Arc<AppConfig>, config: Arc<AppConfig>,
tx: mpsc::Sender<Envelope>, tx: mpsc::Sender<Envelope>,
rx: mpsc::Receiver<Envelope>, rx: mpsc::Receiver<Envelope>,
@@ -26,30 +85,77 @@ impl ServiceManager {
} }
} }
/// 注册插件 /// 注册静态插件(编译时链接的插件)
pub fn register(&mut self, plugin: Box<dyn Plugin>) { pub fn register(&mut self, plugin: Box<dyn Plugin>) {
println!("[ServiceManager] 注册插件: {}", plugin.id()); println!("[ServiceManager] 注册插件: {}", plugin.id());
self.plugins.push(plugin); self.plugins.push(PluginState::new_static(plugin));
}
/// 注册动态插件(运行时加载的 .so 插件)
pub fn register_dynamic(
&mut self,
plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy,
max_errors: u32,
) {
println!(
"[ServiceManager] 注册动态插件: {} (策略: {:?}, 最大错误: {})",
plugin.id(),
error_policy,
max_errors
);
self.plugins
.push(PluginState::new_dynamic(plugin, error_policy, max_errors));
} }
/// 按注册顺序 init() + start() 所有插件 /// 按注册顺序 init() + start() 所有插件
/// 动态插件 init/start 失败时按策略处理,不中断其他插件
pub fn start_all(&mut self) -> Result<()> { pub fn start_all(&mut self) -> Result<()> {
self.validate_and_sort_plugins()?; self.validate_and_sort_plugins()?;
// init // init
for plugin in &mut self.plugins { for state in &mut self.plugins {
let ctx = PluginContext { let ctx = PluginContext {
tx: self.tx.clone(), tx: self.tx.clone(),
config: Arc::clone(&self.config), config: Arc::clone(&self.config),
}; };
println!("[ServiceManager] 初始化插件: {}", plugin.id()); println!("[ServiceManager] 初始化插件: {}", state.id());
plugin.init(ctx)?; if let Err(e) = state.plugin.init(ctx) {
if state.is_dynamic {
eprintln!(
"[ServiceManager] 动态插件 '{}' 初始化失败,禁用: {}",
state.id(),
e
);
state.enabled = false;
continue;
} else {
return Err(e);
}
}
} }
// start // start
for plugin in &mut self.plugins { for state in &mut self.plugins {
println!("[ServiceManager] 启动插件: {}", plugin.id()); if !state.enabled {
plugin.start()?; continue;
}
println!("[ServiceManager] 启动插件: {}", state.id());
if let Err(e) = state.plugin.start() {
if state.is_dynamic {
eprintln!(
"[ServiceManager] 动态插件 '{}' 启动失败,禁用: {}",
state.id(),
e
);
state.enabled = false;
continue;
} else {
return Err(e);
}
}
} }
Ok(()) Ok(())
} }
@@ -69,13 +175,7 @@ impl ServiceManager {
match envelope.to { match envelope.to {
Destination::Plugin(id) => { Destination::Plugin(id) => {
if let Some(plugin) = self.plugins.iter_mut().find(|p| p.id() == id) { self.deliver_to_plugin(&id, envelope.message);
if let Err(e) = plugin.handle_message(envelope.message) {
eprintln!("[ServiceManager] 插件 '{}' 处理消息失败: {}", id, e);
}
} else {
eprintln!("[ServiceManager] 目标插件 '{}' 不存在", id);
}
} }
Destination::Broadcast => { Destination::Broadcast => {
self.broadcast_message(envelope.message); self.broadcast_message(envelope.message);
@@ -92,15 +192,94 @@ impl ServiceManager {
/// 逆序 stop() 所有插件 /// 逆序 stop() 所有插件
pub fn stop_all(&mut self) -> Result<()> { pub fn stop_all(&mut self) -> Result<()> {
println!("[ServiceManager] 停止所有插件"); println!("[ServiceManager] 停止所有插件");
for plugin in self.plugins.iter_mut().rev() { for state in self.plugins.iter_mut().rev() {
println!("[ServiceManager] 停止插件: {}", plugin.id()); if !state.enabled {
if let Err(e) = plugin.stop() { continue;
eprintln!("[ServiceManager] 停止插件 '{}' 失败: {}", plugin.id(), e); }
println!("[ServiceManager] 停止插件: {}", state.id());
if let Err(e) = state.plugin.stop() {
eprintln!(
"[ServiceManager] 停止插件 '{}' 失败: {}",
state.id(),
e
);
} }
} }
Ok(()) Ok(())
} }
/// 启用/禁用指定插件
pub fn set_plugin_enabled(&mut self, plugin_id: &str, enabled: bool) -> Result<()> {
let state = self
.plugins
.iter_mut()
.find(|s| s.id() == plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?;
if enabled && !state.enabled {
// 重新启用reset 错误计数
state.error_count = 0;
state.enabled = true;
println!("[ServiceManager] 插件 '{plugin_id}' 已启用");
} else if !enabled && state.enabled {
state.enabled = false;
println!("[ServiceManager] 插件 '{plugin_id}' 已禁用");
}
Ok(())
}
/// 查询插件状态信息(供 HTTP API 使用)
pub fn plugin_states(&self) -> Vec<PluginStateInfo> {
self.plugins
.iter()
.map(|s| PluginStateInfo {
id: s.id().to_string(),
info: s.plugin.info(),
is_dynamic: s.is_dynamic,
error_policy: s.error_policy.clone(),
error_count: s.error_count,
max_errors: s.max_errors,
enabled: s.enabled,
})
.collect()
}
/// 热替换动态插件stop 旧的 → 替换 → init → start 新的)
pub fn replace_dynamic_plugin(
&mut self,
plugin_id: &str,
new_plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy,
max_errors: u32,
) -> Result<()> {
let idx = self
.plugins
.iter()
.position(|s| s.id() == plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found for replacement"))?;
// Stop old plugin
if self.plugins[idx].enabled {
let _ = self.plugins[idx].plugin.stop();
}
// Replace
let mut new_state = PluginState::new_dynamic(new_plugin, error_policy, max_errors);
// Init new plugin
let ctx = PluginContext {
tx: self.tx.clone(),
config: Arc::clone(&self.config),
};
new_state.plugin.init(ctx)?;
new_state.plugin.start()?;
self.plugins[idx] = new_state;
println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功");
Ok(())
}
/// 处理发给管理层自身的消息 /// 处理发给管理层自身的消息
fn handle_manager_message(&mut self, msg: Message) -> Result<()> { fn handle_manager_message(&mut self, msg: Message) -> Result<()> {
match msg { match msg {
@@ -154,14 +333,14 @@ impl ServiceManager {
let mut plugin_set = HashSet::with_capacity(self.plugins.len()); let mut plugin_set = HashSet::with_capacity(self.plugins.len());
let mut dependency_map = HashMap::with_capacity(self.plugins.len()); let mut dependency_map = HashMap::with_capacity(self.plugins.len());
for plugin in &self.plugins { for state in &self.plugins {
let id = plugin.id(); let id = state.id().to_string();
if !plugin_set.insert(id) { if !plugin_set.insert(id.clone()) {
return Err(anyhow!("duplicate plugin id registered: '{id}'")); return Err(anyhow!("duplicate plugin id registered: '{id}'"));
} }
plugin_ids.push(id); plugin_ids.push(id.clone());
dependency_map.insert(id, plugin.dependencies()); dependency_map.insert(id, state.plugin.dependencies());
} }
for (plugin_id, dependencies) in &dependency_map { for (plugin_id, dependencies) in &dependency_map {
@@ -170,7 +349,7 @@ impl ServiceManager {
return Err(anyhow!("plugin '{plugin_id}' cannot depend on itself")); return Err(anyhow!("plugin '{plugin_id}' cannot depend on itself"));
} }
if !plugin_set.contains(dependency) { if !plugin_set.contains(dependency.as_str()) {
return Err(anyhow!( return Err(anyhow!(
"plugin '{plugin_id}' depends on missing plugin '{dependency}'" "plugin '{plugin_id}' depends on missing plugin '{dependency}'"
)); ));
@@ -197,8 +376,8 @@ impl ServiceManager {
.iter() .iter()
.all(|dependency| resolved.contains(dependency)) .all(|dependency| resolved.contains(dependency))
{ {
resolved.insert(*plugin_id); resolved.insert(plugin_id.clone());
sorted_ids.push(*plugin_id); sorted_ids.push(plugin_id.clone());
progressed = true; progressed = true;
} }
} }
@@ -206,8 +385,8 @@ impl ServiceManager {
if !progressed { if !progressed {
let unresolved = plugin_ids let unresolved = plugin_ids
.iter() .iter()
.copied() .filter(|plugin_id| !resolved.contains(plugin_id.as_str()))
.filter(|plugin_id| !resolved.contains(plugin_id)) .cloned()
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
@@ -220,10 +399,10 @@ impl ServiceManager {
let mut remaining_plugins = std::mem::take(&mut self.plugins); let mut remaining_plugins = std::mem::take(&mut self.plugins);
let mut ordered_plugins = Vec::with_capacity(remaining_plugins.len()); let mut ordered_plugins = Vec::with_capacity(remaining_plugins.len());
for plugin_id in sorted_ids { for plugin_id in &sorted_ids {
let index = remaining_plugins let index = remaining_plugins
.iter() .iter()
.position(|plugin| plugin.id() == plugin_id) .position(|state| state.id() == plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' disappeared during sorting"))?; .ok_or_else(|| anyhow!("plugin '{plugin_id}' disappeared during sorting"))?;
ordered_plugins.push(remaining_plugins.remove(index)); ordered_plugins.push(remaining_plugins.remove(index));
} }
@@ -232,16 +411,60 @@ impl ServiceManager {
Ok(()) Ok(())
} }
/// 投递消息给指定插件,带错误计数和策略处理
fn deliver_to_plugin(&mut self, id: &str, msg: Message) {
let state = match self.plugins.iter_mut().find(|s| s.id() == id) {
Some(s) => s,
None => {
eprintln!("[ServiceManager] 目标插件 '{}' 不存在", id);
return;
}
};
if !state.enabled {
return;
}
match state.plugin.handle_message(msg) {
Ok(()) => {
state.reset_errors();
}
Err(e) => {
eprintln!(
"[ServiceManager] 插件 '{}' 处理消息失败 ({}/{}): {}",
id,
state.error_count + 1,
state.max_errors,
e
);
if state.record_error() && state.is_dynamic {
self.handle_error_threshold(id);
}
}
}
}
fn broadcast_message(&mut self, msg: Message) { fn broadcast_message(&mut self, msg: Message) {
let should_shutdown = matches!(&msg, Message::Shutdown); let should_shutdown = matches!(&msg, Message::Shutdown);
for plugin in &mut self.plugins { for state in &mut self.plugins {
if let Err(e) = plugin.handle_message(msg.clone()) { if !state.enabled {
eprintln!( continue;
"[ServiceManager] 插件 '{}' 处理广播消息失败: {}", }
plugin.id(),
e match state.plugin.handle_message(msg.clone()) {
); Ok(()) => {
state.reset_errors();
}
Err(e) => {
eprintln!(
"[ServiceManager] 插件 '{}' 处理广播消息失败: {}",
state.id(),
e
);
// 广播消息的错误不触发阈值处理(避免广播期间修改列表)
}
} }
} }
@@ -251,8 +474,46 @@ impl ServiceManager {
} }
} }
/// 插件错误达到阈值时的处理
fn handle_error_threshold(&mut self, plugin_id: &str) {
let state = match self.plugins.iter_mut().find(|s| s.id() == plugin_id) {
Some(s) => s,
None => return,
};
match state.error_policy {
ErrorPolicy::DisableAndLog => {
eprintln!(
"[ServiceManager] 插件 '{}' 错误次数达到阈值,已禁用",
plugin_id
);
state.enabled = false;
}
ErrorPolicy::AutoRollback => {
eprintln!(
"[ServiceManager] 插件 '{}' 错误次数达到阈值,需要回退 (由外部 VersionManager 处理)",
plugin_id
);
// 先禁用,等待外部 (main.rs / HTTP API) 调用 VersionManager 执行回退
state.enabled = false;
}
}
}
/// 获取发送通道的克隆(供外部使用) /// 获取发送通道的克隆(供外部使用)
pub fn sender(&self) -> mpsc::Sender<Envelope> { pub fn sender(&self) -> mpsc::Sender<Envelope> {
self.tx.clone() self.tx.clone()
} }
} }
/// 插件状态信息(用于 API 查询)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginStateInfo {
pub id: String,
pub info: crate::core::plugin::PluginInfo,
pub is_dynamic: bool,
pub error_policy: ErrorPolicy,
pub error_count: u32,
pub max_errors: u32,
pub enabled: bool,
}

View File

@@ -77,14 +77,18 @@ fn message_label(message: &Message) -> String {
} }
struct TestPlugin { struct TestPlugin {
id: &'static str, id: String,
deps: Vec<&'static str>, deps: Vec<String>,
events: Arc<Mutex<Vec<String>>>, events: Arc<Mutex<Vec<String>>>,
} }
impl TestPlugin { impl TestPlugin {
fn new(id: &'static str, deps: Vec<&'static str>, events: Arc<Mutex<Vec<String>>>) -> Self { fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
Self { id, deps, events } Self {
id: id.to_string(),
deps: deps.into_iter().map(|s| s.to_string()).collect(),
events,
}
} }
fn record(&self, entry: impl Into<String>) { fn record(&self, entry: impl Into<String>) {
@@ -93,20 +97,20 @@ impl TestPlugin {
} }
impl Plugin for TestPlugin { impl Plugin for TestPlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
self.id &self.id
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: self.id, name: self.id.clone(),
version: "test", version: "test".to_string(),
description: "test plugin", description: "test plugin".to_string(),
platform: Platform::Any, platform: Platform::Any,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
self.deps.clone() self.deps.clone()
} }
@@ -168,8 +172,8 @@ fn routes_plugin_broadcast_and_manager_messages() {
sender sender
.send(Envelope { .send(Envelope {
from: "alpha", from: "alpha".to_string(),
to: Destination::Plugin("beta"), to: Destination::Plugin("beta".to_string()),
message: Message::Custom { message: Message::Custom {
kind: "direct".to_string(), kind: "direct".to_string(),
payload: "hello".to_string(), payload: "hello".to_string(),
@@ -178,7 +182,7 @@ fn routes_plugin_broadcast_and_manager_messages() {
.expect("direct message should send"); .expect("direct message should send");
sender sender
.send(Envelope { .send(Envelope {
from: "alpha", from: "alpha".to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::Custom { message: Message::Custom {
kind: "broadcast".to_string(), kind: "broadcast".to_string(),
@@ -188,14 +192,14 @@ fn routes_plugin_broadcast_and_manager_messages() {
.expect("broadcast message should send"); .expect("broadcast message should send");
sender sender
.send(Envelope { .send(Envelope {
from: "alpha", from: "alpha".to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::PluginReady("alpha"), message: Message::PluginReady("alpha".to_string()),
}) })
.expect("manager message should send"); .expect("manager message should send");
sender sender
.send(Envelope { .send(Envelope {
from: "test", from: "test".to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::Shutdown, message: Message::Shutdown,
}) })
@@ -301,14 +305,14 @@ fn wifi_result_sent_to_manager_is_broadcast_to_plugins() {
sender sender
.send(Envelope { .send(Envelope {
from: "wifi", from: "wifi".to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::WifiResult("connected".to_string()), message: Message::WifiResult("connected".to_string()),
}) })
.expect("wifi result should send"); .expect("wifi result should send");
sender sender
.send(Envelope { .send(Envelope {
from: "test", from: "test".to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::Shutdown, message: Message::Shutdown,
}) })
@@ -379,8 +383,8 @@ fn all_plugin_ids_must_be_unique() {
let mut ids = HashSet::new(); let mut ids = HashSet::new();
for plugin in plugins { for plugin in plugins {
let id = plugin.id(); let id = plugin.id().to_string();
assert!(ids.insert(id), "duplicate plugin id detected: '{}'", id); assert!(ids.insert(id.clone()), "duplicate plugin id detected: '{}'", id);
} }
} }

286
src/core/version_manager.rs Normal file
View File

@@ -0,0 +1,286 @@
//! VersionManager — 插件版本切换、回退、稳定标记
//!
//! 管理 plugin_store/ 中插件的版本生命周期。
use crate::core::plugin_loader::PluginLoader;
use anyhow::{anyhow, Context, Result};
/// 版本管理器
pub struct VersionManager {
loader: PluginLoader,
}
/// 版本信息
#[derive(Debug, Clone, serde::Serialize)]
pub struct VersionInfo {
pub version: String,
pub is_active: bool,
pub is_stable: bool,
}
impl VersionManager {
pub fn new(loader: PluginLoader) -> Self {
Self { loader }
}
pub fn loader(&self) -> &PluginLoader {
&self.loader
}
pub fn loader_mut(&mut self) -> &mut PluginLoader {
&mut self.loader
}
/// 标记当前活跃版本为稳定版本
pub fn mark_stable(&self, plugin_id: &str, version: &str) -> Result<()> {
let mut registry = self.loader.load_registry()?;
let entry = registry
.plugins
.get_mut(plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?;
entry.last_stable_version = Some(version.to_string());
self.loader.save_registry(&registry)?;
println!(
"[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本"
);
Ok(())
}
/// 回退到上一个稳定版本,返回回退到的版本号
pub fn rollback(&self, plugin_id: &str) -> Result<String> {
let mut registry = self.loader.load_registry()?;
let entry = registry
.plugins
.get_mut(plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?;
let stable_version = entry
.last_stable_version
.clone()
.ok_or_else(|| anyhow!("plugin '{plugin_id}' has no stable version to rollback to"))?;
if stable_version == entry.active_version {
return Err(anyhow!(
"plugin '{plugin_id}' is already at stable version {stable_version}"
));
}
let old_version = entry.active_version.clone();
entry.active_version = stable_version.clone();
self.loader.save_registry(&registry)?;
println!(
"[VersionManager] 插件 '{plugin_id}' 从 v{old_version} 回退到 v{stable_version}"
);
Ok(stable_version)
}
/// 切换到指定版本
pub fn switch_version(&self, plugin_id: &str, version: &str) -> Result<()> {
// 验证版本目录存在
let version_dir = self
.loader
.store_path()
.join(plugin_id)
.join(version);
if !version_dir.exists() {
return Err(anyhow!(
"version {version} not found for plugin '{plugin_id}'"
));
}
let mut registry = self.loader.load_registry()?;
let entry = registry
.plugins
.get_mut(plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?;
entry.active_version = version.to_string();
self.loader.save_registry(&registry)?;
println!(
"[VersionManager] 插件 '{plugin_id}' 切换到 v{version}"
);
Ok(())
}
/// 列出插件的所有版本信息
pub fn list_versions(&self, plugin_id: &str) -> Result<Vec<VersionInfo>> {
let versions = self.loader.list_versions(plugin_id)?;
let registry = self.loader.load_registry()?;
let entry = registry.plugins.get(plugin_id);
Ok(versions
.into_iter()
.map(|v| {
let is_active = entry.map_or(false, |e| e.active_version == v);
let is_stable = entry.map_or(false, |e| {
e.last_stable_version.as_deref() == Some(&v)
});
VersionInfo {
version: v,
is_active,
is_stable,
}
})
.collect())
}
/// 垃圾回收:保留最近 N 个版本,删除旧版本
/// 不删除活跃版本和稳定版本
pub fn gc(&self, plugin_id: &str, keep: usize) -> Result<Vec<String>> {
let versions = self.loader.list_versions(plugin_id)?;
let registry = self.loader.load_registry()?;
let entry = registry.plugins.get(plugin_id);
let active = entry.map(|e| e.active_version.as_str());
let stable = entry.and_then(|e| e.last_stable_version.as_deref());
// 保护活跃版本和稳定版本
let mut deletable: Vec<&str> = versions
.iter()
.map(|s| s.as_str())
.filter(|v| Some(*v) != active && Some(*v) != stable)
.collect();
// 保留最近的 keep 个(版本排在后面的更新)
let mut removed = Vec::new();
while deletable.len() + 2 > keep && !deletable.is_empty() {
// 2 是为活跃和稳定版本预留
let oldest = deletable.remove(0);
let version_dir = self
.loader
.store_path()
.join(plugin_id)
.join(oldest);
if version_dir.exists() {
std::fs::remove_dir_all(&version_dir).with_context(|| {
format!("failed to remove {}", version_dir.display())
})?;
removed.push(oldest.to_string());
println!(
"[VersionManager] 已清理 '{plugin_id}' v{oldest}"
);
}
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::plugin_loader::{ErrorPolicy, PluginRegistry, PluginRegistryEntry};
use std::fs;
use std::path::Path;
fn setup(base: &Path) -> VersionManager {
let _ = fs::remove_dir_all(base);
fs::create_dir_all(base).unwrap();
let loader = PluginLoader::new(base);
// 创建两个版本目录
for v in &["1.0.0", "1.1.0", "2.0.0"] {
fs::create_dir_all(base.join("test-plugin").join(v)).unwrap();
}
// 写入注册表
let mut registry = PluginRegistry::default();
registry.plugins.insert(
"test-plugin".to_string(),
PluginRegistryEntry {
active_version: "1.1.0".to_string(),
last_stable_version: Some("1.0.0".to_string()),
enabled: true,
error_policy: ErrorPolicy::AutoRollback,
max_errors: 5,
},
);
loader.save_registry(&registry).unwrap();
VersionManager::new(loader)
}
#[test]
fn rollback_switches_to_stable_version() {
let tmp = std::env::temp_dir().join("showen_test_rollback");
let vm = setup(&tmp);
let rolled = vm.rollback("test-plugin").unwrap();
assert_eq!(rolled, "1.0.0");
let reg = vm.loader().load_registry().unwrap();
assert_eq!(reg.plugins["test-plugin"].active_version, "1.0.0");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn switch_version_updates_active() {
let tmp = std::env::temp_dir().join("showen_test_switch");
let vm = setup(&tmp);
vm.switch_version("test-plugin", "2.0.0").unwrap();
let reg = vm.loader().load_registry().unwrap();
assert_eq!(reg.plugins["test-plugin"].active_version, "2.0.0");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn mark_stable_updates_registry() {
let tmp = std::env::temp_dir().join("showen_test_mark_stable");
let vm = setup(&tmp);
vm.mark_stable("test-plugin", "1.1.0").unwrap();
let reg = vm.loader().load_registry().unwrap();
assert_eq!(
reg.plugins["test-plugin"].last_stable_version,
Some("1.1.0".to_string())
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn list_versions_shows_flags() {
let tmp = std::env::temp_dir().join("showen_test_list_ver");
let vm = setup(&tmp);
let versions = vm.list_versions("test-plugin").unwrap();
assert_eq!(versions.len(), 3);
let v100 = versions.iter().find(|v| v.version == "1.0.0").unwrap();
assert!(!v100.is_active);
assert!(v100.is_stable);
let v110 = versions.iter().find(|v| v.version == "1.1.0").unwrap();
assert!(v110.is_active);
assert!(!v110.is_stable);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn gc_removes_old_versions() {
let tmp = std::env::temp_dir().join("showen_test_gc");
let vm = setup(&tmp);
// keep=2: active(1.1.0) + stable(1.0.0) are protected, 2.0.0 is deletable
// With keep=2, since we have 2 protected + 1 deletable = 3, and we want to keep at most 2 total...
// Actually the gc tries to keep `keep` total versions including protected ones
let removed = vm.gc("test-plugin", 2).unwrap();
assert_eq!(removed, vec!["2.0.0"]);
let versions = vm.loader().list_versions("test-plugin").unwrap();
assert_eq!(versions.len(), 2);
let _ = fs::remove_dir_all(&tmp);
}
}

View File

@@ -5,5 +5,7 @@
//! //!
//! 核心理念:平台不关心内容是什么,插件决定一切。 //! 核心理念:平台不关心内容是什么,插件决定一切。
#![recursion_limit = "512"]
pub mod core; pub mod core;
pub mod plugins; pub mod plugins;

View File

@@ -1,5 +1,6 @@
use anyhow::Result; use anyhow::Result;
use showen_v2::core::config::AppConfig; use showen_v2::core::config::AppConfig;
use showen_v2::core::plugin_loader::PluginLoader;
use showen_v2::core::service_manager::ServiceManager; use showen_v2::core::service_manager::ServiceManager;
use showen_v2::plugins::{ use showen_v2::plugins::{
ble::BlePlugin, http::HttpPlugin, screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin, ble::BlePlugin, http::HttpPlugin, screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin,
@@ -64,6 +65,46 @@ fn main() -> Result<()> {
manager.register(Box::new(HttpPlugin::new())); manager.register(Box::new(HttpPlugin::new()));
println!(" ✓ HttpPlugin"); println!(" ✓ HttpPlugin");
// 加载动态插件
let plugin_store = std::path::Path::new("plugin_store");
if plugin_store.exists() {
println!("扫描动态插件...");
let loader = PluginLoader::new(plugin_store);
match loader.load_registry() {
Ok(registry) => {
for (plugin_id, entry) in &registry.plugins {
if !entry.enabled {
println!(" - {plugin_id} (禁用)");
continue;
}
match loader.load_plugin(plugin_id, Some(&entry.active_version)) {
Ok((plugin, manifest)) => {
manager.register_dynamic(
Box::new(plugin),
manifest.error_policy,
entry.max_errors,
);
println!(
"{} v{} (动态)",
plugin_id, entry.active_version
);
}
Err(e) => {
eprintln!(
"{} v{} 加载失败: {e}",
plugin_id, entry.active_version
);
}
}
}
}
Err(e) => {
eprintln!("读取插件注册表失败: {e}");
}
}
}
// 设置 Ctrl+C 信号处理 // 设置 Ctrl+C 信号处理
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let r = running.clone(); let r = running.clone();

28
src/plugins/README.md Normal file
View File

@@ -0,0 +1,28 @@
# plugins/ — 内置功能插件
ShowenV2 编译时链接的 5 个内置插件。
| 插件 | 目录 | 说明 | 平台 |
|------|------|------|------|
| VideoPlugin | `video/` | 视频播放引擎,基于 OpenCV支持状态机驱动、帧变换、过渡效果 | Any |
| HttpPlugin | `http/` | Web UI + REST API + WebSocket基于 warp依赖 VideoPlugin | Any |
| BlePlugin | `ble/` | BLE GATT WiFi 配网,基于 D-Bus/BlueZ | Linux |
| WifiPlugin | `wifi/` | WiFi 管理(扫描/连接/热点),基于 nmcli | Linux |
| ScreenPlugin | `screen/` | 屏幕唤醒锁 + 光标隐藏,基于 systemd-inhibit | Linux |
## 依赖关系
```
video ←── http
screen (独立)
ble (独立)
wifi (独立)
```
## 插件生命周期
1. `register()` → ServiceManager 注册
2. `init(ctx)` → 获取消息通道和配置
3. `start()` → 启动工作线程
4. `handle_message(msg)` → 处理消息
5. `stop()` → 优雅关闭

22
src/plugins/ble/README.md Normal file
View File

@@ -0,0 +1,22 @@
# BlePlugin — BLE 配网服务
通过 D-Bus 与 BlueZ 交互,注册 GATT 服务和 LE Advertisement实现 BLE WiFi 配网。
## 模块
| 文件 | 说明 |
|------|------|
| `mod.rs` | BlePlugin 实现,工作线程管理 |
| `gatt.rs` | D-Bus GATT 服务注册、BLE 广播、命令解析、WiFi 凭据传递 |
## 功能
- GATT 服务注册(含 LocalName 双连接修复)
- LE Advertisement 广播
- WiFi SSID/Password 特征值写入
- 命令特征值play/pause/scan 等文本命令)
- 状态通知推送给 BLE 客户端
## 平台
Linux only (D-Bus + BlueZ)

View File

@@ -1,4 +1,5 @@
use crate::core::message::{Destination, Envelope, Message, WifiCommand}; use crate::core::dispatch;
use crate::core::message::{Destination, Envelope, Message};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use dbus::arg::{PropMap, Variant}; use dbus::arg::{PropMap, Variant};
use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties}; use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties};
@@ -12,7 +13,6 @@ use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration; use std::time::Duration;
const BUS_NAME: &str = "io.showen.BleProvisioning"; const BUS_NAME: &str = "io.showen.BleProvisioning";
@@ -106,50 +106,29 @@ impl SharedState {
fn dispatch_command(&self, raw: &[u8]) -> Result<()> { fn dispatch_command(&self, raw: &[u8]) -> Result<()> {
let command = bytes_to_string(raw); let command = bytes_to_string(raw);
let message = match command.as_str() { let ssid = self.ssid.lock().unwrap().clone();
"scan" => Message::WifiCommand(WifiCommand::Scan), let password = self.password.lock().unwrap().clone();
"status" => Message::WifiCommand(WifiCommand::Status),
"connect" => { match dispatch::parse_command(&command, "ble", &ssid, &password) {
let ssid = self.ssid.lock().unwrap().clone(); Ok(result) => {
let password = self.password.lock().unwrap().clone(); self.tx
if ssid.trim().is_empty() { .send(result.envelope)
self.set_status(r#"{"ok":false,"action":"connect","error":"ssid required"}"#); .context("failed to send command from BLE")?;
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!( self.set_status(format!(
r#"{{"ok":false,"action":"{}","error":"unsupported command"}}"#, r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
other command
)); ));
return Err(anyhow!("unsupported BLE command: {}", other)); Ok(())
} }
}; Err(error) => {
self.set_status(format!(
self.tx r#"{{"ok":false,"action":"{}","error":"{}"}}"#,
.send(Envelope { command,
from: "ble", error.replace('"', "\\\"")
to: Destination::Plugin("wifi"), ));
message, Err(anyhow!("BLE command error: {}", error))
}) }
.context("failed to send WiFi command from BLE")?; }
self.set_status(format!(
r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
command
));
Ok(())
} }
} }
@@ -190,78 +169,15 @@ pub fn run_ble_service(
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) -> Result<()> { ) -> Result<()> {
let shared = SharedState::new(tx.clone()); 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 || { eprintln!("[BLE] connecting to system bus...");
run_server_connection(server_shared, server_device_name, ready_tx, server_stop) let conn =
}); Connection::new_system().context("failed to connect to system bus for BLE")?;
conn.request_name(BUS_NAME, false, true, false)
match ready_rx
.recv_timeout(Duration::from_secs(5))
.context("BLE server connection did not become ready in time")
{
Ok(Ok(())) => {}
Ok(Err(error)) => {
stop.store(true, Ordering::SeqCst);
let _ = join_server_thread(server_thread);
return Err(error);
}
Err(error) => {
stop.store(true, Ordering::SeqCst);
let _ = join_server_thread(server_thread);
return Err(error);
}
}
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) {
drain_control_messages(&shared, &control_rx)?;
thread::sleep(SERVER_TIMEOUT);
}
drain_control_messages(&shared, &control_rx)?;
unregister_ble_objects(&conn_client, &adapter_path)
})();
if client_result.is_err() {
stop.store(true, Ordering::SeqCst);
}
join_server_thread(server_thread)?;
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")?; .context("failed to request BLE D-Bus name")?;
eprintln!("[BLE] D-Bus name acquired");
// 构建 Crossroads 并注册所有 GATT/Advertisement objects
let mut cr = Crossroads::new(); let mut cr = Crossroads::new();
let object_manager = register_object_manager_iface(&mut cr); let object_manager = register_object_manager_iface(&mut cr);
let service_iface = register_service_iface(&mut cr); let service_iface = register_service_iface(&mut cr);
@@ -333,14 +249,16 @@ fn run_server_connection(
AdvertisementData { AdvertisementData {
advertisement_type: "peripheral".to_string(), advertisement_type: "peripheral".to_string(),
service_uuids: vec![SERVICE_UUID.to_string()], service_uuids: vec![SERVICE_UUID.to_string()],
local_name: device_name, local_name: device_name.clone(),
includes: vec!["tx-power".to_string()], includes: vec!["tx-power".to_string(), "local-name".to_string()],
}, },
); );
// 注册 Crossroads 消息处理(必须在 RegisterApplication 之前,
// 因为 BlueZ 会在注册过程中回调 GetManagedObjects
let shared_cr = Arc::new(Mutex::new(cr)); let shared_cr = Arc::new(Mutex::new(cr));
let cr_for_handler = Arc::clone(&shared_cr); let cr_for_handler = Arc::clone(&shared_cr);
conn_server.start_receive( conn.start_receive(
MatchRule::new_method_call(), MatchRule::new_method_call(),
Box::new(move |msg, conn| { Box::new(move |msg, conn| {
if cr_for_handler if cr_for_handler
@@ -349,25 +267,56 @@ fn run_server_connection(
.handle_message(msg, conn) .handle_message(msg, conn)
.is_err() .is_err()
{ {
eprintln!("[ble] crossroads dispatch error"); eprintln!("[BLE] crossroads dispatch error");
} }
true true
}), }),
); );
ready_tx // 配置 adapter
.send(Ok(())) let adapter_path = find_adapter(&conn)?;
.map_err(|_| anyhow!("failed to notify BLE server readiness"))?; configure_adapter(&conn, &adapter_path, &device_name)?;
// 非阻塞发送 RegisterApplication + RegisterAdvertisement
let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?;
let _ad_serial = send_register_advertisement(&conn, &adapter_path)?;
eprintln!("[BLE] registration requests sent, processing callbacks...");
// 处理消息循环等待 BlueZ 回调 GetManagedObjects 并完成注册
// start_receive 会处理所有入站方法调用(包括 BlueZ 的回调),
// 注册回复也由 process() 内部分发,我们只需等待足够时间
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
conn.process(Duration::from_millis(100))
.context("BLE connection process failed during registration")?;
}
eprintln!("[BLE] GATT application and advertisement registered");
tx.send(Envelope {
from: "ble".to_string(),
to: Destination::Manager,
message: Message::PluginReady("ble".to_string()),
})
.context("failed to report BLE plugin readiness")?;
eprintln!("[BLE] ready, entering main loop");
// 主循环:处理 D-Bus 消息 + control 消息
while !stop.load(Ordering::SeqCst) { while !stop.load(Ordering::SeqCst) {
if shared.is_notifying() && shared.take_pending_notification() { if shared.is_notifying() && shared.take_pending_notification() {
emit_status_notification(&conn_server, &shared)?; emit_status_notification(&conn, &shared)?;
} }
conn_server drain_control_messages(&shared, &control_rx)?;
.process(SERVER_TIMEOUT) conn.process(SERVER_TIMEOUT)
.context("BLE server connection process loop failed")?; .context("BLE connection process loop failed")?;
} }
drain_control_messages(&shared, &control_rx)?;
// 清理
unregister_ble_objects(&conn, &adapter_path)?;
Ok(()) Ok(())
} }
@@ -552,6 +501,32 @@ fn build_managed_objects() -> ManagedObjects {
objects objects
} }
fn send_register_gatt_app(conn: &Connection, adapter_path: &str) -> Result<u32> {
let msg = dbus::Message::method_call(
&BLUEZ_SERVICE.into(),
&Path::from(adapter_path.to_string()),
&GATT_MANAGER_IFACE.into(),
&"RegisterApplication".into(),
)
.append2(Path::from(APP_PATH), PropMap::new());
conn.send(msg)
.map_err(|_| anyhow!("failed to send RegisterApplication"))
}
fn send_register_advertisement(conn: &Connection, adapter_path: &str) -> Result<u32> {
let msg = dbus::Message::method_call(
&BLUEZ_SERVICE.into(),
&Path::from(adapter_path.to_string()),
&LE_ADVERTISING_MANAGER_IFACE.into(),
&"RegisterAdvertisement".into(),
)
.append2(Path::from(ADV_PATH), PropMap::new());
conn.send(msg)
.map_err(|_| anyhow!("failed to send RegisterAdvertisement"))
}
fn find_adapter(conn: &Connection) -> Result<String> { fn find_adapter(conn: &Connection) -> Result<String> {
let proxy = conn.with_proxy(BLUEZ_SERVICE, "/", PROXY_TIMEOUT); let proxy = conn.with_proxy(BLUEZ_SERVICE, "/", PROXY_TIMEOUT);
let objects = proxy let objects = proxy
@@ -582,28 +557,6 @@ fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) -
Ok(()) 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<()> { 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 = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
let _ = adv_manager.method_call::<(), _, _, _>( let _ = adv_manager.method_call::<(), _, _, _>(
@@ -629,12 +582,6 @@ fn bytes_to_string(value: &[u8]) -> String {
.to_string() .to_string()
} }
fn join_server_thread(server_thread: thread::JoinHandle<Result<()>>) -> Result<()> {
server_thread
.join()
.map_err(|_| anyhow!("BLE server thread panicked"))?
}
fn drain_control_messages(shared: &SharedState, control_rx: &Receiver<BleControl>) -> Result<()> { fn drain_control_messages(shared: &SharedState, control_rx: &Receiver<BleControl>) -> Result<()> {
loop { loop {
match control_rx.try_recv() { match control_rx.try_recv() {

View File

@@ -38,20 +38,20 @@ impl Default for BlePlugin {
} }
impl Plugin for BlePlugin { impl Plugin for BlePlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
"ble" "ble"
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: "BLE Provisioning", name: "BLE Provisioning".to_string(),
version: "0.2.0", version: "0.2.0".to_string(),
description: "BLE GATT WiFi 配网 (D-Bus BlueZ)", description: "BLE GATT WiFi 配网 (D-Bus BlueZ)".to_string(),
platform: Platform::Linux, platform: Platform::Linux,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec![] vec![]
} }
@@ -82,7 +82,11 @@ impl Plugin for BlePlugin {
self.control_tx = Some(control_tx); self.control_tx = Some(control_tx);
self.worker = Some(thread::spawn(move || { self.worker = Some(thread::spawn(move || {
gatt::run_ble_service(device_name, tx, control_rx, stop) let result = gatt::run_ble_service(device_name, tx, control_rx, stop);
if let Err(ref error) = result {
eprintln!("[BlePlugin] worker exited with error: {error:#}");
}
result
})); }));
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,48 @@
# HttpPlugin — Web UI + REST API
基于 warp 的 HTTP 服务插件,提供完整的控制 API 和实时 WebSocket 事件。
## 模块
| 文件 | 说明 |
|------|------|
| `mod.rs` | HttpPlugin 实现、HttpState 共享状态、WebSocket 事件编码 |
| `routes.rs` | 全部 HTTP 路由定义、请求处理、内嵌 Web UI HTML |
## API 端点
### 播放控制
- `GET /api/status` — 播放状态
- `POST /api/play` / `pause` / `next` / `previous`
- `POST /api/goto` — 跳转到指定索引
- `GET /api/playlist` — 播放列表
- `POST /api/scene` — 切换场景
- `POST /api/trigger` — 发送触发器
### 配置管理
- `GET /api/config` — 完整配置
- `GET /api/config/display` — 显示配置
- `POST /api/config` — 更新配置(热重载)
### 媒体管理
- `GET /api/videos` — 视频文件列表
- `POST /api/videos/upload` — 上传视频
- `DELETE /api/videos/:name` — 删除视频
### WiFi / BLE
- `GET /api/wifi/status` / `scan` / `connect` / `ap/start` / `ap/stop`
- `POST /api/ble/start` / `stop` / `GET /api/ble/status`
### 插件管理 (动态插件)
- `GET /api/plugins` — 列出所有插件状态
- `GET /api/plugins/:id` — 插件详情
- `POST /api/plugins/:id/enable` / `disable` / `rollback` / `switch`
- `POST /api/plugins/install` — 远程安装
- `POST /api/plugins/check-updates` — 检查更新
### WebSocket
- `ws://host:port/ws` — 实时事件推送
## 依赖
- 依赖 VideoPlugin启动顺序

View File

@@ -43,6 +43,8 @@ pub(crate) struct HttpState {
player_status: Mutex<crate::core::message::PlayerStatusData>, player_status: Mutex<crate::core::message::PlayerStatusData>,
ble_ready: AtomicBool, ble_ready: AtomicBool,
ws_events: broadcast::Sender<String>, ws_events: broadcast::Sender<String>,
/// 动态插件管理状态(由 Custom 消息更新)
plugin_states: Mutex<Vec<crate::core::service_manager::PluginStateInfo>>,
} }
impl HttpState { impl HttpState {
@@ -68,6 +70,7 @@ impl HttpState {
player_status: Mutex::new(player_status), player_status: Mutex::new(player_status),
ble_ready: AtomicBool::new(false), ble_ready: AtomicBool::new(false),
ws_events, ws_events,
plugin_states: Mutex::new(Vec::new()),
} }
} }
@@ -179,6 +182,21 @@ impl HttpState {
self.publish_ws(payload); self.publish_ws(payload);
} }
} }
pub(crate) fn plugin_states(&self) -> Vec<crate::core::service_manager::PluginStateInfo> {
self.plugin_states
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
fn update_plugin_states(&self, json: &str) {
if let Ok(states) = serde_json::from_str::<Vec<crate::core::service_manager::PluginStateInfo>>(json) {
if let Ok(mut current) = self.plugin_states.lock() {
*current = states;
}
}
}
} }
pub struct HttpPlugin { pub struct HttpPlugin {
@@ -202,21 +220,21 @@ impl Default for HttpPlugin {
} }
impl Plugin for HttpPlugin { impl Plugin for HttpPlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
"http" "http"
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: "HTTP API", name: "HTTP API".to_string(),
version: "0.2.0", version: "0.2.0".to_string(),
description: "Web UI + REST API (warp)", description: "Web UI + REST API (warp)".to_string(),
platform: Platform::Any, platform: Platform::Any,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec!["video"] vec!["video".to_string()]
} }
fn init(&mut self, ctx: PluginContext) -> Result<()> { fn init(&mut self, ctx: PluginContext) -> Result<()> {
@@ -268,9 +286,9 @@ impl Plugin for HttpPlugin {
}; };
if let Err(error) = tx.send(Envelope { if let Err(error) = tx.send(Envelope {
from: "http", from: "http".to_string(),
to: crate::core::message::Destination::Manager, to: crate::core::message::Destination::Manager,
message: Message::PluginReady("http"), message: Message::PluginReady("http".to_string()),
}) { }) {
eprintln!("[HttpPlugin] failed to report ready state: {error}"); eprintln!("[HttpPlugin] failed to report ready state: {error}");
} }
@@ -314,8 +332,11 @@ impl Plugin for HttpPlugin {
state.publish_ws(payload); state.publish_ws(payload);
} }
} }
Message::PluginReady("ble") => state.set_ble_ready(true), Message::PluginReady(ref id) if id == "ble" => state.set_ble_ready(true),
Message::Shutdown => state.set_ble_ready(false), Message::Shutdown => state.set_ble_ready(false),
Message::Custom { ref kind, ref payload } if kind == "plugin_states" => {
state.update_plugin_states(payload);
}
_ => {} _ => {}
} }

View File

@@ -1,5 +1,6 @@
use super::HttpState; use super::HttpState;
use crate::core::config::{self, AppConfig}; use crate::core::config::{self, AppConfig};
use crate::core::dispatch;
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand}; use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
use bytes::Buf; use bytes::Buf;
use futures_util::{SinkExt, StreamExt, TryStreamExt}; use futures_util::{SinkExt, StreamExt, TryStreamExt};
@@ -67,7 +68,8 @@ pub(crate) fn build_routes(
tx: mpsc::Sender<Envelope>, tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>, state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone { ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let api = status_route(Arc::clone(&state)) // 使用 boxed() 分段避免 warp 递归类型溢出
let core_api = status_route(Arc::clone(&state))
.or(play_route(tx.clone())) .or(play_route(tx.clone()))
.or(pause_route(tx.clone())) .or(pause_route(tx.clone()))
.or(next_route(tx.clone())) .or(next_route(tx.clone()))
@@ -79,7 +81,9 @@ pub(crate) fn build_routes(
.or(config_get_route(Arc::clone(&state))) .or(config_get_route(Arc::clone(&state)))
.or(config_display_route(Arc::clone(&state))) .or(config_display_route(Arc::clone(&state)))
.or(config_update_route(tx.clone(), Arc::clone(&state))) .or(config_update_route(tx.clone(), Arc::clone(&state)))
.or(video_list_route(Arc::clone(&state))) .boxed();
let media_api = video_list_route(Arc::clone(&state))
.or(video_upload_route(Arc::clone(&state))) .or(video_upload_route(Arc::clone(&state)))
.or(video_delete_route(Arc::clone(&state))) .or(video_delete_route(Arc::clone(&state)))
.or(wifi_status_route(tx.clone(), Arc::clone(&state))) .or(wifi_status_route(tx.clone(), Arc::clone(&state)))
@@ -89,9 +93,22 @@ pub(crate) fn build_routes(
.or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state))) .or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state)))
.or(ble_start_route(Arc::clone(&state))) .or(ble_start_route(Arc::clone(&state)))
.or(ble_stop_route()) .or(ble_stop_route())
.or(ble_status_route(Arc::clone(&state))); .or(ble_status_route(Arc::clone(&state)))
.boxed();
root_route().or(ws_route(Arc::clone(&state))).or(api).with( let plugin_api = plugins_list_route(Arc::clone(&state))
.or(plugin_detail_route(Arc::clone(&state)))
.or(plugin_enable_route(tx.clone()))
.or(plugin_disable_route(tx.clone()))
.or(plugin_rollback_route(tx.clone()))
.or(plugin_switch_route(tx.clone()))
.or(plugin_install_route(tx.clone()))
.or(plugin_check_updates_route(tx.clone()))
.boxed();
let api = core_api.or(media_api).or(plugin_api);
root_route().or(ws_route(tx.clone(), Arc::clone(&state))).or(api).with(
warp::cors() warp::cors()
.allow_any_origin() .allow_any_origin()
.allow_headers(["content-type"]) .allow_headers(["content-type"])
@@ -112,14 +129,16 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
} }
fn ws_route( fn ws_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>, state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone { ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("ws") warp::path("ws")
.and(warp::path::end()) .and(warp::path::end())
.and(warp::ws()) .and(warp::ws())
.and(with_tx(tx))
.and(with_state(state)) .and(with_state(state))
.map(|ws: warp::ws::Ws, state: Arc<HttpState>| { .map(|ws: warp::ws::Ws, tx: mpsc::Sender<Envelope>, state: Arc<HttpState>| {
ws.on_upgrade(move |socket| websocket_session(socket, state)) ws.on_upgrade(move |socket| websocket_session(socket, tx, state))
}) })
} }
@@ -570,7 +589,7 @@ async fn handle_config_update(
} }
if let Err(error) = tx.send(Envelope { if let Err(error) = tx.send(Envelope {
from: "http", from: "http".to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::ConfigReloadRequest, message: Message::ConfigReloadRequest,
}) { }) {
@@ -733,8 +752,8 @@ async fn send_video_command(
success_message: impl Into<String>, success_message: impl Into<String>,
) -> Result<warp::reply::Response, Infallible> { ) -> Result<warp::reply::Response, Infallible> {
match tx.send(Envelope { match tx.send(Envelope {
from: "http", from: "http".to_string(),
to: Destination::Plugin("video"), to: Destination::Plugin("video".to_string()),
message, message,
}) { }) {
Ok(()) => Ok(success_json(success_message.into())), Ok(()) => Ok(success_json(success_message.into())),
@@ -778,8 +797,8 @@ async fn wifi_request(
}; };
if let Err(error) = tx.send(Envelope { if let Err(error) = tx.send(Envelope {
from: "http", from: "http".to_string(),
to: Destination::Plugin("wifi"), to: Destination::Plugin("wifi".to_string()),
message: Message::WifiCommand(command), message: Message::WifiCommand(command),
}) { }) {
return Err(error_json( return Err(error_json(
@@ -852,7 +871,11 @@ async fn wifi_request(
Ok(payload) Ok(payload)
} }
async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) { async fn websocket_session(
ws: warp::ws::WebSocket,
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) {
let (mut sender, mut receiver) = ws.split(); let (mut sender, mut receiver) = ws.split();
let mut events = state.ws_subscribe(); let mut events = state.ws_subscribe();
@@ -884,6 +907,11 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
} }
} else if message.is_close() { } else if message.is_close() {
break; break;
} else if message.is_text() {
let reply = handle_ws_command(message.to_str().unwrap_or(""), &tx);
if sender.send(warp::ws::Message::text(reply)).await.is_err() {
break;
}
} }
} }
Some(Err(_)) | None => break, Some(Err(_)) | None => break,
@@ -893,6 +921,104 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
} }
} }
/// 解析 WebSocket 收到的 JSON 命令,返回 JSON 响应字符串。
///
/// 输入格式: `{"cmd":"play"}` 或 `{"cmd":"goto","index":3}` 或
/// `{"cmd":"connect","ssid":"x","password":"y"}` 等
fn handle_ws_command(text: &str, tx: &mpsc::Sender<Envelope>) -> String {
let json: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return r#"{"ok":false,"error":"invalid JSON"}"#.to_string(),
};
let cmd = match json.get("cmd").and_then(|v| v.as_str()) {
Some(c) => c,
None => return r#"{"ok":false,"error":"missing cmd field"}"#.to_string(),
};
// 将 JSON 字段组合为文本命令字符串
let command_str = build_command_string(cmd, &json);
// 从 JSON 中提取 ssid/password 作为 hint用于无参数的 connect/ap_start
let ssid_hint = json
.get("ssid")
.and_then(|v| v.as_str())
.unwrap_or("");
let password_hint = json
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("");
match dispatch::parse_command(&command_str, "ws", ssid_hint, password_hint) {
Ok(result) => {
if tx.send(result.envelope).is_ok() {
format!(r#"{{"ok":true,"cmd":"{}"}}"#, cmd)
} else {
r#"{"ok":false,"error":"channel closed"}"#.to_string()
}
}
Err(error) => {
format!(
r#"{{"ok":false,"cmd":"{}","error":"{}"}}"#,
cmd,
error.replace('"', "\\\"")
)
}
}
}
/// 从 JSON 对象组装文本命令字符串。
/// 例: `{"cmd":"goto","index":3}` -> `"goto:3"`
/// `{"cmd":"scene","name":"idle"}` -> `"scene:idle"`
/// `{"cmd":"trigger","name":"voice","value":"hi"}` -> `"trigger:voice:hi"`
/// `{"cmd":"connect","ssid":"x","password":"y"}` -> `"connect:x:y"`
fn build_command_string(cmd: &str, json: &serde_json::Value) -> String {
match cmd {
"goto" => {
if let Some(index) = json.get("index").and_then(|v| v.as_u64()) {
format!("goto:{index}")
} else {
"goto".to_string()
}
}
"scene" => {
if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
format!("scene:{name}")
} else {
"scene".to_string()
}
}
"trigger" => {
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = json.get("value").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
"trigger".to_string()
} else {
format!("trigger:{name}:{value}")
}
}
"connect" => {
let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
if ssid.is_empty() {
"connect".to_string()
} else {
format!("connect:{ssid}:{password}")
}
}
"ap_start" => {
let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
if ssid.is_empty() {
"ap_start".to_string()
} else {
format!("ap_start:{ssid}:{password}")
}
}
_ => cmd.to_string(),
}
}
fn parse_optional_json<T>(body: &bytes::Bytes) -> Result<T, Box<warp::reply::Response>> fn parse_optional_json<T>(body: &bytes::Bytes) -> Result<T, Box<warp::reply::Response>>
where where
T: DeserializeOwned + Default, T: DeserializeOwned + Default,
@@ -1000,6 +1126,181 @@ fn error_json(status: StatusCode, message: &str) -> warp::reply::Response {
) )
} }
// ── 插件管理 API ──
fn plugins_list_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
Ok::<_, Infallible>(json_response(StatusCode::OK, &state.plugin_states()))
})
}
fn plugin_detail_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String)
.and(warp::get())
.and(with_state(state))
.and_then(|id: String, state: Arc<HttpState>| async move {
let plugins = state.plugin_states();
match plugins.iter().find(|p| p.id == id) {
Some(info) => Ok::<_, Infallible>(json_response(StatusCode::OK, info)),
None => Ok(error_json(StatusCode::NOT_FOUND, &format!("plugin '{}' not found", id))),
}
})
}
#[derive(Deserialize)]
struct PluginSwitchRequest {
version: String,
}
#[derive(Deserialize)]
struct PluginInstallRequest {
id: String,
#[serde(default)]
version: Option<String>,
}
fn plugin_enable_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "enable")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_enable", &id).await
})
}
fn plugin_disable_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "disable")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_disable", &id).await
})
}
fn plugin_rollback_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "rollback")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_rollback", &id).await
})
}
fn plugin_switch_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "switch")
.and(warp::post())
.and(warp::body::json::<PluginSwitchRequest>())
.and(with_tx(tx))
.and_then(
|id: String, body: PluginSwitchRequest, tx: mpsc::Sender<Envelope>| async move {
let payload = serde_json::json!({
"id": id,
"version": body.version,
})
.to_string();
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_switch".to_string(),
payload,
},
}) {
Ok(()) => Ok::<_, Infallible>(success_json(
format!("版本切换请求已发送: {} -> v{}", id, body.version),
)),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
},
)
}
fn plugin_install_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / "install")
.and(warp::post())
.and(warp::body::json::<PluginInstallRequest>())
.and(with_tx(tx))
.and_then(
|body: PluginInstallRequest, tx: mpsc::Sender<Envelope>| async move {
let payload = serde_json::json!({
"id": body.id,
"version": body.version,
})
.to_string();
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_install".to_string(),
payload,
},
}) {
Ok(()) => Ok::<_, Infallible>(success_json(
format!("安装请求已发送: {}", body.id),
)),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
},
)
}
fn plugin_check_updates_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / "check-updates")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_check_updates", "").await
})
}
async fn send_plugin_command(
tx: mpsc::Sender<Envelope>,
kind: &str,
plugin_id: &str,
) -> Result<warp::reply::Response, Infallible> {
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: kind.to_string(),
payload: plugin_id.to_string(),
},
}) {
Ok(()) => Ok(success_json(format!("{kind} 命令已发送"))),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
}
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response { fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
warp::reply::with_status(warp::reply::json(payload), status).into_response() warp::reply::with_status(warp::reply::json(payload), status).into_response()
} }
@@ -1042,7 +1343,7 @@ const WEB_UI_HTML: &str = r#"<!doctype html>
<section class="panel active" id="panel-control"> <section class="panel active" id="panel-control">
<div class="grid"> <div class="grid">
<div class="card"><h2>播放状态</h2><div class="status"><div><span class="label">状态</span><span id="st-state" class="val">--</span></div><div><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div><span class="label">索引</span><span id="st-index" class="val">--</span></div><div><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div> <div class="card"><h2>播放状态</h2><div class="status"><div><span class="label">状态</span><span id="st-state" class="val">--</span></div><div><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div><span class="label">索引</span><span id="st-index" class="val">--</span></div><div><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div>
<div class="card"><h2>播放控制</h2><div class="btns"><button class="secondary" onclick="api('POST','/api/previous')">上一个</button><button onclick="api('POST','/api/play')">播放</button><button class="secondary" onclick="api('POST','/api/pause')">暂停</button><button onclick="api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:10px"><input id="goto-idx" type="number" min="0" placeholder="输入视频索引"><button onclick="gotoVideo()">跳转</button></div></div> <div class="card"><h2>播放控制</h2><div class="btns"><button class="secondary" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button><button onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button><button class="secondary" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button><button onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:10px"><input id="goto-idx" type="number" min="0" placeholder="输入视频索引"><button onclick="gotoVideo()">跳转</button></div></div>
<div class="card"><h2>触发器</h2><div class="btns"><button class="secondary" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="secondary" onclick="triggerPreset('button','button1')">按钮1</button><button class="secondary" onclick="triggerPreset('button','button2')">按钮2</button><button class="secondary" onclick="triggerPreset('sensor','touch')">触摸</button></div><label>名称</label><input id="tr-name" type="text" placeholder="voice"><label>值</label><input id="tr-value" type="text" placeholder="name"><div class="btns" style="margin-top:10px"><button onclick="triggerCustom()">发送触发器</button></div></div> <div class="card"><h2>触发器</h2><div class="btns"><button class="secondary" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="secondary" onclick="triggerPreset('button','button1')">按钮1</button><button class="secondary" onclick="triggerPreset('button','button2')">按钮2</button><button class="secondary" onclick="triggerPreset('sensor','touch')">触摸</button></div><label>名称</label><input id="tr-name" type="text" placeholder="voice"><label>值</label><input id="tr-value" type="text" placeholder="name"><div class="btns" style="margin-top:10px"><button onclick="triggerCustom()">发送触发器</button></div></div>
</div> </div>
</section> </section>
@@ -1055,24 +1356,29 @@ const WEB_UI_HTML: &str = r#"<!doctype html>
</div> </div>
<script> <script>
var cachedConfig=null; var cachedConfig=null;var ws=null;var wsReady=false;
function $(id){return document.getElementById(id)} function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.style.display='block';el.style.background=err?'#7f1d1d':'#1f2937';clearTimeout(el._timer);el._timer=setTimeout(function(){el.style.display='none'},3000)} function toast(msg,err){var el=$('toast');el.textContent=msg;el.style.display='block';el.style.background=err?'#7f1d1d':'#1f2937';clearTimeout(el._timer);el._timer=setTimeout(function(){el.style.display='none'},3000)}
document.querySelectorAll('.tab').forEach(function(tab){tab.onclick=function(){document.querySelectorAll('.tab').forEach(function(el){el.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(el){el.classList.remove('active')});tab.classList.add('active');$('panel-'+tab.dataset.tab).classList.add('active');if(tab.dataset.tab==='videos')loadVideoList();if(tab.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab.dataset.tab==='settings'&&!cachedConfig)loadConfig()}}) document.querySelectorAll('.tab').forEach(function(tab){tab.onclick=function(){document.querySelectorAll('.tab').forEach(function(el){el.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(el){el.classList.remove('active')});tab.classList.add('active');$('panel-'+tab.dataset.tab).classList.add('active');if(tab.dataset.tab==='videos')loadVideoList();if(tab.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab.dataset.tab==='settings'&&!cachedConfig)loadConfig()}})
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');refreshStatus();return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})} function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';var url=proto+'//'+location.host+'/ws';ws=new WebSocket(url);ws.onopen=function(){wsReady=true};ws.onclose=function(){wsReady=false;setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update'){toast('场景切换: '+msg.data.old_state+' → '+msg.data.new_state)}else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update'){cachedConfig=msg.data}}catch(e){}}}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(function(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val paused'}else if(d.paused){el.textContent='已暂停';el.className='val paused'}else{el.textContent='播放中';el.className='val'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}).catch(function(){})} function wsCmd(obj){if(wsReady){ws.send(JSON.stringify(obj))}else{toast('WebSocket 未连接',true)}}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}api('POST','/api/goto/'+idx)} function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
function triggerPreset(name,value){api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))} function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val paused'}else if(d.paused){el.textContent='已暂停';el.className='val paused'}else{el.textContent='播放中';el.className='val'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val':'val paused';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val':'val paused'}}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady){wsCmd({cmd:'goto',index:parseInt(idx,10)})}else{api('POST','/api/goto/'+idx)}}
function triggerPreset(name,value){if(wsReady){wsCmd({cmd:'trigger',name:name,value:value||''})}else{api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))}}
function triggerCustom(){var name=$('tr-name').value;var value=$('tr-value').value;if(!name){toast('请输入触发器名',true);return}triggerPreset(name,value)} function triggerCustom(){var name=$('tr-name').value;var value=$('tr-value').value;if(!name){toast('请输入触发器名',true);return}triggerPreset(name,value)}
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item">目录中没有视频文件</div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span>'+escapeHtml(f.name)+' ('+sz+')</span><button class="danger" onclick="deleteVideo(\''+jsString(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})} function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item">目录中没有视频文件</div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span>'+escapeHtml(f.name)+' ('+sz+')</span><button class="danger" onclick="deleteVideo(\''+jsString(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})} function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})} function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(function(d){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val':'val paused';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}).catch(function(){})} function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item">扫描中...</div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item">未发现 WiFi 网络</div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span>'+escapeHtml(n.ssid||'隐藏网络')+' / '+escapeHtml(String(n.signal||0))+' / '+escapeHtml(n.security||'OPEN')+'</span><button class="secondary" onclick="selectWifi(\''+jsString(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})} function scanWifi(){$('wifi-list').innerHTML='<div class="item">扫描中...</div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item">未发现 WiFi 网络</div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span>'+escapeHtml(n.ssid||'隐藏网络')+' / '+escapeHtml(String(n.signal||0))+' / '+escapeHtml(n.security||'OPEN')+'</span><button class="secondary" onclick="selectWifi(\''+jsString(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid} function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var ssid=$('wifi-ssid-input').value;var password=$('wifi-pass-input').value;if(!ssid){toast('请输入 WiFi 名称',true);return}api('POST','/api/wifi/connect',{ssid:ssid,password:password}).then(function(){setTimeout(loadWifiStatus,1500)})} function connectWifi(){var ssid=$('wifi-ssid-input').value;var password=$('wifi-pass-input').value;if(!ssid){toast('请输入 WiFi 名称',true);return}if(wsReady){wsCmd({cmd:'connect',ssid:ssid,password:password})}else{api('POST','/api/wifi/connect',{ssid:ssid,password:password}).then(function(){setTimeout(loadWifiStatus,1500)})}}
function startAP(){var ssid=$('ap-ssid').value||'showen';var password=$('ap-pass').value||'12345678';if(password.length<8){toast('热点密码至少 8 位',true);return}api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})} function startAP(){var ssid=$('ap-ssid').value||'showen';var password=$('ap-pass').value||'12345678';if(password.length<8){toast('热点密码至少 8 位',true);return}if(wsReady){wsCmd({cmd:'ap_start',ssid:ssid,password:password})}else{api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})}}
function stopAP(){api('POST','/api/wifi/ap/stop')} function stopAP(){if(wsReady){wsCmd({cmd:'ap_stop'})}else{api('POST','/api/wifi/ap/stop')}}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val'}else{el.textContent='未就绪';el.className='val paused'}}).catch(function(){})} function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val'}else{el.textContent='未就绪';el.className='val paused'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)} function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)} function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
@@ -1084,7 +1390,7 @@ function saveDisplay(){if(!cachedConfig){loadConfig();return}var next=JSON.parse
function escapeHtml(v){return String(v).replace(/[&<>\"]/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch]})} function escapeHtml(v){return String(v).replace(/[&<>\"]/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch]})}
function escapeAttr(v){return escapeHtml(v).replace(/'/g,'&#39;')} function escapeAttr(v){return escapeHtml(v).replace(/'/g,'&#39;')}
function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")} function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
refreshStatus();setInterval(refreshStatus,3000); connectWS();refreshStatus();
</script> </script>
</body> </body>
</html>"#; </html>"#;

View File

@@ -0,0 +1,15 @@
# ScreenPlugin — 屏幕管理
防止屏幕休眠和管理光标显示。
## 功能
- **唤醒锁**: `systemd-inhibit --what=idle:sleep` 阻止系统休眠
- **光标隐藏**: `unclutter -idle 0 -root` 隐藏鼠标光标
- 播放时自动获取唤醒锁,暂停时释放
- 响应 `ScreenLockRequest` / `CursorVisibility` 消息
## 平台
Linux only (systemd-inhibit, unclutter)
其他平台编译通过但功能为空操作。

View File

@@ -113,20 +113,20 @@ impl Default for ScreenPlugin {
} }
impl Plugin for ScreenPlugin { impl Plugin for ScreenPlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
"screen" "screen"
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: "Screen Manager", name: "Screen Manager".to_string(),
version: "0.2.0", version: "0.2.0".to_string(),
description: "屏幕唤醒锁 + 光标管理", description: "屏幕唤醒锁 + 光标管理".to_string(),
platform: Platform::Linux, platform: Platform::Linux,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec![] vec![]
} }

View File

@@ -0,0 +1,24 @@
# VideoPlugin — 视频播放引擎
基于 OpenCV 的视频播放插件,支持状态机驱动的场景切换和帧变换。
## 模块
| 文件 | 说明 |
|------|------|
| `mod.rs` | VideoPlugin 实现 Plugin trait工作线程管理状态/消息发布 |
| `processor.rs` | VideoProcessor视频捕获、帧处理、过渡效果、播放列表管理 |
| `state_machine.rs` | StateMachineJSON 配置驱动的场景/动画状态机 |
## 功能
- 视频播放/暂停/上一个/下一个/跳转
- 场景切换 (ChangeScene)
- 触发器驱动的状态转换 (voice/button/sensor)
- 帧变换:旋转、翻转、透视校正、色键抠像、亮度调节
- 过渡效果:淡入淡出、直切
- 配置热重载
## 平台
Any (需要 OpenCV 运行时)

View File

@@ -48,7 +48,7 @@ impl VideoPlugin {
}; };
if let Err(error) = ctx.tx.send(Envelope { if let Err(error) = ctx.tx.send(Envelope {
from: self.id(), from: self.id().to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::PlayerStatus(status), message: Message::PlayerStatus(status),
}) { }) {
@@ -70,7 +70,7 @@ impl VideoPlugin {
} }
if let Err(error) = ctx.tx.send(Envelope { if let Err(error) = ctx.tx.send(Envelope {
from: self.id(), from: self.id().to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::StateChanged { message: Message::StateChanged {
old_state, old_state,
@@ -89,20 +89,20 @@ impl Default for VideoPlugin {
} }
impl Plugin for VideoPlugin { impl Plugin for VideoPlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
"video" "video"
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: "Video Player", name: "Video Player".to_string(),
version: "0.2.0", version: "0.2.0".to_string(),
description: "视频播放引擎 (OpenCV)", description: "视频播放引擎 (OpenCV)".to_string(),
platform: Platform::Any, platform: Platform::Any,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec![] vec![]
} }
@@ -130,9 +130,9 @@ impl Plugin for VideoPlugin {
self.worker = Some(handle); self.worker = Some(handle);
ctx.tx.send(Envelope { ctx.tx.send(Envelope {
from: self.id(), from: self.id().to_string(),
to: Destination::Manager, to: Destination::Manager,
message: Message::PluginReady(self.id()), message: Message::PluginReady(self.id().to_string()),
})?; })?;
self.publish_status(); self.publish_status();
@@ -150,8 +150,8 @@ impl Plugin for VideoPlugin {
// 恢复播放时重新获取防息屏锁 // 恢复播放时重新获取防息屏锁
if let Some(ctx) = &self.ctx { if let Some(ctx) = &self.ctx {
let _ = ctx.tx.send(Envelope { let _ = ctx.tx.send(Envelope {
from: self.id(), from: self.id().to_string(),
to: Destination::Plugin("screen"), to: Destination::Plugin("screen".to_string()),
message: Message::ScreenLockRequest(true), message: Message::ScreenLockRequest(true),
}); });
} }
@@ -161,8 +161,8 @@ impl Plugin for VideoPlugin {
// 暂停时释放防息屏锁 // 暂停时释放防息屏锁
if let Some(ctx) = &self.ctx { if let Some(ctx) = &self.ctx {
let _ = ctx.tx.send(Envelope { let _ = ctx.tx.send(Envelope {
from: self.id(), from: self.id().to_string(),
to: Destination::Plugin("screen"), to: Destination::Plugin("screen".to_string()),
message: Message::ScreenLockRequest(false), message: Message::ScreenLockRequest(false),
}); });
} }
@@ -324,7 +324,7 @@ fn publish_status_message(
status: PlayerStatusData, status: PlayerStatusData,
) -> Result<()> { ) -> Result<()> {
tx.send(Envelope { tx.send(Envelope {
from: "video", from: "video".to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::PlayerStatus(status), message: Message::PlayerStatus(status),
})?; })?;
@@ -345,7 +345,7 @@ fn publish_state_changed(
} }
tx.send(Envelope { tx.send(Envelope {
from: "video", from: "video".to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::StateChanged { message: Message::StateChanged {
old_state, old_state,

View File

@@ -929,8 +929,10 @@ impl VideoProcessor {
if let Some(resolution) = parts.first() { if let Some(resolution) = parts.first() {
let dims: Vec<&str> = resolution.split('x').collect(); let dims: Vec<&str> = resolution.split('x').collect();
if dims.len() == 2 { if dims.len() == 2 {
let w_str = dims[0].trim_end_matches(|c: char| !c.is_ascii_digit());
let h_str = dims[1].trim_end_matches(|c: char| !c.is_ascii_digit());
if let (Ok(w), Ok(h)) = if let (Ok(w), Ok(h)) =
(dims[0].parse::<i32>(), dims[1].parse::<i32>()) (w_str.parse::<i32>(), h_str.parse::<i32>())
{ {
println!( println!(
"Detected screen size from xrandr (X11 active): {}x{}", "Detected screen size from xrandr (X11 active): {}x{}",

View File

@@ -0,0 +1,19 @@
# WifiPlugin — WiFi 管理
通过 nmcli 实现 WiFi 网络管理。
## 功能
- `scan` — 扫描可用 WiFi 网络(去重、信号强度排序)
- `connect` — 连接到指定 SSID
- `status` — 查询设备状态和 IP 地址
- `ap_start` — 启动 WiFi 热点
- `ap_stop` — 停止热点
## 消息流
收到 `WifiCommand` → 执行 nmcli → 广播 `WifiResult` (JSON)
## 平台
Linux only (nmcli)

View File

@@ -59,7 +59,7 @@ impl WifiPlugin {
.context("wifi plugin context is not initialized")?; .context("wifi plugin context is not initialized")?;
ctx.tx.send(Envelope { ctx.tx.send(Envelope {
from: "wifi", from: "wifi".to_string(),
to: Destination::Broadcast, to: Destination::Broadcast,
message: Message::WifiResult(payload), message: Message::WifiResult(payload),
})?; })?;
@@ -237,20 +237,20 @@ impl Default for WifiPlugin {
} }
impl Plugin for WifiPlugin { impl Plugin for WifiPlugin {
fn id(&self) -> &'static str { fn id(&self) -> &str {
"wifi" "wifi"
} }
fn info(&self) -> PluginInfo { fn info(&self) -> PluginInfo {
PluginInfo { PluginInfo {
name: "WiFi Manager", name: "WiFi Manager".to_string(),
version: "0.2.0", version: "0.2.0".to_string(),
description: "WiFi 管理 (nmcli)", description: "WiFi 管理 (nmcli)".to_string(),
platform: Platform::Linux, platform: Platform::Linux,
} }
} }
fn dependencies(&self) -> Vec<&'static str> { fn dependencies(&self) -> Vec<String> {
vec![] vec![]
} }