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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user