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