From 3654af5843a28c1e4914888e681d39d535d10014 Mon Sep 17 00:00:00 2001 From: showen Date: Thu, 12 Mar 2026 05:31:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20config=E9=AA=8C=E8=AF=81=20+=20StateMac?= =?UTF-8?q?hine=20+=20WifiPlugin=20+=20ScreenPlugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 团队交付 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 Co-Authored-By: Claude Opus 4.6 --- src/core/config.rs | 318 +++++++++++++++++++++++++++-- src/plugins/screen/mod.rs | 133 +++++++++++- src/plugins/video/state_machine.rs | 276 ++++++++++++++++++++++++- src/plugins/wifi/mod.rs | 229 ++++++++++++++++++++- 4 files changed, 927 insertions(+), 29 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index b2c7343..708492d 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -128,9 +128,15 @@ impl Default for BrightnessAdjustConfig { } } -fn default_subject_boost() -> f64 { 1.5 } -fn default_background_suppress() -> f64 { 0.3 } -fn default_brightness_threshold() -> i32 { 30 } +fn default_subject_boost() -> f64 { + 1.5 +} +fn default_background_suppress() -> f64 { + 0.3 +} +fn default_brightness_threshold() -> i32 { + 30 +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -144,7 +150,9 @@ pub struct VideoItem { pub random_loop_range: Option<[i32; 2]>, } -fn default_loop_count() -> i32 { 1 } +fn default_loop_count() -> i32 { + 1 +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -217,7 +225,9 @@ pub struct NextStateEntry { pub weight: f32, } -fn default_weight() -> f32 { 1.0 } +fn default_weight() -> f32 { + 1.0 +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -280,8 +290,12 @@ impl Default for BleConfig { } } -fn default_ble_enabled() -> bool { true } -fn default_ble_device_name() -> String { "showen".to_string() } +fn default_ble_enabled() -> bool { + true +} +fn default_ble_device_name() -> String { + "showen".to_string() +} // ── 加载与验证 ── @@ -313,10 +327,24 @@ impl AppConfig { } pub fn validate(&self) -> Result<()> { - // 基础验证 — 完整验证在 Commit 2 补全 + self.display.validate()?; + if self.playlist.is_empty() { bail!("playlist 不能为空"); } + + let mut playlist_ids = HashSet::new(); + for (index, item) in self.playlist.iter().enumerate() { + item.validate(&format!("playlist[{index}]"))?; + if !playlist_ids.insert(item.id.as_str()) { + bail!("playlist[{index}] id '{}' 重复", item.id); + } + } + + self.transition.validate()?; + self.scenes.validate(&playlist_ids)?; + self.remote_control.validate()?; + Ok(()) } @@ -326,7 +354,9 @@ impl AppConfig { if !full_path.exists() { bail!( "视频文件 '{}' 不存在: {} (相对于 {})", - item.id, full_path.display(), self.source_path.display() + item.id, + full_path.display(), + self.source_path.display() ); } } @@ -360,6 +390,268 @@ fn absolute_from_current_dir(path: &Path) -> PathBuf { .unwrap_or_else(|_| path.to_path_buf()) } -fn default_render_width() -> i32 { 1024 } -fn default_render_height() -> i32 { 1024 } -fn default_allow_upscale() -> bool { true } +fn default_render_width() -> i32 { + 1024 +} +fn default_render_height() -> i32 { + 1024 +} +fn default_allow_upscale() -> bool { + true +} + +impl DisplayConfig { + pub fn validate(&self) -> Result<()> { + if self.window_title.trim().is_empty() { + bail!("display.window_title 不能为空"); + } + + if self.render_width <= 0 { + bail!("display.render_width 必须大于 0"); + } + + if self.render_height <= 0 { + bail!("display.render_height 必须大于 0"); + } + + if !matches!(self.rotation, 0 | 90 | 180 | 270) { + bail!("display.rotation 只能是 0/90/180/270"); + } + + self.perspective_correction.validate()?; + self.chroma_key.validate()?; + self.brightness_adjust.validate()?; + + Ok(()) + } +} + +impl PerspectiveCorrectionConfig { + pub fn validate(&self) -> Result<()> { + let point_count = self.points.len(); + if point_count != 0 && point_count != 4 { + bail!("display.perspective_correction.points 必须为 0 或 4 个点"); + } + + if self.enabled && point_count != 4 { + bail!("display.perspective_correction 启用时必须提供 4 个点"); + } + + Ok(()) + } +} + +impl ChromaKeyConfig { + pub fn validate(&self) -> Result<()> { + validate_hsv_component("display.chroma_key.hsv_min[0]", self.hsv_min[0], 0, 180)?; + validate_hsv_component("display.chroma_key.hsv_min[1]", self.hsv_min[1], 0, 255)?; + validate_hsv_component("display.chroma_key.hsv_min[2]", self.hsv_min[2], 0, 255)?; + validate_hsv_component("display.chroma_key.hsv_max[0]", self.hsv_max[0], 0, 180)?; + validate_hsv_component("display.chroma_key.hsv_max[1]", self.hsv_max[1], 0, 255)?; + validate_hsv_component("display.chroma_key.hsv_max[2]", self.hsv_max[2], 0, 255)?; + + for index in 0..3 { + if self.hsv_min[index] > self.hsv_max[index] { + bail!("display.chroma_key hsv_min[{index}] 不能大于 hsv_max[{index}]"); + } + } + + if self.feather < 0 { + bail!("display.chroma_key.feather 不能小于 0"); + } + + Ok(()) + } +} + +impl BrightnessAdjustConfig { + pub fn validate(&self) -> Result<()> { + if !self.subject_boost.is_finite() || self.subject_boost < 0.0 { + bail!("display.brightness_adjust.subject_boost 必须为非负有限数"); + } + + if !self.background_suppress.is_finite() || !(0.0..=1.0).contains(&self.background_suppress) + { + bail!("display.brightness_adjust.background_suppress 必须在 0.0 到 1.0 之间"); + } + + if !(0..=255).contains(&self.threshold) { + bail!("display.brightness_adjust.threshold 必须在 0 到 255 之间"); + } + + Ok(()) + } +} + +impl VideoItem { + pub fn validate(&self, section: &str) -> Result<()> { + if self.id.trim().is_empty() { + bail!("{section}.id 不能为空"); + } + + if self.path.trim().is_empty() { + bail!("{section}.path 不能为空"); + } + + if self.loop_count <= 0 { + bail!("{section}.loop_count 必须大于 0"); + } + + if let Some(duration) = self.duration { + if !duration.is_finite() || duration <= 0.0 { + bail!("{section}.duration 必须为正有限数"); + } + } + + Ok(()) + } +} + +impl TransitionConfig { + pub fn validate(&self) -> Result<()> { + if !self.duration.is_finite() || self.duration < 0.0 { + bail!("transition.duration 必须为非负有限数"); + } + + if self.enabled && self.transition_type != TransitionType::None && self.duration <= 0.0 { + bail!("transition 启用且 type 非 none 时,duration 必须大于 0"); + } + + Ok(()) + } +} + +impl ScenesConfig { + pub fn validate(&self, playlist_index: &HashSet<&str>) -> Result<()> { + self.rest.validate_items("scenes.rest")?; + self.active.validate_items("scenes.active")?; + self.sleep.validate_items("scenes.sleep")?; + self.interact.validate_items("scenes.interact")?; + + let Some(state_machine) = &self.state_machine else { + return Ok(()); + }; + + if state_machine.initial_state.trim().is_empty() { + bail!("scenes.state_machine.initial_state 不能为空"); + } + + if !state_machine + .states + .contains_key(&state_machine.initial_state) + { + bail!( + "scenes.state_machine.initial_state '{}' 不存在于 states 中", + state_machine.initial_state + ); + } + + let mut has_free_mode = false; + for (state_id, state) in &state_machine.states { + if state.mode == StateMode::FreeMode { + has_free_mode = true; + } + + for (step_index, step) in state.sequence.iter().enumerate() { + if step.video_id.trim().is_empty() { + bail!( + "scenes.state_machine.states['{state_id}'].sequence[{step_index}].video_id 不能为空" + ); + } + if !playlist_index.contains(step.video_id.as_str()) { + bail!( + "scenes.state_machine.states['{state_id}'].sequence[{step_index}] 引用的 video_id '{}' 不存在于 playlist 中", + step.video_id + ); + } + } + + if let Some(next_state) = &state.next_state { + if next_state.trim().is_empty() { + bail!("scenes.state_machine.states['{state_id}'].next_state 不能为空"); + } + if !state_machine.states.contains_key(next_state) { + bail!( + "scenes.state_machine.states['{state_id}'].next_state '{}' 不存在", + next_state + ); + } + } + + if let Some(next_states) = &state.next_states { + for (entry_index, entry) in next_states.iter().enumerate() { + if entry.state.trim().is_empty() { + bail!( + "scenes.state_machine.states['{state_id}'].next_states[{entry_index}].state 不能为空" + ); + } + if !state_machine.states.contains_key(&entry.state) { + bail!( + "scenes.state_machine.states['{state_id}'].next_states[{entry_index}] 的目标状态 '{}' 不存在", + entry.state + ); + } + if entry.weight <= 0.0 { + bail!( + "scenes.state_machine.states['{state_id}'].next_states[{entry_index}].weight 必须大于 0" + ); + } + } + } + + for (transition_index, transition) in state.transitions.iter().enumerate() { + if transition.target_state.trim().is_empty() { + bail!( + "scenes.state_machine.states['{state_id}'].transitions[{transition_index}].target_state 不能为空" + ); + } + if !state_machine.states.contains_key(&transition.target_state) { + bail!( + "scenes.state_machine.states['{state_id}'].transitions[{transition_index}] 的 target_state '{}' 不存在", + transition.target_state + ); + } + } + } + + if !has_free_mode { + bail!("scenes.state_machine 至少需要一个 FreeMode 状态"); + } + + Ok(()) + } +} + +impl RemoteControlConfig { + pub fn validate(&self) -> Result<()> { + if self.enabled && self.host.trim().is_empty() { + bail!("remote_control.enabled 为 true 时,host 不能为空"); + } + + if self.enabled && self.port == 0 { + bail!("remote_control.enabled 为 true 时,port 不能为 0"); + } + + Ok(()) + } +} + +trait ValidateVideoItems { + fn validate_items(&self, section: &str) -> Result<()>; +} + +impl ValidateVideoItems for [VideoItem] { + fn validate_items(&self, section: &str) -> Result<()> { + for (index, item) in self.iter().enumerate() { + item.validate(&format!("{section}[{index}]"))?; + } + Ok(()) + } +} + +fn validate_hsv_component(field: &str, value: i32, min: i32, max: i32) -> Result<()> { + if !(min..=max).contains(&value) { + bail!("{field} 必须在 {min} 到 {max} 之间"); + } + Ok(()) +} diff --git a/src/plugins/screen/mod.rs b/src/plugins/screen/mod.rs index 30f72a3..bc9cbf4 100644 --- a/src/plugins/screen/mod.rs +++ b/src/plugins/screen/mod.rs @@ -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, + wake_lock_child: Option, + 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(()) + } } diff --git a/src/plugins/video/state_machine.rs b/src/plugins/video/state_machine.rs index cf5a22e..3d2b0a4 100644 --- a/src/plugins/video/state_machine.rs +++ b/src/plugins/video/state_machine.rs @@ -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, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + } + } +} diff --git a/src/plugins/wifi/mod.rs b/src/plugins/wifi/mod.rs index 7804030..1a0a511 100644 --- a/src/plugins/wifi/mod.rs +++ b/src/plugins/wifi/mod.rs @@ -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, } +#[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, +} + impl WifiPlugin { pub fn new() -> Self { Self { ctx: None } } + + fn run_nmcli(args: &[&str]) -> Result { + 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 { + 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::() + .unwrap_or_default(); + let security = parts.next().unwrap_or_default().trim().to_string(); + + WifiNetwork { + ssid, + signal, + security, + } + }) + .collect::>(); + + Ok(json!({ + "ok": true, + "action": "scan", + "networks": networks, + })) + } + + fn connect_network(&self, ssid: &str, password: &str) -> Result { + 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 { + 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> = 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::>(); + + Ok(json!({ + "ok": true, + "action": "status", + "devices": devices, + })) + } + + fn ap_start(&self, ssid: &str, password: &str) -> Result { + 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 { + 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(()) + } }