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:
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,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() {
|
||||
|
||||
@@ -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