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

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

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