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:
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>"#;
|
||||
|
||||
Reference in New Issue
Block a user