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:
249
Cargo.lock
generated
249
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -29,6 +35,12 @@ version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -119,6 +131,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -230,12 +251,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -374,7 +416,7 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 0.2.12",
|
||||
@@ -613,6 +655,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -653,6 +713,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -798,6 +868,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -870,6 +946,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -899,6 +984,68 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -989,6 +1136,22 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "showen_v2"
|
||||
version = "0.2.0"
|
||||
@@ -998,12 +1161,17 @@ dependencies = [
|
||||
"ctrlc",
|
||||
"dbus",
|
||||
"dbus-crossroads",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"libloading",
|
||||
"opencv",
|
||||
"rand",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tokio",
|
||||
"ureq",
|
||||
"warp",
|
||||
]
|
||||
|
||||
@@ -1017,6 +1185,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -1061,6 +1235,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -1083,6 +1263,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1234,6 +1425,28 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1323,6 +1536,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -1432,6 +1663,16 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -1496,6 +1737,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -1,3 +1,6 @@
|
||||
[workspace]
|
||||
members = [".", "plugin-sdk", "plugins/example-plugin"]
|
||||
|
||||
[package]
|
||||
name = "showen_v2"
|
||||
version = "0.2.0"
|
||||
@@ -21,3 +24,12 @@ futures-util = "0.3"
|
||||
# Linux 特有插件依赖
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
|
||||
# 动态插件加载
|
||||
libloading = "0.8"
|
||||
|
||||
# 远程插件仓库
|
||||
ureq = "2"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
semver = "1"
|
||||
|
||||
73
README.md
Normal file
73
README.md
Normal 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
|
||||
3
clients/alipay-miniapp/README.md
Normal file
3
clients/alipay-miniapp/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# alipay-miniapp/ — 支付宝小程序
|
||||
|
||||
支付宝小程序客户端。(规划中)
|
||||
3
clients/android/README.md
Normal file
3
clients/android/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# android/ — Android 原生应用
|
||||
|
||||
Kotlin/Jetpack Compose 实现的 Android 控制应用。(规划中)
|
||||
3
clients/cli/README.md
Normal file
3
clients/cli/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# cli/ — 命令行工具
|
||||
|
||||
终端命令行控制工具,用于调试和自动化。(规划中)
|
||||
3
clients/desktop/README.md
Normal file
3
clients/desktop/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# desktop/ — 桌面应用
|
||||
|
||||
Electron/Tauri 实现的桌面控制应用(Windows/macOS/Linux)。(规划中)
|
||||
6
clients/docs/README.md
Normal file
6
clients/docs/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# docs/ — 客户端开发文档
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `API.md` | ShowenV2 REST API 接口文档 |
|
||||
| `DESIGN.md` | 客户端 UI/UX 设计规范 |
|
||||
3
clients/flutter/README.md
Normal file
3
clients/flutter/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# flutter/ — Flutter 跨平台应用
|
||||
|
||||
Flutter 实现的跨平台移动控制应用(iOS + Android)。(规划中)
|
||||
3
clients/ios/README.md
Normal file
3
clients/ios/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ios/ — iOS 原生应用
|
||||
|
||||
Swift/SwiftUI 实现的 iOS 控制应用。(规划中)
|
||||
3
clients/plugin-dev/README.md
Normal file
3
clients/plugin-dev/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# plugin-dev/ — 插件开发工具
|
||||
|
||||
动态插件的开发、调试和打包工具。(规划中)
|
||||
3
clients/sdk/README.md
Normal file
3
clients/sdk/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# sdk/ — 多语言 SDK
|
||||
|
||||
各语言的 ShowenV2 客户端 SDK(Python、JavaScript、Go、Rust)。(规划中)
|
||||
9
clients/shared/README.md
Normal file
9
clients/shared/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# shared/ — 客户端共享代码
|
||||
|
||||
跨客户端复用的代码库。
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `api/` | ShowenV2 HTTP/WebSocket API 客户端封装 |
|
||||
| `models/` | 共享数据模型定义 |
|
||||
| `utils/` | 通用工具函数 |
|
||||
3
clients/shared/api/README.md
Normal file
3
clients/shared/api/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# api/ — API 客户端库
|
||||
|
||||
ShowenV2 HTTP REST API 和 WebSocket 客户端的封装,供各客户端应用复用。
|
||||
3
clients/shared/models/README.md
Normal file
3
clients/shared/models/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# models/ — 共享数据模型
|
||||
|
||||
客户端通用的数据模型定义(设备状态、播放列表、配置等)。
|
||||
3
clients/shared/utils/README.md
Normal file
3
clients/shared/utils/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# utils/ — 通用工具函数
|
||||
|
||||
客户端共享的工具函数(网络请求、数据格式化、错误处理等)。
|
||||
3
clients/smarthome/README.md
Normal file
3
clients/smarthome/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# smarthome/ — 智能家居集成
|
||||
|
||||
HomeKit / 米家 / 小度等智能家居平台接入。(规划中)
|
||||
3
clients/voice/README.md
Normal file
3
clients/voice/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# voice/ — 智能音箱集成
|
||||
|
||||
语音控制集成(天猫精灵、小爱同学、小度等)。(规划中)
|
||||
3
clients/watch/README.md
Normal file
3
clients/watch/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# watch/ — 智能手表应用
|
||||
|
||||
Apple Watch / Wear OS 控制应用。(规划中)
|
||||
3
clients/web/README.md
Normal file
3
clients/web/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# web/ — Web 应用
|
||||
|
||||
基于 React/Vue 的响应式 Web 控制界面。(规划中)
|
||||
3
clients/wechat-miniapp/README.md
Normal file
3
clients/wechat-miniapp/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# wechat-miniapp/ — 微信小程序
|
||||
|
||||
微信小程序客户端。(规划中)
|
||||
19
configs/README.md
Normal file
19
configs/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# configs/ — 状态机配置
|
||||
|
||||
JSON 格式的状态机配置文件,定义视频播放场景和状态转换规则。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dog_state_machine.json` | 狗宠物的场景/动画状态机 |
|
||||
| `cat_state_machine.json` | 猫宠物的场景/动画状态机 |
|
||||
|
||||
## 配置结构
|
||||
|
||||
每个 JSON 配置定义:
|
||||
- **场景 (scenes)**: 包含多个动画状态
|
||||
- **状态 (states)**: 绑定视频文件,定义播放行为
|
||||
- **转换 (transitions)**: 触发器驱动的状态跳转(voice / button / sensor)
|
||||
|
||||
由 `VideoPlugin` 的 `StateMachine` 模块解析和驱动。
|
||||
@@ -8,10 +8,10 @@
|
||||
"offset_x": 0,
|
||||
"offset_y": 0,
|
||||
"prevent_screen_lock": true,
|
||||
"render_width": 1280,
|
||||
"render_height": 800,
|
||||
"output_width": null,
|
||||
"output_height": null,
|
||||
"render_width": 1920,
|
||||
"render_height": 1080,
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"scale_mode": "stretch",
|
||||
"allow_upscale": true,
|
||||
"perspective_correction": {
|
||||
@@ -22,16 +22,16 @@
|
||||
0
|
||||
],
|
||||
[
|
||||
1280,
|
||||
1920,
|
||||
0
|
||||
],
|
||||
[
|
||||
1280,
|
||||
800
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
[
|
||||
0,
|
||||
800
|
||||
1080
|
||||
]
|
||||
]
|
||||
},
|
||||
|
||||
10
plugin-sdk/Cargo.toml
Normal file
10
plugin-sdk/Cargo.toml
Normal 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
45
plugin-sdk/README.md
Normal 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
292
plugin-sdk/src/lib.rs
Normal 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
19
plugins/README.md
Normal 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`。
|
||||
12
plugins/example-plugin/Cargo.toml
Normal file
12
plugins/example-plugin/Cargo.toml
Normal 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"
|
||||
29
plugins/example-plugin/README.md
Normal file
29
plugins/example-plugin/README.md
Normal 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 |
|
||||
72
plugins/example-plugin/src/lib.rs
Normal file
72
plugins/example-plugin/src/lib.rs
Normal 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
8
scripts/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# scripts/ — 脚本工具
|
||||
|
||||
构建、部署和开发辅助脚本。
|
||||
|
||||
目前为空目录,预留用于:
|
||||
- 交叉编译脚本(ARM aarch64 目标)
|
||||
- 部署脚本(推送到设备)
|
||||
- CI/CD 流水线脚本
|
||||
13
souls/README.md
Normal file
13
souls/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# souls/ — 团队成员档案
|
||||
|
||||
虚拟团队成员的角色定义文件,每个 `.md` 文件描述一位成员的背景、专长和职责。
|
||||
|
||||
## 成员
|
||||
|
||||
团队采用 AI 驱动开发模式,成员由 Claude Opus 4.6 模型扮演,各有分工。
|
||||
|
||||
## 子目录
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `archived/` | 已归档(淘汰)的成员档案 |
|
||||
3
souls/archived/README.md
Normal file
3
souls/archived/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# archived/ — 已归档成员
|
||||
|
||||
末尾淘汰的团队成员档案存放于此。
|
||||
8
src/README.md
Normal file
8
src/README.md
Normal 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
39
src/core/README.md
Normal 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` trait,JSON 序列化跨 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
316
src/core/dispatch.rs
Normal 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) => (¶ms[..pos], ¶ms[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
207
src/core/dynamic_plugin.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
use crate::core::config::AppConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 消息信封:包含来源、目的地、消息体
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
pub from: &'static str,
|
||||
pub from: String,
|
||||
pub to: Destination,
|
||||
pub message: Message,
|
||||
}
|
||||
|
||||
/// 消息目的地
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Destination {
|
||||
/// 点对点发送给指定插件
|
||||
Plugin(&'static str),
|
||||
Plugin(String),
|
||||
/// 广播给所有插件
|
||||
Broadcast,
|
||||
/// 发给管理层自身
|
||||
@@ -21,7 +22,7 @@ pub enum Destination {
|
||||
}
|
||||
|
||||
/// 所有插件间通信的类型安全消息
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Message {
|
||||
// ── 播放控制 ──
|
||||
PlayerCommand(PlayerCommand),
|
||||
@@ -48,12 +49,14 @@ pub enum Message {
|
||||
},
|
||||
|
||||
// ── 配置 ──
|
||||
/// Arc<AppConfig> 无法跨 FFI 序列化,动态插件通过 init 时传入的 JSON 获取配置
|
||||
#[serde(skip)]
|
||||
ConfigReloaded(Arc<AppConfig>),
|
||||
ConfigReloadRequest,
|
||||
|
||||
// ── 系统 ──
|
||||
Shutdown,
|
||||
PluginReady(&'static str),
|
||||
PluginReady(String),
|
||||
|
||||
// ── 扩展(未来插件用) ──
|
||||
Custom {
|
||||
@@ -62,7 +65,7 @@ pub enum Message {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PlayerCommand {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -72,7 +75,7 @@ pub enum PlayerCommand {
|
||||
ChangeScene(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlayerStatusData {
|
||||
pub running: bool,
|
||||
pub paused: bool,
|
||||
@@ -82,7 +85,7 @@ pub struct PlayerStatusData {
|
||||
pub current_video: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum WifiCommand {
|
||||
Scan,
|
||||
Connect { ssid: String, password: String },
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod dynamic_plugin;
|
||||
pub mod message;
|
||||
pub mod plugin;
|
||||
pub mod plugin_abi;
|
||||
pub mod plugin_loader;
|
||||
pub mod plugin_repo;
|
||||
pub mod service_manager;
|
||||
pub mod version_manager;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
use crate::core::config::AppConfig;
|
||||
use crate::core::message::{Envelope, Message};
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
/// 所有功能都通过实现此 trait 接入系统
|
||||
pub trait Plugin: Send {
|
||||
/// 唯一标识 (如 "video", "http", "ble")
|
||||
fn id(&self) -> &'static str;
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// 插件信息
|
||||
fn info(&self) -> PluginInfo;
|
||||
|
||||
/// 声明启动所需的插件依赖
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -29,14 +30,15 @@ pub trait Plugin: Send {
|
||||
fn stop(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
pub description: &'static str,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Platform {
|
||||
Any,
|
||||
Linux,
|
||||
|
||||
152
src/core/plugin_abi.rs
Normal file
152
src/core/plugin_abi.rs
Normal 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
336
src/core/plugin_loader.rs
Normal 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(®istry_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(®istry_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(®istry).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
186
src/core/plugin_repo.rs
Normal 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 ®istry.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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,72 @@
|
||||
use crate::core::config::AppConfig;
|
||||
use crate::core::message::{Destination, Envelope, Message};
|
||||
use crate::core::plugin::{Plugin, PluginContext};
|
||||
use crate::core::plugin_loader::ErrorPolicy;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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 {
|
||||
plugins: Vec<Box<dyn Plugin>>,
|
||||
plugins: Vec<PluginState>,
|
||||
config: Arc<AppConfig>,
|
||||
tx: mpsc::Sender<Envelope>,
|
||||
rx: mpsc::Receiver<Envelope>,
|
||||
@@ -26,30 +85,77 @@ impl ServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册插件
|
||||
/// 注册静态插件(编译时链接的插件)
|
||||
pub fn register(&mut self, plugin: Box<dyn Plugin>) {
|
||||
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 失败时按策略处理,不中断其他插件
|
||||
pub fn start_all(&mut self) -> Result<()> {
|
||||
self.validate_and_sort_plugins()?;
|
||||
|
||||
// init
|
||||
for plugin in &mut self.plugins {
|
||||
for state in &mut self.plugins {
|
||||
let ctx = PluginContext {
|
||||
tx: self.tx.clone(),
|
||||
config: Arc::clone(&self.config),
|
||||
};
|
||||
println!("[ServiceManager] 初始化插件: {}", plugin.id());
|
||||
plugin.init(ctx)?;
|
||||
println!("[ServiceManager] 初始化插件: {}", state.id());
|
||||
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
|
||||
for plugin in &mut self.plugins {
|
||||
println!("[ServiceManager] 启动插件: {}", plugin.id());
|
||||
plugin.start()?;
|
||||
for state in &mut self.plugins {
|
||||
if !state.enabled {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -69,13 +175,7 @@ impl ServiceManager {
|
||||
|
||||
match envelope.to {
|
||||
Destination::Plugin(id) => {
|
||||
if let Some(plugin) = self.plugins.iter_mut().find(|p| p.id() == id) {
|
||||
if let Err(e) = plugin.handle_message(envelope.message) {
|
||||
eprintln!("[ServiceManager] 插件 '{}' 处理消息失败: {}", id, e);
|
||||
}
|
||||
} else {
|
||||
eprintln!("[ServiceManager] 目标插件 '{}' 不存在", id);
|
||||
}
|
||||
self.deliver_to_plugin(&id, envelope.message);
|
||||
}
|
||||
Destination::Broadcast => {
|
||||
self.broadcast_message(envelope.message);
|
||||
@@ -92,15 +192,94 @@ impl ServiceManager {
|
||||
/// 逆序 stop() 所有插件
|
||||
pub fn stop_all(&mut self) -> Result<()> {
|
||||
println!("[ServiceManager] 停止所有插件");
|
||||
for plugin in self.plugins.iter_mut().rev() {
|
||||
println!("[ServiceManager] 停止插件: {}", plugin.id());
|
||||
if let Err(e) = plugin.stop() {
|
||||
eprintln!("[ServiceManager] 停止插件 '{}' 失败: {}", plugin.id(), e);
|
||||
for state in self.plugins.iter_mut().rev() {
|
||||
if !state.enabled {
|
||||
continue;
|
||||
}
|
||||
println!("[ServiceManager] 停止插件: {}", state.id());
|
||||
if let Err(e) = state.plugin.stop() {
|
||||
eprintln!(
|
||||
"[ServiceManager] 停止插件 '{}' 失败: {}",
|
||||
state.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
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<()> {
|
||||
match msg {
|
||||
@@ -154,14 +333,14 @@ impl ServiceManager {
|
||||
let mut plugin_set = HashSet::with_capacity(self.plugins.len());
|
||||
let mut dependency_map = HashMap::with_capacity(self.plugins.len());
|
||||
|
||||
for plugin in &self.plugins {
|
||||
let id = plugin.id();
|
||||
if !plugin_set.insert(id) {
|
||||
for state in &self.plugins {
|
||||
let id = state.id().to_string();
|
||||
if !plugin_set.insert(id.clone()) {
|
||||
return Err(anyhow!("duplicate plugin id registered: '{id}'"));
|
||||
}
|
||||
|
||||
plugin_ids.push(id);
|
||||
dependency_map.insert(id, plugin.dependencies());
|
||||
plugin_ids.push(id.clone());
|
||||
dependency_map.insert(id, state.plugin.dependencies());
|
||||
}
|
||||
|
||||
for (plugin_id, dependencies) in &dependency_map {
|
||||
@@ -170,7 +349,7 @@ impl ServiceManager {
|
||||
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!(
|
||||
"plugin '{plugin_id}' depends on missing plugin '{dependency}'"
|
||||
));
|
||||
@@ -197,8 +376,8 @@ impl ServiceManager {
|
||||
.iter()
|
||||
.all(|dependency| resolved.contains(dependency))
|
||||
{
|
||||
resolved.insert(*plugin_id);
|
||||
sorted_ids.push(*plugin_id);
|
||||
resolved.insert(plugin_id.clone());
|
||||
sorted_ids.push(plugin_id.clone());
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
@@ -206,8 +385,8 @@ impl ServiceManager {
|
||||
if !progressed {
|
||||
let unresolved = plugin_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|plugin_id| !resolved.contains(plugin_id))
|
||||
.filter(|plugin_id| !resolved.contains(plugin_id.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
@@ -220,10 +399,10 @@ impl ServiceManager {
|
||||
let mut remaining_plugins = std::mem::take(&mut self.plugins);
|
||||
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
|
||||
.iter()
|
||||
.position(|plugin| plugin.id() == plugin_id)
|
||||
.position(|state| state.id() == plugin_id)
|
||||
.ok_or_else(|| anyhow!("plugin '{plugin_id}' disappeared during sorting"))?;
|
||||
ordered_plugins.push(remaining_plugins.remove(index));
|
||||
}
|
||||
@@ -232,16 +411,60 @@ impl ServiceManager {
|
||||
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) {
|
||||
let should_shutdown = matches!(&msg, Message::Shutdown);
|
||||
|
||||
for plugin in &mut self.plugins {
|
||||
if let Err(e) = plugin.handle_message(msg.clone()) {
|
||||
for state in &mut self.plugins {
|
||||
if !state.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
match state.plugin.handle_message(msg.clone()) {
|
||||
Ok(()) => {
|
||||
state.reset_errors();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[ServiceManager] 插件 '{}' 处理广播消息失败: {}",
|
||||
plugin.id(),
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -77,14 +77,18 @@ fn message_label(message: &Message) -> String {
|
||||
}
|
||||
|
||||
struct TestPlugin {
|
||||
id: &'static str,
|
||||
deps: Vec<&'static str>,
|
||||
id: String,
|
||||
deps: Vec<String>,
|
||||
events: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TestPlugin {
|
||||
fn new(id: &'static str, deps: Vec<&'static str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
||||
Self { id, deps, events }
|
||||
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
deps: deps.into_iter().map(|s| s.to_string()).collect(),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self, entry: impl Into<String>) {
|
||||
@@ -93,20 +97,20 @@ impl TestPlugin {
|
||||
}
|
||||
|
||||
impl Plugin for TestPlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: self.id,
|
||||
version: "test",
|
||||
description: "test plugin",
|
||||
name: self.id.clone(),
|
||||
version: "test".to_string(),
|
||||
description: "test plugin".to_string(),
|
||||
platform: Platform::Any,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
self.deps.clone()
|
||||
}
|
||||
|
||||
@@ -168,8 +172,8 @@ fn routes_plugin_broadcast_and_manager_messages() {
|
||||
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "alpha",
|
||||
to: Destination::Plugin("beta"),
|
||||
from: "alpha".to_string(),
|
||||
to: Destination::Plugin("beta".to_string()),
|
||||
message: Message::Custom {
|
||||
kind: "direct".to_string(),
|
||||
payload: "hello".to_string(),
|
||||
@@ -178,7 +182,7 @@ fn routes_plugin_broadcast_and_manager_messages() {
|
||||
.expect("direct message should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "alpha",
|
||||
from: "alpha".to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::Custom {
|
||||
kind: "broadcast".to_string(),
|
||||
@@ -188,14 +192,14 @@ fn routes_plugin_broadcast_and_manager_messages() {
|
||||
.expect("broadcast message should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "alpha",
|
||||
from: "alpha".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::PluginReady("alpha"),
|
||||
message: Message::PluginReady("alpha".to_string()),
|
||||
})
|
||||
.expect("manager message should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test",
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
@@ -301,14 +305,14 @@ fn wifi_result_sent_to_manager_is_broadcast_to_plugins() {
|
||||
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "wifi",
|
||||
from: "wifi".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::WifiResult("connected".to_string()),
|
||||
})
|
||||
.expect("wifi result should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test",
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
@@ -379,8 +383,8 @@ fn all_plugin_ids_must_be_unique() {
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
for plugin in plugins {
|
||||
let id = plugin.id();
|
||||
assert!(ids.insert(id), "duplicate plugin id detected: '{}'", id);
|
||||
let id = plugin.id().to_string();
|
||||
assert!(ids.insert(id.clone()), "duplicate plugin id detected: '{}'", id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
286
src/core/version_manager.rs
Normal file
286
src/core/version_manager.rs
Normal 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(®istry)?;
|
||||
|
||||
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(®istry)?;
|
||||
|
||||
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(®istry)?;
|
||||
|
||||
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(®istry).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);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,7 @@
|
||||
//!
|
||||
//! 核心理念:平台不关心内容是什么,插件决定一切。
|
||||
|
||||
#![recursion_limit = "512"]
|
||||
|
||||
pub mod core;
|
||||
pub mod plugins;
|
||||
|
||||
41
src/main.rs
41
src/main.rs
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use showen_v2::core::config::AppConfig;
|
||||
use showen_v2::core::plugin_loader::PluginLoader;
|
||||
use showen_v2::core::service_manager::ServiceManager;
|
||||
use showen_v2::plugins::{
|
||||
ble::BlePlugin, http::HttpPlugin, screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin,
|
||||
@@ -64,6 +65,46 @@ fn main() -> Result<()> {
|
||||
manager.register(Box::new(HttpPlugin::new()));
|
||||
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 ®istry.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 信号处理
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
28
src/plugins/README.md
Normal file
28
src/plugins/README.md
Normal 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
22
src/plugins/ble/README.md
Normal 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)
|
||||
@@ -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 dbus::arg::{PropMap, Variant};
|
||||
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::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const BUS_NAME: &str = "io.showen.BleProvisioning";
|
||||
@@ -106,51 +106,30 @@ impl SharedState {
|
||||
|
||||
fn dispatch_command(&self, raw: &[u8]) -> Result<()> {
|
||||
let command = bytes_to_string(raw);
|
||||
let message = match command.as_str() {
|
||||
"scan" => Message::WifiCommand(WifiCommand::Scan),
|
||||
"status" => Message::WifiCommand(WifiCommand::Status),
|
||||
"connect" => {
|
||||
let ssid = self.ssid.lock().unwrap().clone();
|
||||
let password = self.password.lock().unwrap().clone();
|
||||
if ssid.trim().is_empty() {
|
||||
self.set_status(r#"{"ok":false,"action":"connect","error":"ssid required"}"#);
|
||||
return Err(anyhow!("ssid required before connect"));
|
||||
}
|
||||
Message::WifiCommand(WifiCommand::Connect { ssid, password })
|
||||
}
|
||||
"ap_start" => {
|
||||
let ssid = self.ssid.lock().unwrap().clone();
|
||||
let password = self.password.lock().unwrap().clone();
|
||||
if ssid.trim().is_empty() {
|
||||
self.set_status(r#"{"ok":false,"action":"ap_start","error":"ssid required"}"#);
|
||||
return Err(anyhow!("ssid required before ap_start"));
|
||||
}
|
||||
Message::WifiCommand(WifiCommand::ApStart { ssid, password })
|
||||
}
|
||||
"ap_stop" => Message::WifiCommand(WifiCommand::ApStop),
|
||||
other => {
|
||||
self.set_status(format!(
|
||||
r#"{{"ok":false,"action":"{}","error":"unsupported command"}}"#,
|
||||
other
|
||||
));
|
||||
return Err(anyhow!("unsupported BLE command: {}", other));
|
||||
}
|
||||
};
|
||||
|
||||
match dispatch::parse_command(&command, "ble", &ssid, &password) {
|
||||
Ok(result) => {
|
||||
self.tx
|
||||
.send(Envelope {
|
||||
from: "ble",
|
||||
to: Destination::Plugin("wifi"),
|
||||
message,
|
||||
})
|
||||
.context("failed to send WiFi command from BLE")?;
|
||||
|
||||
.send(result.envelope)
|
||||
.context("failed to send command from BLE")?;
|
||||
self.set_status(format!(
|
||||
r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
|
||||
command
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
self.set_status(format!(
|
||||
r#"{{"ok":false,"action":"{}","error":"{}"}}"#,
|
||||
command,
|
||||
error.replace('"', "\\\"")
|
||||
));
|
||||
Err(anyhow!("BLE command error: {}", error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppData;
|
||||
@@ -190,78 +169,15 @@ pub fn run_ble_service(
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let shared = SharedState::new(tx.clone());
|
||||
let (ready_tx, ready_rx) = mpsc::channel();
|
||||
let server_stop = Arc::clone(&stop);
|
||||
let server_shared = shared.clone();
|
||||
let server_device_name = device_name.clone();
|
||||
|
||||
let server_thread = thread::spawn(move || {
|
||||
run_server_connection(server_shared, server_device_name, ready_tx, server_stop)
|
||||
});
|
||||
|
||||
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)
|
||||
eprintln!("[BLE] connecting to system bus...");
|
||||
let conn =
|
||||
Connection::new_system().context("failed to connect to system bus for BLE")?;
|
||||
conn.request_name(BUS_NAME, false, true, false)
|
||||
.context("failed to request BLE D-Bus name")?;
|
||||
eprintln!("[BLE] D-Bus name acquired");
|
||||
|
||||
// 构建 Crossroads 并注册所有 GATT/Advertisement objects
|
||||
let mut cr = Crossroads::new();
|
||||
let object_manager = register_object_manager_iface(&mut cr);
|
||||
let service_iface = register_service_iface(&mut cr);
|
||||
@@ -333,14 +249,16 @@ fn run_server_connection(
|
||||
AdvertisementData {
|
||||
advertisement_type: "peripheral".to_string(),
|
||||
service_uuids: vec![SERVICE_UUID.to_string()],
|
||||
local_name: device_name,
|
||||
includes: vec!["tx-power".to_string()],
|
||||
local_name: device_name.clone(),
|
||||
includes: vec!["tx-power".to_string(), "local-name".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
// 注册 Crossroads 消息处理(必须在 RegisterApplication 之前,
|
||||
// 因为 BlueZ 会在注册过程中回调 GetManagedObjects)
|
||||
let shared_cr = Arc::new(Mutex::new(cr));
|
||||
let cr_for_handler = Arc::clone(&shared_cr);
|
||||
conn_server.start_receive(
|
||||
conn.start_receive(
|
||||
MatchRule::new_method_call(),
|
||||
Box::new(move |msg, conn| {
|
||||
if cr_for_handler
|
||||
@@ -349,25 +267,56 @@ fn run_server_connection(
|
||||
.handle_message(msg, conn)
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("[ble] crossroads dispatch error");
|
||||
eprintln!("[BLE] crossroads dispatch error");
|
||||
}
|
||||
true
|
||||
}),
|
||||
);
|
||||
|
||||
ready_tx
|
||||
.send(Ok(()))
|
||||
.map_err(|_| anyhow!("failed to notify BLE server readiness"))?;
|
||||
// 配置 adapter
|
||||
let adapter_path = find_adapter(&conn)?;
|
||||
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) {
|
||||
if shared.is_notifying() && shared.take_pending_notification() {
|
||||
emit_status_notification(&conn_server, &shared)?;
|
||||
emit_status_notification(&conn, &shared)?;
|
||||
}
|
||||
conn_server
|
||||
.process(SERVER_TIMEOUT)
|
||||
.context("BLE server connection process loop failed")?;
|
||||
drain_control_messages(&shared, &control_rx)?;
|
||||
conn.process(SERVER_TIMEOUT)
|
||||
.context("BLE connection process loop failed")?;
|
||||
}
|
||||
|
||||
drain_control_messages(&shared, &control_rx)?;
|
||||
|
||||
// 清理
|
||||
unregister_ble_objects(&conn, &adapter_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -552,6 +501,32 @@ fn build_managed_objects() -> ManagedObjects {
|
||||
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> {
|
||||
let proxy = conn.with_proxy(BLUEZ_SERVICE, "/", PROXY_TIMEOUT);
|
||||
let objects = proxy
|
||||
@@ -582,28 +557,6 @@ fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> {
|
||||
let gatt_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||
gatt_manager
|
||||
.method_call::<(), _, _, _>(
|
||||
GATT_MANAGER_IFACE,
|
||||
"RegisterApplication",
|
||||
(Path::from(APP_PATH.to_string()), PropMap::new()),
|
||||
)
|
||||
.context("failed to register BLE GATT application")?;
|
||||
|
||||
let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||
adv_manager
|
||||
.method_call::<(), _, _, _>(
|
||||
LE_ADVERTISING_MANAGER_IFACE,
|
||||
"RegisterAdvertisement",
|
||||
(Path::from(ADV_PATH.to_string()), PropMap::new()),
|
||||
)
|
||||
.context("failed to register BLE advertisement")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unregister_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> {
|
||||
let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||
let _ = adv_manager.method_call::<(), _, _, _>(
|
||||
@@ -629,12 +582,6 @@ fn bytes_to_string(value: &[u8]) -> 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<()> {
|
||||
loop {
|
||||
match control_rx.try_recv() {
|
||||
|
||||
@@ -38,20 +38,20 @@ impl Default for BlePlugin {
|
||||
}
|
||||
|
||||
impl Plugin for BlePlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
fn id(&self) -> &str {
|
||||
"ble"
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: "BLE Provisioning",
|
||||
version: "0.2.0",
|
||||
description: "BLE GATT WiFi 配网 (D-Bus BlueZ)",
|
||||
name: "BLE Provisioning".to_string(),
|
||||
version: "0.2.0".to_string(),
|
||||
description: "BLE GATT WiFi 配网 (D-Bus BlueZ)".to_string(),
|
||||
platform: Platform::Linux,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -82,7 +82,11 @@ impl Plugin for BlePlugin {
|
||||
self.control_tx = Some(control_tx);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
48
src/plugins/http/README.md
Normal file
48
src/plugins/http/README.md
Normal 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(启动顺序)
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct HttpState {
|
||||
player_status: Mutex<crate::core::message::PlayerStatusData>,
|
||||
ble_ready: AtomicBool,
|
||||
ws_events: broadcast::Sender<String>,
|
||||
/// 动态插件管理状态(由 Custom 消息更新)
|
||||
plugin_states: Mutex<Vec<crate::core::service_manager::PluginStateInfo>>,
|
||||
}
|
||||
|
||||
impl HttpState {
|
||||
@@ -68,6 +70,7 @@ impl HttpState {
|
||||
player_status: Mutex::new(player_status),
|
||||
ble_ready: AtomicBool::new(false),
|
||||
ws_events,
|
||||
plugin_states: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +182,21 @@ impl HttpState {
|
||||
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 {
|
||||
@@ -202,21 +220,21 @@ impl Default for HttpPlugin {
|
||||
}
|
||||
|
||||
impl Plugin for HttpPlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
fn id(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: "HTTP API",
|
||||
version: "0.2.0",
|
||||
description: "Web UI + REST API (warp)",
|
||||
name: "HTTP API".to_string(),
|
||||
version: "0.2.0".to_string(),
|
||||
description: "Web UI + REST API (warp)".to_string(),
|
||||
platform: Platform::Any,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
vec!["video"]
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec!["video".to_string()]
|
||||
}
|
||||
|
||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||
@@ -268,9 +286,9 @@ impl Plugin for HttpPlugin {
|
||||
};
|
||||
|
||||
if let Err(error) = tx.send(Envelope {
|
||||
from: "http",
|
||||
from: "http".to_string(),
|
||||
to: crate::core::message::Destination::Manager,
|
||||
message: Message::PluginReady("http"),
|
||||
message: Message::PluginReady("http".to_string()),
|
||||
}) {
|
||||
eprintln!("[HttpPlugin] failed to report ready state: {error}");
|
||||
}
|
||||
@@ -314,8 +332,11 @@ impl Plugin for HttpPlugin {
|
||||
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::Custom { ref kind, ref payload } if kind == "plugin_states" => {
|
||||
state.update_plugin_states(payload);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::HttpState;
|
||||
use crate::core::config::{self, AppConfig};
|
||||
use crate::core::dispatch;
|
||||
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||
use bytes::Buf;
|
||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||
@@ -67,7 +68,8 @@ pub(crate) fn build_routes(
|
||||
tx: mpsc::Sender<Envelope>,
|
||||
state: Arc<HttpState>,
|
||||
) -> 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(pause_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_display_route(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_delete_route(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(ble_start_route(Arc::clone(&state)))
|
||||
.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()
|
||||
.allow_any_origin()
|
||||
.allow_headers(["content-type"])
|
||||
@@ -112,14 +129,16 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
|
||||
}
|
||||
|
||||
fn ws_route(
|
||||
tx: mpsc::Sender<Envelope>,
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("ws")
|
||||
.and(warp::path::end())
|
||||
.and(warp::ws())
|
||||
.and(with_tx(tx))
|
||||
.and(with_state(state))
|
||||
.map(|ws: warp::ws::Ws, state: Arc<HttpState>| {
|
||||
ws.on_upgrade(move |socket| websocket_session(socket, state))
|
||||
.map(|ws: warp::ws::Ws, tx: mpsc::Sender<Envelope>, state: Arc<HttpState>| {
|
||||
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 {
|
||||
from: "http",
|
||||
from: "http".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::ConfigReloadRequest,
|
||||
}) {
|
||||
@@ -733,8 +752,8 @@ async fn send_video_command(
|
||||
success_message: impl Into<String>,
|
||||
) -> Result<warp::reply::Response, Infallible> {
|
||||
match tx.send(Envelope {
|
||||
from: "http",
|
||||
to: Destination::Plugin("video"),
|
||||
from: "http".to_string(),
|
||||
to: Destination::Plugin("video".to_string()),
|
||||
message,
|
||||
}) {
|
||||
Ok(()) => Ok(success_json(success_message.into())),
|
||||
@@ -778,8 +797,8 @@ async fn wifi_request(
|
||||
};
|
||||
|
||||
if let Err(error) = tx.send(Envelope {
|
||||
from: "http",
|
||||
to: Destination::Plugin("wifi"),
|
||||
from: "http".to_string(),
|
||||
to: Destination::Plugin("wifi".to_string()),
|
||||
message: Message::WifiCommand(command),
|
||||
}) {
|
||||
return Err(error_json(
|
||||
@@ -852,7 +871,11 @@ async fn wifi_request(
|
||||
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 events = state.ws_subscribe();
|
||||
|
||||
@@ -884,6 +907,11 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
|
||||
}
|
||||
} else if message.is_close() {
|
||||
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,
|
||||
@@ -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>>
|
||||
where
|
||||
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 {
|
||||
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">
|
||||
<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="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>
|
||||
</section>
|
||||
@@ -1055,24 +1356,29 @@ const WEB_UI_HTML: &str = r#"<!doctype html>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var cachedConfig=null;
|
||||
var cachedConfig=null;var ws=null;var wsReady=false;
|
||||
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)}
|
||||
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 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 gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}api('POST','/api/goto/'+idx)}
|
||||
function triggerPreset(name,value){api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))}
|
||||
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 wsCmd(obj){if(wsReady){ws.send(JSON.stringify(obj))}else{toast('WebSocket 未连接',true)}}
|
||||
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 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 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 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 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 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 stopAP(){api('POST','/api/wifi/ap/stop')}
|
||||
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}if(wsReady){wsCmd({cmd:'ap_start',ssid:ssid,password:password})}else{api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})}}
|
||||
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 startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).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({'&':'&','<':'<','>':'>','"':'"'})[ch]})}
|
||||
function escapeAttr(v){return escapeHtml(v).replace(/'/g,''')}
|
||||
function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
|
||||
refreshStatus();setInterval(refreshStatus,3000);
|
||||
connectWS();refreshStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
15
src/plugins/screen/README.md
Normal file
15
src/plugins/screen/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# ScreenPlugin — 屏幕管理
|
||||
|
||||
防止屏幕休眠和管理光标显示。
|
||||
|
||||
## 功能
|
||||
|
||||
- **唤醒锁**: `systemd-inhibit --what=idle:sleep` 阻止系统休眠
|
||||
- **光标隐藏**: `unclutter -idle 0 -root` 隐藏鼠标光标
|
||||
- 播放时自动获取唤醒锁,暂停时释放
|
||||
- 响应 `ScreenLockRequest` / `CursorVisibility` 消息
|
||||
|
||||
## 平台
|
||||
|
||||
Linux only (systemd-inhibit, unclutter)
|
||||
其他平台编译通过但功能为空操作。
|
||||
@@ -113,20 +113,20 @@ impl Default for ScreenPlugin {
|
||||
}
|
||||
|
||||
impl Plugin for ScreenPlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
fn id(&self) -> &str {
|
||||
"screen"
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: "Screen Manager",
|
||||
version: "0.2.0",
|
||||
description: "屏幕唤醒锁 + 光标管理",
|
||||
name: "Screen Manager".to_string(),
|
||||
version: "0.2.0".to_string(),
|
||||
description: "屏幕唤醒锁 + 光标管理".to_string(),
|
||||
platform: Platform::Linux,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
|
||||
24
src/plugins/video/README.md
Normal file
24
src/plugins/video/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# VideoPlugin — 视频播放引擎
|
||||
|
||||
基于 OpenCV 的视频播放插件,支持状态机驱动的场景切换和帧变换。
|
||||
|
||||
## 模块
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | VideoPlugin 实现 Plugin trait,工作线程管理,状态/消息发布 |
|
||||
| `processor.rs` | VideoProcessor:视频捕获、帧处理、过渡效果、播放列表管理 |
|
||||
| `state_machine.rs` | StateMachine:JSON 配置驱动的场景/动画状态机 |
|
||||
|
||||
## 功能
|
||||
|
||||
- 视频播放/暂停/上一个/下一个/跳转
|
||||
- 场景切换 (ChangeScene)
|
||||
- 触发器驱动的状态转换 (voice/button/sensor)
|
||||
- 帧变换:旋转、翻转、透视校正、色键抠像、亮度调节
|
||||
- 过渡效果:淡入淡出、直切
|
||||
- 配置热重载
|
||||
|
||||
## 平台
|
||||
|
||||
Any (需要 OpenCV 运行时)
|
||||
@@ -48,7 +48,7 @@ impl VideoPlugin {
|
||||
};
|
||||
|
||||
if let Err(error) = ctx.tx.send(Envelope {
|
||||
from: self.id(),
|
||||
from: self.id().to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::PlayerStatus(status),
|
||||
}) {
|
||||
@@ -70,7 +70,7 @@ impl VideoPlugin {
|
||||
}
|
||||
|
||||
if let Err(error) = ctx.tx.send(Envelope {
|
||||
from: self.id(),
|
||||
from: self.id().to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::StateChanged {
|
||||
old_state,
|
||||
@@ -89,20 +89,20 @@ impl Default for VideoPlugin {
|
||||
}
|
||||
|
||||
impl Plugin for VideoPlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
fn id(&self) -> &str {
|
||||
"video"
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: "Video Player",
|
||||
version: "0.2.0",
|
||||
description: "视频播放引擎 (OpenCV)",
|
||||
name: "Video Player".to_string(),
|
||||
version: "0.2.0".to_string(),
|
||||
description: "视频播放引擎 (OpenCV)".to_string(),
|
||||
platform: Platform::Any,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -130,9 +130,9 @@ impl Plugin for VideoPlugin {
|
||||
self.worker = Some(handle);
|
||||
|
||||
ctx.tx.send(Envelope {
|
||||
from: self.id(),
|
||||
from: self.id().to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::PluginReady(self.id()),
|
||||
message: Message::PluginReady(self.id().to_string()),
|
||||
})?;
|
||||
|
||||
self.publish_status();
|
||||
@@ -150,8 +150,8 @@ impl Plugin for VideoPlugin {
|
||||
// 恢复播放时重新获取防息屏锁
|
||||
if let Some(ctx) = &self.ctx {
|
||||
let _ = ctx.tx.send(Envelope {
|
||||
from: self.id(),
|
||||
to: Destination::Plugin("screen"),
|
||||
from: self.id().to_string(),
|
||||
to: Destination::Plugin("screen".to_string()),
|
||||
message: Message::ScreenLockRequest(true),
|
||||
});
|
||||
}
|
||||
@@ -161,8 +161,8 @@ impl Plugin for VideoPlugin {
|
||||
// 暂停时释放防息屏锁
|
||||
if let Some(ctx) = &self.ctx {
|
||||
let _ = ctx.tx.send(Envelope {
|
||||
from: self.id(),
|
||||
to: Destination::Plugin("screen"),
|
||||
from: self.id().to_string(),
|
||||
to: Destination::Plugin("screen".to_string()),
|
||||
message: Message::ScreenLockRequest(false),
|
||||
});
|
||||
}
|
||||
@@ -324,7 +324,7 @@ fn publish_status_message(
|
||||
status: PlayerStatusData,
|
||||
) -> Result<()> {
|
||||
tx.send(Envelope {
|
||||
from: "video",
|
||||
from: "video".to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::PlayerStatus(status),
|
||||
})?;
|
||||
@@ -345,7 +345,7 @@ fn publish_state_changed(
|
||||
}
|
||||
|
||||
tx.send(Envelope {
|
||||
from: "video",
|
||||
from: "video".to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::StateChanged {
|
||||
old_state,
|
||||
|
||||
@@ -929,8 +929,10 @@ impl VideoProcessor {
|
||||
if let Some(resolution) = parts.first() {
|
||||
let dims: Vec<&str> = resolution.split('x').collect();
|
||||
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)) =
|
||||
(dims[0].parse::<i32>(), dims[1].parse::<i32>())
|
||||
(w_str.parse::<i32>(), h_str.parse::<i32>())
|
||||
{
|
||||
println!(
|
||||
"Detected screen size from xrandr (X11 active): {}x{}",
|
||||
|
||||
19
src/plugins/wifi/README.md
Normal file
19
src/plugins/wifi/README.md
Normal 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)
|
||||
@@ -59,7 +59,7 @@ impl WifiPlugin {
|
||||
.context("wifi plugin context is not initialized")?;
|
||||
|
||||
ctx.tx.send(Envelope {
|
||||
from: "wifi",
|
||||
from: "wifi".to_string(),
|
||||
to: Destination::Broadcast,
|
||||
message: Message::WifiResult(payload),
|
||||
})?;
|
||||
@@ -237,20 +237,20 @@ impl Default for WifiPlugin {
|
||||
}
|
||||
|
||||
impl Plugin for WifiPlugin {
|
||||
fn id(&self) -> &'static str {
|
||||
fn id(&self) -> &str {
|
||||
"wifi"
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: "WiFi Manager",
|
||||
version: "0.2.0",
|
||||
description: "WiFi 管理 (nmcli)",
|
||||
name: "WiFi Manager".to_string(),
|
||||
version: "0.2.0".to_string(),
|
||||
description: "WiFi 管理 (nmcli)".to_string(),
|
||||
platform: Platform::Linux,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&'static str> {
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user