feat: config验证 + StateMachine + WifiPlugin + ScreenPlugin

团队交付 Phase 1 第一轮:
- 张明远: config.rs 完整验证逻辑 (Display/VideoItem/Transition/Scenes/StateMachine)
- 李思琪: state_machine.rs 完整实现 (defer/ignore triggers, 加权随机, loop range)
- 王浩然: wifi/mod.rs WiFi管理插件 (scan/connect/status/ap via nmcli)
- 赵雨薇: screen/mod.rs 屏幕管理插件 (systemd-inhibit唤醒锁 + unclutter光标)

cargo check 零 warning 通过。

Co-Authored-By: GPT-5.4 <noreply@openai.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-12 05:31:21 +08:00
parent 311e4bad0e
commit 3654af5843
4 changed files with 927 additions and 29 deletions

View File

@@ -2,22 +2,106 @@
//!
//! 唤醒锁systemd-inhibit、光标隐藏unclutter
use crate::core::message::Message;
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
use crate::core::{message::Message, plugin::*};
use anyhow::Result;
use std::process::{Child, Command, Stdio};
pub struct ScreenPlugin {
ctx: Option<PluginContext>,
wake_lock_child: Option<Child>,
cursor_hidden: bool,
}
impl ScreenPlugin {
pub fn new() -> Self {
Self { ctx: None }
Self {
ctx: None,
wake_lock_child: None,
cursor_hidden: false,
}
}
#[cfg(target_os = "linux")]
fn start_wake_lock(&mut self) {
if self.wake_lock_child.is_some() {
return;
}
match Command::new("systemd-inhibit")
.arg("--what=idle")
.arg("--who=ShowenV2")
.arg("--why=Prevent screen lock during playback")
.arg("sleep")
.arg("infinity")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => self.wake_lock_child = Some(child),
Err(err) => eprintln!("[ScreenPlugin] 启动防息屏失败: {err}"),
}
}
#[cfg(not(target_os = "linux"))]
fn start_wake_lock(&mut self) {}
#[cfg(target_os = "linux")]
fn stop_wake_lock(&mut self) {
if let Some(mut child) = self.wake_lock_child.take() {
if let Err(err) = child.kill() {
eprintln!("[ScreenPlugin] 停止防息屏失败: {err}");
}
let _ = child.wait();
}
}
#[cfg(not(target_os = "linux"))]
fn stop_wake_lock(&mut self) {}
#[cfg(target_os = "linux")]
fn set_cursor_hidden(&mut self, hidden: bool) {
if hidden == self.cursor_hidden {
return;
}
if hidden {
match Command::new("unclutter")
.arg("-idle")
.arg("0")
.arg("-root")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => self.cursor_hidden = true,
Err(err) => eprintln!("[ScreenPlugin] 隐藏光标失败: {err}"),
}
} else {
match Command::new("pkill")
.arg("unclutter")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Ok(_) => self.cursor_hidden = false,
Err(err) => eprintln!("[ScreenPlugin] 恢复光标失败: {err}"),
}
}
}
#[cfg(not(target_os = "linux"))]
fn set_cursor_hidden(&mut self, hidden: bool) {
self.cursor_hidden = hidden;
}
}
impl Plugin for ScreenPlugin {
fn id(&self) -> &'static str { "screen" }
fn id(&self) -> &'static str {
"screen"
}
fn info(&self) -> PluginInfo {
PluginInfo {
@@ -33,7 +117,42 @@ impl Plugin for ScreenPlugin {
Ok(())
}
fn start(&mut self) -> Result<()> { Ok(()) }
fn handle_message(&mut self, _msg: Message) -> Result<()> { Ok(()) }
fn stop(&mut self) -> Result<()> { Ok(()) }
fn start(&mut self) -> Result<()> {
if self
.ctx
.as_ref()
.map(|ctx| ctx.config.display.prevent_screen_lock)
.unwrap_or(false)
{
self.start_wake_lock();
}
self.set_cursor_hidden(true);
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg {
Message::ScreenLockRequest(lock) => {
if lock {
self.start_wake_lock();
} else {
self.stop_wake_lock();
}
}
Message::CursorVisibility(visible) => self.set_cursor_hidden(!visible),
Message::Shutdown => {
self.stop()?;
}
_ => {}
}
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.stop_wake_lock();
self.set_cursor_hidden(false);
Ok(())
}
}

View File

@@ -1 +1,275 @@
// StateMachine — 待 Commit 4 迁移
use crate::core::config::{
AnimationStep, NextStateEntry, StateConfig, StateMachineConfig, StateMode, StateTransition,
TriggerType,
};
use anyhow::Result;
use rand::Rng;
pub struct StateMachine {
pub config: StateMachineConfig,
pub current_state: String,
pub current_sequence_index: usize,
pub current_loop_remaining: i32,
pub pending_trigger_target: Option<String>,
}
impl StateMachine {
pub fn new(config: StateMachineConfig) -> Self {
Self {
current_state: config.initial_state.clone(),
config,
current_sequence_index: 0,
current_loop_remaining: 0,
pending_trigger_target: None,
}
}
pub fn start(&mut self) -> Result<()> {
self.current_state = self.config.initial_state.clone();
self.pending_trigger_target = None;
self.reset_state_progress()
}
pub fn current_video_id(&self) -> Option<String> {
self.current_state_config()
.and_then(|state| state.sequence.get(self.current_sequence_index))
.map(|step| step.video_id.clone())
}
pub fn current_state_config(&self) -> Option<&StateConfig> {
self.config.states.get(&self.current_state)
}
pub fn on_video_completed(&mut self) -> Result<bool> {
self.ensure_current_state_valid()?;
if self.current_loop_remaining > 1 {
self.current_loop_remaining -= 1;
return Ok(true);
}
if let Some(target_state) = self.pending_trigger_target.take() {
self.transition_to_state(&target_state)?;
return Ok(true);
}
let sequence_len = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?
.sequence
.len();
if self.current_sequence_index + 1 < sequence_len {
self.current_sequence_index += 1;
self.current_loop_remaining = self.resolve_current_loop_count()?;
return Ok(true);
}
let next_state = self.select_next_state()?;
self.transition_to_state(&next_state)?;
Ok(true)
}
pub fn handle_trigger(&mut self, name: &str, value: &str) -> Result<bool> {
let Some(state) = self.current_state_config() else {
return Ok(false);
};
if state.ignore_triggers {
return Ok(false);
}
let target_state = self
.matching_transition(&state.transitions, name, value)
.map(|transition| transition.target_state.clone());
let Some(target_state) = target_state else {
return Ok(false);
};
if state.defer_triggers {
self.pending_trigger_target = Some(target_state);
return Ok(true);
}
self.transition_to_state(&target_state)?;
Ok(true)
}
pub fn check_random_triggers(&mut self) -> Result<bool> {
let Some(state) = self.current_state_config() else {
return Ok(false);
};
if state.ignore_triggers {
return Ok(false);
}
if !matches!(state.mode, StateMode::FreeMode | StateMode::InteractiveMode) {
return Ok(false);
}
let mut rng = rand::thread_rng();
let target_state = state
.transitions
.iter()
.filter_map(|transition| match &transition.trigger {
TriggerType::Random { probability } => {
let probability = probability.clamp(0.0, 1.0);
if rng.gen_bool(probability) {
Some(transition)
} else {
None
}
}
_ => None,
})
.max_by_key(|transition| transition.priority)
.map(|transition| transition.target_state.clone());
let Some(target_state) = target_state else {
return Ok(false);
};
if state.defer_triggers {
self.pending_trigger_target = Some(target_state);
return Ok(true);
}
self.transition_to_state(&target_state)?;
Ok(true)
}
pub fn has_pending_trigger(&self) -> bool {
self.pending_trigger_target.is_some()
}
fn reset_state_progress(&mut self) -> Result<()> {
self.ensure_current_state_valid()?;
self.current_sequence_index = 0;
self.current_loop_remaining = self.resolve_current_loop_count()?;
Ok(())
}
fn transition_to_state(&mut self, target_state: &str) -> Result<()> {
if !self.config.states.contains_key(target_state) {
anyhow::bail!("目标状态不存在: {}", target_state);
}
self.current_state = target_state.to_string();
self.pending_trigger_target = None;
self.reset_state_progress()
}
fn ensure_current_state_valid(&self) -> Result<()> {
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
if state.sequence.is_empty() {
anyhow::bail!("状态 '{}' 的 sequence 不能为空", state.name);
}
if self.current_sequence_index >= state.sequence.len() {
anyhow::bail!(
"状态 '{}' 的 sequence 索引越界: {} >= {}",
state.name,
self.current_sequence_index,
state.sequence.len()
);
}
Ok(())
}
fn resolve_current_loop_count(&self) -> Result<i32> {
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
let step = state
.sequence
.get(self.current_sequence_index)
.ok_or_else(|| {
anyhow::anyhow!(
"状态 '{}' 的 sequence 索引越界: {}",
state.name,
self.current_sequence_index
)
})?;
Ok(self.resolve_step_loop_count(step))
}
fn resolve_step_loop_count(&self, step: &AnimationStep) -> i32 {
if let Some([start, end]) = step.random_loop_range {
let min = start.min(end);
let max = start.max(end);
return rand::thread_rng().gen_range(min..=max).max(1);
}
step.loop_count.unwrap_or(1).max(1)
}
fn select_next_state(&self) -> Result<String> {
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
if let Some(entries) = &state.next_states {
if let Some(next_state) = self.select_weighted_next_state(entries) {
return Ok(next_state);
}
}
if let Some(next_state) = &state.next_state {
return Ok(next_state.clone());
}
match state.mode {
StateMode::FreeMode | StateMode::InteractiveMode => Ok(self.current_state.clone()),
}
}
fn select_weighted_next_state(&self, entries: &[NextStateEntry]) -> Option<String> {
let valid_entries: Vec<&NextStateEntry> =
entries.iter().filter(|entry| entry.weight > 0.0).collect();
if valid_entries.is_empty() {
return None;
}
let total_weight: f32 = valid_entries.iter().map(|entry| entry.weight).sum();
if total_weight <= 0.0 {
return None;
}
let mut cursor = rand::thread_rng().gen_range(0.0..total_weight);
for entry in valid_entries {
cursor -= entry.weight;
if cursor <= 0.0 {
return Some(entry.state.clone());
}
}
entries.last().map(|entry| entry.state.clone())
}
fn matching_transition<'a>(
&self,
transitions: &'a [StateTransition],
name: &str,
value: &str,
) -> Option<&'a StateTransition> {
transitions
.iter()
.filter(|transition| Self::trigger_matches(&transition.trigger, name, value))
.max_by_key(|transition| transition.priority)
}
fn trigger_matches(trigger: &TriggerType, name: &str, value: &str) -> bool {
match trigger {
TriggerType::Button { name: expected } => expected == name,
TriggerType::Voice { keyword } => keyword == value || keyword == name,
TriggerType::Sensor { name: expected } => expected == name,
TriggerType::Timer { .. } | TriggerType::Random { .. } => false,
}
}
}

View File

@@ -1,23 +1,223 @@
//! WifiPlugin — WiFi 管理
//!
//! 通过 nmcli 实现 WiFi 扫描、连接、AP 热点。
//! 通过 nmcli 实现 WiFi 扫描、连接、状态查询、AP 热点启停
use crate::core::message::Message;
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
use anyhow::Result;
use crate::core::{message::*, plugin::*};
use anyhow::{anyhow, Context, Result};
use serde::Serialize;
use serde_json::json;
use std::collections::HashMap;
use std::process::Command;
use std::thread;
use std::time::Duration;
pub struct WifiPlugin {
ctx: Option<PluginContext>,
}
#[derive(Serialize)]
struct WifiNetwork {
ssid: String,
signal: i32,
security: String,
}
#[derive(Serialize)]
struct DeviceStatus {
device: String,
device_type: String,
state: String,
connection: String,
ip4_addresses: Vec<String>,
}
impl WifiPlugin {
pub fn new() -> Self {
Self { ctx: None }
}
fn run_nmcli(args: &[&str]) -> Result<String> {
let output = Command::new("nmcli")
.args(args)
.output()
.with_context(|| format!("failed to execute nmcli {:?}", args))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() { stderr } else { stdout };
Err(anyhow!("nmcli {:?} failed: {}", args, message))
}
}
fn send_result(&self, payload: String) -> Result<()> {
let ctx = self
.ctx
.as_ref()
.context("wifi plugin context is not initialized")?;
ctx.tx.send(Envelope {
from: "wifi",
to: Destination::Manager,
message: Message::WifiResult(payload),
})?;
Ok(())
}
fn handle_wifi_command(&self, cmd: WifiCommand) -> String {
let result = match cmd {
WifiCommand::Scan => self.scan_networks(),
WifiCommand::Connect { ssid, password } => self.connect_network(&ssid, &password),
WifiCommand::Status => self.status(),
WifiCommand::ApStart { ssid, password } => self.ap_start(&ssid, &password),
WifiCommand::ApStop => self.ap_stop(),
};
match result {
Ok(value) => value.to_string(),
Err(err) => json!({
"ok": false,
"error": err.to_string(),
})
.to_string(),
}
}
fn scan_networks(&self) -> Result<serde_json::Value> {
Self::run_nmcli(&["device", "wifi", "rescan"])?;
thread::sleep(Duration::from_secs(2));
let output =
Self::run_nmcli(&["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"])?;
let networks = output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut parts = line.splitn(3, ':');
let ssid = parts.next().unwrap_or_default().trim().to_string();
let signal = parts
.next()
.unwrap_or_default()
.trim()
.parse::<i32>()
.unwrap_or_default();
let security = parts.next().unwrap_or_default().trim().to_string();
WifiNetwork {
ssid,
signal,
security,
}
})
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"action": "scan",
"networks": networks,
}))
}
fn connect_network(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
let output = Self::run_nmcli(&["device", "wifi", "connect", ssid, "password", password])?;
Ok(json!({
"ok": true,
"action": "connect",
"ssid": ssid,
"output": output,
}))
}
fn status(&self) -> Result<serde_json::Value> {
let device_output = Self::run_nmcli(&[
"-t",
"-f",
"DEVICE,TYPE,STATE,CONNECTION",
"device",
"status",
])?;
let ip_output = Self::run_nmcli(&["-t", "-f", "DEVICE,IP4.ADDRESS", "device", "show"])?;
let mut ip_map: HashMap<String, Vec<String>> = HashMap::new();
for line in ip_output.lines().filter(|line| !line.trim().is_empty()) {
let mut parts = line.splitn(2, ':');
let device = parts.next().unwrap_or_default().trim();
let address = parts.next().unwrap_or_default().trim();
if device.is_empty() || address.is_empty() {
continue;
}
ip_map
.entry(device.to_string())
.or_default()
.push(address.to_string());
}
let devices = device_output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut parts = line.splitn(4, ':');
let device = parts.next().unwrap_or_default().trim().to_string();
DeviceStatus {
ip4_addresses: ip_map.remove(&device).unwrap_or_default(),
device,
device_type: parts.next().unwrap_or_default().trim().to_string(),
state: parts.next().unwrap_or_default().trim().to_string(),
connection: parts.next().unwrap_or_default().trim().to_string(),
}
})
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"action": "status",
"devices": devices,
}))
}
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
let output = Self::run_nmcli(&[
"device", "wifi", "hotspot", "ssid", ssid, "password", password,
])?;
Ok(json!({
"ok": true,
"action": "ap_start",
"ssid": ssid,
"output": output,
}))
}
fn ap_stop(&self) -> Result<serde_json::Value> {
let active = Self::run_nmcli(&["-t", "-f", "NAME", "connection", "show", "--active"])?;
let hotspot_name = active
.lines()
.map(str::trim)
.find(|name| *name == "hotspot")
.ok_or_else(|| anyhow!("active hotspot connection 'hotspot' not found"))?;
let output = Self::run_nmcli(&["connection", "down", hotspot_name])?;
Ok(json!({
"ok": true,
"action": "ap_stop",
"connection": hotspot_name,
"output": output,
}))
}
}
impl Plugin for WifiPlugin {
fn id(&self) -> &'static str { "wifi" }
fn id(&self) -> &'static str {
"wifi"
}
fn info(&self) -> PluginInfo {
PluginInfo {
@@ -33,7 +233,20 @@ impl Plugin for WifiPlugin {
Ok(())
}
fn start(&mut self) -> Result<()> { Ok(()) }
fn handle_message(&mut self, _msg: Message) -> Result<()> { Ok(()) }
fn stop(&mut self) -> Result<()> { Ok(()) }
fn start(&mut self) -> Result<()> {
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
if let Message::WifiCommand(cmd) = msg {
let payload = self.handle_wifi_command(cmd);
self.send_result(payload)?;
}
Ok(())
}
fn stop(&mut self) -> Result<()> {
Ok(())
}
}