Initial commit
This commit is contained in:
12
crates/infinity_config/Cargo.toml
Normal file
12
crates/infinity_config/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "infinity_config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
27
crates/infinity_config/src/lib.rs
Normal file
27
crates/infinity_config/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
mod model;
|
||||
mod validation;
|
||||
|
||||
pub use model::*;
|
||||
pub use validation::*;
|
||||
|
||||
use std::{fs, path::Path};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectLoadError {
|
||||
#[error("failed to read project config: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("failed to parse project config: {0}")]
|
||||
Parse(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn from_toml_str(raw: &str) -> Result<Self, toml::de::Error> {
|
||||
toml::from_str(raw)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, ProjectLoadError> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
|
||||
}
|
||||
|
||||
261
crates/infinity_config/src/model.rs
Normal file
261
crates/infinity_config/src/model.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const REQUIRED_NODE_COUNT: usize = 6;
|
||||
pub const REQUIRED_OUTPUTS_PER_NODE: usize = 3;
|
||||
pub const REQUIRED_LED_COUNT_PER_OUTPUT: u16 = 106;
|
||||
pub const REQUIRED_TOTAL_OUTPUTS: usize = REQUIRED_NODE_COUNT * REQUIRED_OUTPUTS_PER_NODE;
|
||||
|
||||
fn default_expected_node_count() -> usize {
|
||||
REQUIRED_NODE_COUNT
|
||||
}
|
||||
|
||||
fn default_outputs_per_node() -> usize {
|
||||
REQUIRED_OUTPUTS_PER_NODE
|
||||
}
|
||||
|
||||
fn default_led_count_per_output() -> u16 {
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_transition_ms() -> u32 {
|
||||
150
|
||||
}
|
||||
|
||||
fn default_network_send_hz() -> u16 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_logic_hz() -> u16 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_preview_hz() -> u16 {
|
||||
15
|
||||
}
|
||||
|
||||
fn default_heartbeat_hz() -> u16 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_hold_last_frame_ms() -> u32 {
|
||||
1500
|
||||
}
|
||||
|
||||
fn default_brightness_limit() -> f32 {
|
||||
0.35
|
||||
}
|
||||
|
||||
fn default_start_brightness() -> f32 {
|
||||
0.2
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectConfig {
|
||||
pub metadata: ProjectMetadata,
|
||||
pub topology: TopologyConfig,
|
||||
#[serde(default)]
|
||||
pub transport_profiles: Vec<TransportProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub safety_profiles: Vec<SafetyProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub presets: Vec<PresetConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ProjectMetadata {
|
||||
pub project_name: String,
|
||||
pub schema_version: u32,
|
||||
pub default_transport_profile: String,
|
||||
pub default_safety_profile: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TopologyConfig {
|
||||
#[serde(default = "default_expected_node_count")]
|
||||
pub expected_node_count: usize,
|
||||
#[serde(default = "default_outputs_per_node")]
|
||||
pub outputs_per_node: usize,
|
||||
#[serde(default = "default_led_count_per_output")]
|
||||
pub leds_per_output: u16,
|
||||
pub nodes: Vec<NodeConfig>,
|
||||
#[serde(default)]
|
||||
pub layout_panels: Vec<LayoutPanelConfig>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<GroupConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeConfig {
|
||||
pub node_id: String,
|
||||
pub display_name: String,
|
||||
pub network: NodeNetworkConfig,
|
||||
pub outputs: Vec<PanelOutputConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeNetworkConfig {
|
||||
pub reserved_ip: Option<String>,
|
||||
pub telemetry_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PanelOutputConfig {
|
||||
pub panel_position: PanelPosition,
|
||||
pub physical_output_name: String,
|
||||
pub driver_channel: DriverChannelRef,
|
||||
pub led_count: u16,
|
||||
pub direction: LedDirection,
|
||||
pub color_order: ColorOrder,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub validation_state: ValidationState,
|
||||
pub logical_panel_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DriverChannelRef {
|
||||
pub kind: DriverKind,
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PanelPosition {
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LedDirection {
|
||||
Forward,
|
||||
Reverse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColorOrder {
|
||||
Rgb,
|
||||
Rbg,
|
||||
Grb,
|
||||
Gbr,
|
||||
Brg,
|
||||
Bgr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriverKind {
|
||||
PendingValidation,
|
||||
Gpio,
|
||||
RmtChannel,
|
||||
I2sLane,
|
||||
UartPort,
|
||||
SpiBus,
|
||||
ExternalDriver,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidationState {
|
||||
PendingHardwareValidation,
|
||||
Validated,
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PanelRef {
|
||||
pub node_id: String,
|
||||
pub panel_position: PanelPosition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LayoutPanelConfig {
|
||||
pub node_id: String,
|
||||
pub panel_position: PanelPosition,
|
||||
pub row: usize,
|
||||
pub column: usize,
|
||||
#[serde(default)]
|
||||
pub rotation_degrees: i16,
|
||||
#[serde(default)]
|
||||
pub mirror_x: bool,
|
||||
#[serde(default)]
|
||||
pub mirror_y: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GroupConfig {
|
||||
pub group_id: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub members: Vec<PanelRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TransportProfileConfig {
|
||||
pub profile_id: String,
|
||||
pub mode: TransportMode,
|
||||
#[serde(default = "default_logic_hz")]
|
||||
pub logic_hz: u16,
|
||||
#[serde(default = "default_network_send_hz")]
|
||||
pub network_send_hz: u16,
|
||||
#[serde(default = "default_preview_hz")]
|
||||
pub preview_hz: u16,
|
||||
#[serde(default = "default_heartbeat_hz")]
|
||||
pub heartbeat_hz: u16,
|
||||
#[serde(default)]
|
||||
pub ddp_compatibility: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportMode {
|
||||
DistributedScene,
|
||||
FrameStreaming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SafetyProfileConfig {
|
||||
pub profile_id: String,
|
||||
#[serde(default = "default_brightness_limit")]
|
||||
pub master_brightness_limit: f32,
|
||||
#[serde(default = "default_start_brightness")]
|
||||
pub default_start_brightness: f32,
|
||||
#[serde(default)]
|
||||
pub allow_strobe: bool,
|
||||
#[serde(default)]
|
||||
pub max_strobe_hz: Option<f32>,
|
||||
#[serde(default = "default_hold_last_frame_ms")]
|
||||
pub hold_last_frame_ms: u32,
|
||||
#[serde(default)]
|
||||
pub fallback_preset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PresetConfig {
|
||||
pub preset_id: String,
|
||||
pub scene: SceneConfig,
|
||||
#[serde(default = "default_transition_ms")]
|
||||
pub transition_ms: u32,
|
||||
#[serde(default)]
|
||||
pub target_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SceneConfig {
|
||||
pub effect: String,
|
||||
pub seed: u64,
|
||||
#[serde(default)]
|
||||
pub palette: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub speed: f32,
|
||||
#[serde(default)]
|
||||
pub intensity: f32,
|
||||
#[serde(default)]
|
||||
pub blackout: bool,
|
||||
}
|
||||
660
crates/infinity_config/src/validation.rs
Normal file
660
crates/infinity_config/src/validation.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
use crate::{
|
||||
DriverKind, GroupConfig, PanelPosition, PanelRef, ProjectConfig, ValidationState,
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT, REQUIRED_NODE_COUNT, REQUIRED_OUTPUTS_PER_NODE,
|
||||
REQUIRED_TOTAL_OUTPUTS,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationMode {
|
||||
Structural,
|
||||
Activation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationSeverity {
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationIssue {
|
||||
pub severity: ValidationSeverity,
|
||||
pub code: &'static str,
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ValidationReport {
|
||||
pub issues: Vec<ValidationIssue>,
|
||||
}
|
||||
|
||||
impl ValidationReport {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.error_count() == 0
|
||||
}
|
||||
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| issue.severity == ValidationSeverity::Error)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn warning_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| issue.severity == ValidationSeverity::Warning)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
severity: ValidationSeverity,
|
||||
code: &'static str,
|
||||
path: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
self.issues.push(ValidationIssue {
|
||||
severity,
|
||||
code,
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn validate(&self, mode: ValidationMode) -> ValidationReport {
|
||||
let mut report = ValidationReport::default();
|
||||
self.validate_profiles(&mut report);
|
||||
self.validate_topology(mode, &mut report);
|
||||
self.validate_groups(&mut report);
|
||||
self.validate_safety_profiles(&mut report);
|
||||
self.validate_presets(&mut report);
|
||||
report
|
||||
}
|
||||
|
||||
fn validate_profiles(&self, report: &mut ValidationReport) {
|
||||
let transport_profiles: BTreeSet<_> = self
|
||||
.transport_profiles
|
||||
.iter()
|
||||
.map(|profile| profile.profile_id.as_str())
|
||||
.collect();
|
||||
let safety_profiles: BTreeSet<_> = self
|
||||
.safety_profiles
|
||||
.iter()
|
||||
.map(|profile| profile.profile_id.as_str())
|
||||
.collect();
|
||||
|
||||
if !transport_profiles.contains(self.metadata.default_transport_profile.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_default_transport_profile",
|
||||
"metadata.default_transport_profile",
|
||||
format!(
|
||||
"default transport profile '{}' does not exist",
|
||||
self.metadata.default_transport_profile
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !safety_profiles.contains(self.metadata.default_safety_profile.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_default_safety_profile",
|
||||
"metadata.default_safety_profile",
|
||||
format!(
|
||||
"default safety profile '{}' does not exist",
|
||||
self.metadata.default_safety_profile
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_topology(&self, mode: ValidationMode, report: &mut ValidationReport) {
|
||||
let topology = &self.topology;
|
||||
if topology.expected_node_count != REQUIRED_NODE_COUNT {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"expected_node_count_override",
|
||||
"topology.expected_node_count",
|
||||
format!(
|
||||
"expected node count is {}, project overrides to {}",
|
||||
REQUIRED_NODE_COUNT, topology.expected_node_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.outputs_per_node != REQUIRED_OUTPUTS_PER_NODE {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"outputs_per_node_override",
|
||||
"topology.outputs_per_node",
|
||||
format!(
|
||||
"expected outputs per node is {}, project overrides to {}",
|
||||
REQUIRED_OUTPUTS_PER_NODE, topology.outputs_per_node
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.leds_per_output != REQUIRED_LED_COUNT_PER_OUTPUT {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_leds_per_output",
|
||||
"topology.leds_per_output",
|
||||
format!(
|
||||
"fixed system requirement is {} LEDs per output",
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.nodes.len() != topology.expected_node_count {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unexpected_node_count",
|
||||
"topology.nodes",
|
||||
format!(
|
||||
"expected {} nodes, found {}",
|
||||
topology.expected_node_count,
|
||||
topology.nodes.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let mut node_ids = BTreeSet::new();
|
||||
let mut panel_refs = BTreeSet::new();
|
||||
let mut output_count = 0usize;
|
||||
|
||||
for (node_index, node) in topology.nodes.iter().enumerate() {
|
||||
let node_path = format!("topology.nodes[{node_index}]");
|
||||
if !node_ids.insert(node.node_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_node_id",
|
||||
format!("{node_path}.node_id"),
|
||||
format!("duplicate node id '{}'", node.node_id),
|
||||
);
|
||||
}
|
||||
|
||||
if node.outputs.len() != topology.outputs_per_node {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unexpected_output_count",
|
||||
format!("{node_path}.outputs"),
|
||||
format!(
|
||||
"expected {} outputs for node '{}', found {}",
|
||||
topology.outputs_per_node,
|
||||
node.node_id,
|
||||
node.outputs.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let mut panel_positions = BTreeSet::new();
|
||||
let mut driver_refs = BTreeSet::new();
|
||||
|
||||
for (output_index, output) in node.outputs.iter().enumerate() {
|
||||
output_count += 1;
|
||||
let output_path = format!("{node_path}.outputs[{output_index}]");
|
||||
panel_positions.insert(output.panel_position.clone());
|
||||
let panel_ref = PanelRef {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_position: output.panel_position.clone(),
|
||||
};
|
||||
if !panel_refs.insert(panel_ref) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_panel_position",
|
||||
format!("{output_path}.panel_position"),
|
||||
format!(
|
||||
"node '{}' has multiple '{}' outputs",
|
||||
node.node_id,
|
||||
display_panel_position(&output.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if output.led_count != topology.leds_per_output {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_led_count",
|
||||
format!("{output_path}.led_count"),
|
||||
format!(
|
||||
"expected {} LEDs, found {}",
|
||||
topology.leds_per_output, output.led_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let driver_key = format!(
|
||||
"{}::{}",
|
||||
output.driver_channel.kind_label(),
|
||||
output.driver_channel.reference
|
||||
);
|
||||
if !driver_refs.insert(driver_key.clone()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_driver_reference",
|
||||
format!("{output_path}.driver_channel.reference"),
|
||||
format!(
|
||||
"node '{}' reuses driver reference '{}'",
|
||||
node.node_id, driver_key
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if mode == ValidationMode::Activation && output.enabled {
|
||||
if output.driver_channel.kind == DriverKind::PendingValidation {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"pending_driver_validation",
|
||||
format!("{output_path}.driver_channel.kind"),
|
||||
format!(
|
||||
"output '{}' cannot be activated while driver kind is pending validation",
|
||||
output.physical_output_name
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if output.validation_state != ValidationState::Validated {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"pending_output_validation",
|
||||
format!("{output_path}.validation_state"),
|
||||
format!(
|
||||
"output '{}' is not hardware-validated yet",
|
||||
output.physical_output_name
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for required_position in [
|
||||
PanelPosition::Top,
|
||||
PanelPosition::Middle,
|
||||
PanelPosition::Bottom,
|
||||
] {
|
||||
if !panel_positions.contains(&required_position) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_panel_position",
|
||||
format!("{node_path}.outputs"),
|
||||
format!(
|
||||
"node '{}' is missing the '{}' output",
|
||||
node.node_id,
|
||||
display_panel_position(&required_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output_count != REQUIRED_TOTAL_OUTPUTS {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"unexpected_total_output_count",
|
||||
"topology.nodes",
|
||||
format!(
|
||||
"system target is {} total outputs, current config defines {}",
|
||||
REQUIRED_TOTAL_OUTPUTS, output_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let known_panels = panel_refs;
|
||||
let mut layout_refs = BTreeSet::new();
|
||||
for (index, panel) in topology.layout_panels.iter().enumerate() {
|
||||
let panel_ref = PanelRef {
|
||||
node_id: panel.node_id.clone(),
|
||||
panel_position: panel.panel_position.clone(),
|
||||
};
|
||||
if !known_panels.contains(&panel_ref) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_layout_panel_reference",
|
||||
format!("topology.layout_panels[{index}]"),
|
||||
format!(
|
||||
"layout references unknown panel '{}:{}'",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !layout_refs.insert((panel.node_id.as_str(), &panel.panel_position)) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_layout_panel_reference",
|
||||
format!("topology.layout_panels[{index}]"),
|
||||
format!(
|
||||
"layout contains a duplicate reference for '{}:{}'",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_groups(&self, report: &mut ValidationReport) {
|
||||
let known_panels: BTreeSet<_> = self
|
||||
.topology
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|node| {
|
||||
node.outputs.iter().map(move |output| PanelRef {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_position: output.panel_position.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut group_ids = BTreeSet::new();
|
||||
for (group_index, group) in self.topology.groups.iter().enumerate() {
|
||||
if !group_ids.insert(group.group_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_group_id",
|
||||
format!("topology.groups[{group_index}].group_id"),
|
||||
format!("duplicate group id '{}'", group.group_id),
|
||||
);
|
||||
}
|
||||
|
||||
validate_group_members(group, group_index, &known_panels, report);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
|
||||
let preset_ids: BTreeSet<_> = self.presets.iter().map(|preset| preset.preset_id.as_str()).collect();
|
||||
for (index, profile) in self.safety_profiles.iter().enumerate() {
|
||||
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_brightness_limit",
|
||||
format!("safety_profiles[{index}].master_brightness_limit"),
|
||||
"master brightness limit must be between 0.0 and 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_start_brightness",
|
||||
format!("safety_profiles[{index}].default_start_brightness"),
|
||||
"default start brightness must not exceed the master brightness limit",
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(preset_id) = &profile.fallback_preset_id {
|
||||
if !preset_ids.contains(preset_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_fallback_preset",
|
||||
format!("safety_profiles[{index}].fallback_preset_id"),
|
||||
format!("fallback preset '{}' does not exist", preset_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_presets(&self, report: &mut ValidationReport) {
|
||||
let group_ids: BTreeSet<_> = self
|
||||
.topology
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.group_id.as_str())
|
||||
.collect();
|
||||
let mut preset_ids = BTreeSet::new();
|
||||
|
||||
for (index, preset) in self.presets.iter().enumerate() {
|
||||
if !preset_ids.insert(preset.preset_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_preset_id",
|
||||
format!("presets[{index}].preset_id"),
|
||||
format!("duplicate preset id '{}'", preset.preset_id),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(group_id) = &preset.target_group {
|
||||
if !group_ids.contains(group_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_target_group",
|
||||
format!("presets[{index}].target_group"),
|
||||
format!("preset references unknown group '{}'", group_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::DriverChannelRef {
|
||||
fn kind_label(&self) -> &'static str {
|
||||
match self.kind {
|
||||
DriverKind::PendingValidation => "pending_validation",
|
||||
DriverKind::Gpio => "gpio",
|
||||
DriverKind::RmtChannel => "rmt_channel",
|
||||
DriverKind::I2sLane => "i2s_lane",
|
||||
DriverKind::UartPort => "uart_port",
|
||||
DriverKind::SpiBus => "spi_bus",
|
||||
DriverKind::ExternalDriver => "external_driver",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_group_members(
|
||||
group: &GroupConfig,
|
||||
group_index: usize,
|
||||
known_panels: &BTreeSet<PanelRef>,
|
||||
report: &mut ValidationReport,
|
||||
) {
|
||||
let mut unique_members = BTreeSet::new();
|
||||
for (member_index, member) in group.members.iter().enumerate() {
|
||||
if !known_panels.contains(member) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_group_member",
|
||||
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||
format!(
|
||||
"group '{}' references unknown panel '{}:{}'",
|
||||
group.group_id,
|
||||
member.node_id,
|
||||
display_panel_position(&member.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !unique_members.insert((member.node_id.as_str(), &member.panel_position)) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_group_member",
|
||||
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||
format!(
|
||||
"group '{}' contains '{}' more than once",
|
||||
group.group_id,
|
||||
panel_ref_label(member)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_ref_label(panel: &PanelRef) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
)
|
||||
}
|
||||
|
||||
fn display_panel_position(position: &PanelPosition) -> &'static str {
|
||||
match position {
|
||||
PanelPosition::Top => "top",
|
||||
PanelPosition::Middle => "middle",
|
||||
PanelPosition::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
ColorOrder, DriverChannelRef, LedDirection, NodeConfig, NodeNetworkConfig, PanelOutputConfig,
|
||||
PanelPosition, PresetConfig, ProjectMetadata, SceneConfig, TopologyConfig, TransportMode,
|
||||
TransportProfileConfig,
|
||||
};
|
||||
|
||||
fn build_output(position: PanelPosition, label: &str, driver_kind: DriverKind) -> PanelOutputConfig {
|
||||
PanelOutputConfig {
|
||||
panel_position: position,
|
||||
physical_output_name: label.to_string(),
|
||||
driver_channel: DriverChannelRef {
|
||||
kind: driver_kind,
|
||||
reference: label.to_string(),
|
||||
},
|
||||
led_count: 106,
|
||||
direction: LedDirection::Forward,
|
||||
color_order: ColorOrder::Grb,
|
||||
enabled: true,
|
||||
validation_state: ValidationState::Validated,
|
||||
logical_panel_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_project() -> ProjectConfig {
|
||||
let mut nodes = Vec::new();
|
||||
for index in 1..=6 {
|
||||
nodes.push(NodeConfig {
|
||||
node_id: format!("node-{index:02}"),
|
||||
display_name: format!("Node {index:02}"),
|
||||
network: NodeNetworkConfig {
|
||||
reserved_ip: Some(format!("192.168.40.1{index:02}")),
|
||||
telemetry_label: None,
|
||||
},
|
||||
outputs: vec![
|
||||
build_output(PanelPosition::Top, "GPIO_1", DriverKind::Gpio),
|
||||
build_output(PanelPosition::Middle, "GPIO_2", DriverKind::Gpio),
|
||||
build_output(PanelPosition::Bottom, "GPIO_3", DriverKind::Gpio),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
ProjectConfig {
|
||||
metadata: ProjectMetadata {
|
||||
project_name: "Test".to_string(),
|
||||
schema_version: 1,
|
||||
default_transport_profile: "default-scene".to_string(),
|
||||
default_safety_profile: "live-safe".to_string(),
|
||||
},
|
||||
topology: TopologyConfig {
|
||||
expected_node_count: 6,
|
||||
outputs_per_node: 3,
|
||||
leds_per_output: 106,
|
||||
nodes,
|
||||
layout_panels: Vec::new(),
|
||||
groups: vec![GroupConfig {
|
||||
group_id: "all".to_string(),
|
||||
tags: vec!["global".to_string()],
|
||||
members: vec![
|
||||
PanelRef {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Top,
|
||||
},
|
||||
PanelRef {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Middle,
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
transport_profiles: vec![TransportProfileConfig {
|
||||
profile_id: "default-scene".to_string(),
|
||||
mode: TransportMode::DistributedScene,
|
||||
logic_hz: 120,
|
||||
network_send_hz: 60,
|
||||
preview_hz: 15,
|
||||
heartbeat_hz: 5,
|
||||
ddp_compatibility: false,
|
||||
}],
|
||||
safety_profiles: vec![SafetyProfileConfig {
|
||||
profile_id: "live-safe".to_string(),
|
||||
master_brightness_limit: 0.3,
|
||||
default_start_brightness: 0.2,
|
||||
allow_strobe: false,
|
||||
max_strobe_hz: None,
|
||||
hold_last_frame_ms: 1200,
|
||||
fallback_preset_id: Some("safe-blue".to_string()),
|
||||
}],
|
||||
presets: vec![PresetConfig {
|
||||
preset_id: "safe-blue".to_string(),
|
||||
scene: SceneConfig {
|
||||
effect: "solid_color".to_string(),
|
||||
seed: 7,
|
||||
palette: vec!["#003bff".to_string()],
|
||||
speed: 0.0,
|
||||
intensity: 1.0,
|
||||
blackout: false,
|
||||
},
|
||||
transition_ms: 150,
|
||||
target_group: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_structurally_valid_project() {
|
||||
let project = build_project();
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(report.is_ok(), "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_led_count() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[0].led_count = 105;
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "invalid_led_count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_driver_refs() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[1].driver_channel.reference =
|
||||
project.topology.nodes[0].outputs[0].driver_channel.reference.clone();
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "duplicate_driver_reference"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_requires_hardware_validation() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[0].driver_channel.kind = DriverKind::PendingValidation;
|
||||
project.topology.nodes[0].outputs[0].validation_state =
|
||||
ValidationState::PendingHardwareValidation;
|
||||
let report = project.validate(ValidationMode::Activation);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "pending_driver_validation"));
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "pending_output_validation"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user