Initial commit

This commit is contained in:
2026-04-17 01:33:23 +02:00
commit 1f6543bd7a
28 changed files with 2556 additions and 0 deletions

View 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

View 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)
}

View 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,
}

View 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"));
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "infinity_host"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
infinity_config = { path = "../infinity_config" }
infinity_protocol = { path = "../infinity_protocol" }

View File

@@ -0,0 +1,130 @@
mod runtime;
use clap::{Parser, Subcommand, ValueEnum};
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
use runtime::RealtimeEngine;
use std::{path::PathBuf, process::ExitCode};
#[derive(Debug, Parser)]
#[command(author, version, about = "Infinity Vis host-side validation and planning CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Validate {
#[arg(long)]
config: PathBuf,
#[arg(long, value_enum, default_value_t = CliValidationMode::Structural)]
mode: CliValidationMode,
},
PlanBootScene {
#[arg(long)]
config: PathBuf,
#[arg(long)]
preset_id: String,
},
OpenValidationPoints,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliValidationMode {
Structural,
Activation,
}
impl From<CliValidationMode> for ValidationMode {
fn from(value: CliValidationMode) -> Self {
match value {
CliValidationMode::Structural => ValidationMode::Structural,
CliValidationMode::Activation => ValidationMode::Activation,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Validate { config, mode } => validate_command(config, mode),
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
Command::OpenValidationPoints => {
print_open_validation_points();
ExitCode::SUCCESS
}
}
}
fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
let project = match load_project_from_path(&config) {
Ok(project) => project,
Err(error) => {
eprintln!("Failed to load config '{}': {error}", config.display());
return ExitCode::FAILURE;
}
};
let engine = RealtimeEngine::default();
let report = engine.validate_project(&project, mode.into());
println!(
"Validation finished: {} error(s), {} warning(s)",
report.error_count(),
report.warning_count()
);
for issue in &report.issues {
let level = match issue.severity {
ValidationSeverity::Warning => "WARN",
ValidationSeverity::Error => "ERROR",
};
println!("[{level}] {} at {}: {}", issue.code, issue.path, issue.message);
}
if report.is_ok() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn plan_boot_scene_command(config: PathBuf, preset_id: &str) -> ExitCode {
let project = match load_project_from_path(&config) {
Ok(project) => project,
Err(error) => {
eprintln!("Failed to load config '{}': {error}", config.display());
return ExitCode::FAILURE;
}
};
let engine = RealtimeEngine::default();
let plan = engine.plan_boot_scene(&project, preset_id);
if plan.is_empty() {
eprintln!("Preset '{preset_id}' was not found.");
return ExitCode::FAILURE;
}
match serde_json::to_string_pretty(&plan) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Failed to serialize boot plan: {error}");
ExitCode::FAILURE
}
}
}
fn print_open_validation_points() {
for line in [
"Pending hardware validation gates:",
"1. Confirm whether 'UART 6 / 5 / 4' are GPIO labels, logical channels, or real UART peripherals.",
"2. Confirm the exact LED chipset and timing backend required per output.",
"3. Confirm color order, start pixel, and direction for all 18 outputs.",
"4. Confirm whether all outputs truly have 106 active LEDs without dummy or reserve pixels.",
"5. Confirm the final node-to-physical-panel mapping before enabling layout-specific scenes.",
] {
println!("{line}");
}
}

View File

@@ -0,0 +1,167 @@
use infinity_config::{
PanelPosition, ProjectConfig, TransportMode, ValidationIssue, ValidationMode, ValidationReport,
ValidationSeverity,
};
use infinity_protocol::{
ControlEnvelope, ControlMessage, PanelAddress, PanelAssignment, PanelSlot, RealtimeEnvelope,
RealtimeMessage, RealtimeMode, SceneParametersFrame, TransitionMode, TransitionSpec,
};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TickSchedule {
pub logic_hz: u16,
pub frame_synthesis_hz: u16,
pub network_send_hz: u16,
pub preview_hz: u16,
}
impl Default for TickSchedule {
fn default() -> Self {
Self {
logic_hz: 120,
frame_synthesis_hz: 60,
network_send_hz: 60,
preview_hz: 15,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PlannedSend {
pub node_id: String,
pub control: Vec<ControlEnvelope>,
pub realtime: Vec<RealtimeEnvelope>,
}
#[derive(Debug, Clone)]
pub struct RealtimeEngine {
pub schedule: TickSchedule,
}
impl Default for RealtimeEngine {
fn default() -> Self {
Self {
schedule: TickSchedule::default(),
}
}
}
impl RealtimeEngine {
pub fn validate_project(&self, project: &ProjectConfig, mode: ValidationMode) -> ValidationReport {
let mut report = project.validate(mode);
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
report.issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: "preview_rate_too_high",
path: "runtime.schedule.preview_hz".to_string(),
message: "preview rate should stay below frame synthesis rate".to_string(),
});
}
report
}
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
let Some(preset) = project.presets.iter().find(|preset| preset.preset_id == preset_id) else {
return Vec::new();
};
let transport = project
.transport_profiles
.iter()
.find(|profile| profile.profile_id == project.metadata.default_transport_profile);
let safety = project
.safety_profiles
.iter()
.find(|profile| profile.profile_id == project.metadata.default_safety_profile);
project
.topology
.nodes
.iter()
.enumerate()
.map(|(index, node)| {
let assignments = node
.outputs
.iter()
.map(|output| PanelAssignment {
panel: PanelAddress {
node_id: node.node_id.clone(),
panel_slot: map_panel_slot(&output.panel_position),
},
physical_output_name: output.physical_output_name.clone(),
driver_reference: output.driver_channel.reference.clone(),
})
.collect::<Vec<_>>();
let targets = assignments
.iter()
.map(|assignment| assignment.panel.clone())
.collect::<Vec<_>>();
let transition = TransitionSpec {
transition_ms: preset.transition_ms,
mode: TransitionMode::Crossfade,
};
let mode = match transport.map(|profile| &profile.mode) {
Some(TransportMode::FrameStreaming) => RealtimeMode::FrameStreaming,
_ => RealtimeMode::DistributedScene,
};
PlannedSend {
node_id: node.node_id.clone(),
control: vec![
ControlEnvelope::new(
(index as u32) * 2 + 1,
0,
ControlMessage::ConfigSync {
topology_revision: format!(
"schema-{}",
project.metadata.schema_version
),
outputs: assignments,
},
),
ControlEnvelope::new(
(index as u32) * 2 + 2,
0,
ControlMessage::PresetRecall {
preset_id: preset.preset_id.clone(),
transition,
},
),
],
realtime: vec![RealtimeEnvelope::new(
(index as u32) + 1,
0,
RealtimeMessage::SceneParameters(SceneParametersFrame {
node_id: node.node_id.clone(),
mode,
preset_id: Some(preset.preset_id.clone()),
effect: preset.scene.effect.clone(),
seed: preset.scene.seed,
palette: preset.scene.palette.clone(),
master_brightness: safety
.map(|profile| profile.default_start_brightness)
.unwrap_or(0.2),
speed: preset.scene.speed,
intensity: preset.scene.intensity,
target_group: preset.target_group.clone(),
target_outputs: targets,
}),
)],
}
})
.collect()
}
}
fn map_panel_slot(position: &PanelPosition) -> PanelSlot {
match position {
PanelPosition::Top => PanelSlot::Top,
PanelPosition::Middle => PanelSlot::Middle,
PanelPosition::Bottom => PanelSlot::Bottom,
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "infinity_protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde.workspace = true

View File

@@ -0,0 +1,206 @@
use serde::{Deserialize, Serialize};
pub const CONTROL_PROTOCOL_VERSION: u16 = 1;
pub const REALTIME_PROTOCOL_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ControlEnvelope {
pub protocol_version: u16,
pub sequence: u32,
pub sent_at_millis: u64,
pub message: ControlMessage,
}
impl ControlEnvelope {
pub fn new(sequence: u32, sent_at_millis: u64, message: ControlMessage) -> Self {
Self {
protocol_version: CONTROL_PROTOCOL_VERSION,
sequence,
sent_at_millis,
message,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RealtimeEnvelope {
pub protocol_version: u16,
pub sequence: u32,
pub sent_at_millis: u64,
pub message: RealtimeMessage,
}
impl RealtimeEnvelope {
pub fn new(sequence: u32, sent_at_millis: u64, message: RealtimeMessage) -> Self {
Self {
protocol_version: REALTIME_PROTOCOL_VERSION,
sequence,
sent_at_millis,
message,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ControlMessage {
DiscoveryHello {
host_id: String,
},
DiscoveryAck {
node_id: String,
firmware_version: String,
capabilities: NodeCapabilities,
},
ConfigSync {
topology_revision: String,
outputs: Vec<PanelAssignment>,
},
HeartbeatRequest {
node_id: String,
},
Heartbeat(NodeHeartbeat),
PresetRecall {
preset_id: String,
transition: TransitionSpec,
},
Blackout {
enabled: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeMessage {
SceneParameters(SceneParametersFrame),
PixelFrame(PixelFrame),
ResyncRequest {
node_id: String,
last_sequence_seen: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeCapabilities {
pub outputs_per_node: u8,
pub leds_per_output: u16,
pub supported_driver_backends: Vec<String>,
pub supports_scene_mode: bool,
pub supports_frame_streaming: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NodeHeartbeat {
pub node_id: String,
pub fps: f32,
pub heartbeat_age_ms: u32,
pub dropped_frames: u32,
pub reconnect_count: u32,
pub free_heap_bytes: Option<u32>,
pub temperature_celsius: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelAssignment {
pub panel: PanelAddress,
pub physical_output_name: String,
pub driver_reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransitionSpec {
pub transition_ms: u32,
pub mode: TransitionMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TransitionMode {
Snap,
Crossfade,
Chase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneParametersFrame {
pub node_id: String,
pub mode: RealtimeMode,
pub preset_id: Option<String>,
pub effect: String,
pub seed: u64,
pub palette: Vec<String>,
pub master_brightness: f32,
pub speed: f32,
pub intensity: f32,
pub target_group: Option<String>,
pub target_outputs: Vec<PanelAddress>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PixelFrame {
pub node_id: String,
pub frame_index: u64,
pub target_outputs: Vec<OutputFrame>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutputFrame {
pub panel: PanelAddress,
pub pixels: Vec<Rgb8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Rgb8 {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelAddress {
pub node_id: String,
pub panel_slot: PanelSlot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum PanelSlot {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeMode {
DistributedScene,
FrameStreaming,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_stamp_current_protocol_versions() {
let control = ControlEnvelope::new(
4,
123,
ControlMessage::HeartbeatRequest {
node_id: "node-01".to_string(),
},
);
let realtime = RealtimeEnvelope::new(
5,
456,
RealtimeMessage::ResyncRequest {
node_id: "node-01".to_string(),
last_sequence_seen: 4,
},
);
assert_eq!(control.protocol_version, CONTROL_PROTOCOL_VERSION);
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
}
}