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

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

54/54 测试通过,0 warnings。

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

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

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

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

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

View File

@@ -1,4 +1,5 @@
use crate::core::message::{Destination, Envelope, Message, WifiCommand};
use crate::core::dispatch;
use crate::core::message::{Destination, Envelope, Message};
use anyhow::{anyhow, Context, Result};
use 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,50 +106,29 @@ 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 => {
let ssid = self.ssid.lock().unwrap().clone();
let password = self.password.lock().unwrap().clone();
match dispatch::parse_command(&command, "ble", &ssid, &password) {
Ok(result) => {
self.tx
.send(result.envelope)
.context("failed to send command from BLE")?;
self.set_status(format!(
r#"{{"ok":false,"action":"{}","error":"unsupported command"}}"#,
other
r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
command
));
return Err(anyhow!("unsupported BLE command: {}", other));
Ok(())
}
};
self.tx
.send(Envelope {
from: "ble",
to: Destination::Plugin("wifi"),
message,
})
.context("failed to send WiFi command from BLE")?;
self.set_status(format!(
r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
command
));
Ok(())
Err(error) => {
self.set_status(format!(
r#"{{"ok":false,"action":"{}","error":"{}"}}"#,
command,
error.replace('"', "\\\"")
));
Err(anyhow!("BLE command error: {}", error))
}
}
}
}
@@ -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() {

View File

@@ -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(())
}

View File

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

View File

@@ -43,6 +43,8 @@ pub(crate) struct HttpState {
player_status: Mutex<crate::core::message::PlayerStatusData>,
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);
}
_ => {}
}

View File

@@ -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({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch]})}
function escapeAttr(v){return escapeHtml(v).replace(/'/g,'&#39;')}
function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
refreshStatus();setInterval(refreshStatus,3000);
connectWS();refreshStatus();
</script>
</body>
</html>"#;

View File

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

View File

@@ -113,20 +113,20 @@ impl Default for ScreenPlugin {
}
impl Plugin for ScreenPlugin {
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![]
}

View File

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

View File

@@ -48,7 +48,7 @@ impl VideoPlugin {
};
if let Err(error) = ctx.tx.send(Envelope {
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,

View File

@@ -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{}",

View File

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

View File

@@ -59,7 +59,7 @@ impl WifiPlugin {
.context("wifi plugin context is not initialized")?;
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![]
}