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

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
/target/
/build/
/.idea/
/.vscode/
*.swp
*.tmp
*.log
sdkconfig
sdkconfig.old
firmware/esp32_node/build/

21
Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[workspace]
members = [
"crates/infinity_config",
"crates/infinity_protocol",
"crates/infinity_host",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Proprietary"
authors = ["Jan", "OpenAI Codex"]
[workspace.dependencies]
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
toml = "0.8"

37
README.md Normal file
View File

@@ -0,0 +1,37 @@
# Infinity Vis Rust
Production-oriented greenfield architecture for a low-latency LED control system that targets:
- 6 ESP32-N16R8 nodes
- 3 physical LED outputs per node
- 106 LEDs per output
- 18 logical panels and 1908 LEDs total
The repository is intentionally structured around hard separation of concerns:
- `crates/infinity_config`: versioned project configuration and validation
- `crates/infinity_protocol`: shared control and realtime protocol model
- `crates/infinity_host`: host-side CLI and runtime skeleton
- `firmware/esp32_node`: ESP-IDF firmware skeleton with explicit driver abstraction
- `docs/`: architecture, protocol, validation, deployment, testing, and acceptance artifacts
- `config/`: example configuration files
The current baseline is intentionally strict about unresolved hardware facts. `UART 6`, `UART 5`, and `UART 4` are treated as unvalidated labels until the real electrical meaning is confirmed.
## Quick Start
1. Install a current Rust toolchain.
2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md).
3. Start from [config/project.example.toml](config/project.example.toml).
4. Use the host CLI to validate the project config before attempting activation.
## Docs
- [Architecture](docs/architecture.md)
- [Protocol](docs/protocol.md)
- [Config Schema](docs/config_schema.md)
- [Build and Deploy](docs/build_and_deploy.md)
- [Testing](docs/testing.md)
- [Acceptance Template](docs/acceptance_template.md)
- [Legacy XML Reference](docs/legacy_xml_reference.md)

337
config/project.example.toml Normal file
View File

@@ -0,0 +1,337 @@
[metadata]
project_name = "Infinity Vis"
schema_version = 1
default_transport_profile = "scene_default"
default_safety_profile = "live_safe"
[topology]
expected_node_count = 6
outputs_per_node = 3
leds_per_output = 106
[[topology.nodes]]
node_id = "node-01"
display_name = "ESP32 Node 01"
[topology.nodes.network]
reserved_ip = "192.168.40.101"
telemetry_label = "rig-01"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-01-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-01-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-01-bottom"
[[topology.nodes]]
node_id = "node-02"
display_name = "ESP32 Node 02"
[topology.nodes.network]
reserved_ip = "192.168.40.102"
telemetry_label = "rig-02"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-02-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-02-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-02-bottom"
[[topology.nodes]]
node_id = "node-03"
display_name = "ESP32 Node 03"
[topology.nodes.network]
reserved_ip = "192.168.40.103"
telemetry_label = "rig-03"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-03-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-03-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-03-bottom"
[[topology.nodes]]
node_id = "node-04"
display_name = "ESP32 Node 04"
[topology.nodes.network]
reserved_ip = "192.168.40.104"
telemetry_label = "rig-04"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-04-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-04-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-04-bottom"
[[topology.nodes]]
node_id = "node-05"
display_name = "ESP32 Node 05"
[topology.nodes.network]
reserved_ip = "192.168.40.105"
telemetry_label = "rig-05"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-05-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-05-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-05-bottom"
[[topology.nodes]]
node_id = "node-06"
display_name = "ESP32 Node 06"
[topology.nodes.network]
reserved_ip = "192.168.40.106"
telemetry_label = "rig-06"
[[topology.nodes.outputs]]
panel_position = "top"
physical_output_name = "UART 6"
driver_channel = { kind = "pending_validation", reference = "UART 6" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-06-top"
[[topology.nodes.outputs]]
panel_position = "middle"
physical_output_name = "UART 5"
driver_channel = { kind = "pending_validation", reference = "UART 5" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-06-middle"
[[topology.nodes.outputs]]
panel_position = "bottom"
physical_output_name = "UART 4"
driver_channel = { kind = "pending_validation", reference = "UART 4" }
led_count = 106
direction = "forward"
color_order = "grb"
enabled = true
validation_state = "pending_hardware_validation"
logical_panel_name = "node-06-bottom"
[[topology.groups]]
group_id = "all_panels"
tags = ["global", "default"]
members = [
{ node_id = "node-01", panel_position = "top" },
{ node_id = "node-01", panel_position = "middle" },
{ node_id = "node-01", panel_position = "bottom" },
{ node_id = "node-02", panel_position = "top" },
{ node_id = "node-02", panel_position = "middle" },
{ node_id = "node-02", panel_position = "bottom" },
{ node_id = "node-03", panel_position = "top" },
{ node_id = "node-03", panel_position = "middle" },
{ node_id = "node-03", panel_position = "bottom" },
{ node_id = "node-04", panel_position = "top" },
{ node_id = "node-04", panel_position = "middle" },
{ node_id = "node-04", panel_position = "bottom" },
{ node_id = "node-05", panel_position = "top" },
{ node_id = "node-05", panel_position = "middle" },
{ node_id = "node-05", panel_position = "bottom" },
{ node_id = "node-06", panel_position = "top" },
{ node_id = "node-06", panel_position = "middle" },
{ node_id = "node-06", panel_position = "bottom" },
]
[[topology.groups]]
group_id = "top_panels"
tags = ["row_like", "hardware_safe"]
members = [
{ node_id = "node-01", panel_position = "top" },
{ node_id = "node-02", panel_position = "top" },
{ node_id = "node-03", panel_position = "top" },
{ node_id = "node-04", panel_position = "top" },
{ node_id = "node-05", panel_position = "top" },
{ node_id = "node-06", panel_position = "top" },
]
[[transport_profiles]]
profile_id = "scene_default"
mode = "distributed_scene"
logic_hz = 120
network_send_hz = 60
preview_hz = 15
heartbeat_hz = 5
ddp_compatibility = false
[[transport_profiles]]
profile_id = "frame_debug"
mode = "frame_streaming"
logic_hz = 120
network_send_hz = 40
preview_hz = 10
heartbeat_hz = 5
ddp_compatibility = true
[[safety_profiles]]
profile_id = "live_safe"
master_brightness_limit = 0.35
default_start_brightness = 0.20
allow_strobe = false
hold_last_frame_ms = 1500
fallback_preset_id = "safe_static_blue"
[[presets]]
preset_id = "safe_static_blue"
target_group = "all_panels"
transition_ms = 150
[presets.scene]
effect = "solid_color"
seed = 7
palette = ["#003bff"]
speed = 0.0
intensity = 1.0
blackout = false
[[presets]]
preset_id = "mapping_walk_test"
target_group = "all_panels"
transition_ms = 50
[presets.scene]
effect = "walking_pixel"
seed = 42
palette = ["#ffffff", "#000000"]
speed = 1.0
intensity = 1.0
blackout = false

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

View File

@@ -0,0 +1,37 @@
# Acceptance Template
Date:
Technician:
Firmware build:
Host build:
## Core Checks
- [ ] System starts from cold boot
- [ ] All 6 nodes discovered
- [ ] All 18 outputs configured
- [ ] All outputs confirm 106 LEDs
- [ ] Global brightness change is visible immediately
- [ ] Blackout works and recovers cleanly
- [ ] Preset recall works under load
- [ ] Node reconnect works during active show
- [ ] 8-hour soak run completed without critical fault
## Mapping Checks
- [ ] Walking pixel test completed on every output
- [ ] Start pixel confirmed for every output
- [ ] Direction confirmed for every output
- [ ] Color order confirmed for every output
- [ ] Top, middle, bottom assignment confirmed on every node
- [ ] No mismatch between configured mapping and observed physical behavior
## Observations
- Notes:
- Exceptions:
- Follow-up actions:

85
docs/architecture.md Normal file
View File

@@ -0,0 +1,85 @@
# Architecture
## Goal
Build a live-capable LED control platform that keeps realtime output deterministic while letting operators change scenes, brightness, tests, and presets without UI jitter leaking into the hot path.
## Layer Split
1. Control layer
- Operator workflow
- Presets and topology editing
- Monitoring and diagnostics
- Never the timing master for LED output
2. Realtime engine
- Owns the monotonic clock
- Computes scene state, transitions, and dirty regions
- Produces transport-ready commands or pixel frames
3. Transport and node layer
- Discovery, heartbeat, config sync, sequencing, and recovery
- Control protocol and realtime protocol stay separate
- Latest realtime state wins, stale frames may be dropped
4. ESP32 firmware
- Receives commands
- Maintains local buffers
- Drives three independent outputs per node
- Handles watchdog and reconnect logic locally
## Runtime Model
- Logic tick target: 120 Hz
- Frame synthesis target: 60 Hz
- Network send target: 40-60 Hz, profile dependent
- Preview target: 10-15 Hz
Preview and telemetry are explicitly degradable. Realtime output is not.
## Modes
### Distributed Scene Mode
- Default operating mode
- Host sends scene parameters, time basis, seed, palette, and transitions
- Nodes render locally for low bandwidth and better resilience
### Frame Streaming Mode
- Used for mapping tests, debugging, and effects that cannot run node-local
- Host sends explicit output frames
- Kept logically separate so it does not contaminate the primary scene pipeline
## Mapping Model
The project configuration separates mapping into three layers:
1. Hardware mapping
- Node ID
- Top, middle, bottom output
- Physical output label
- Driver channel reference
- LED count, direction, color order, enable flag
2. Layout mapping
- Optional row and column placement
- Optional preview transforms only
3. Group mapping
- Explicit groups for artistic control and fast operator access
The current example config intentionally keeps layout mapping empty because the old XML is only a spatial reference and the final node-to-room placement must still be confirmed on real hardware.
## Validation Gates
The codebase deliberately blocks activation when these remain unresolved:
- `UART 6`, `UART 5`, `UART 4` still marked as `pending_validation`
- output validation state is not `validated`
- LED count deviates from 106
- node outputs are missing top, middle, or bottom
- driver references are ambiguous or duplicated per node
## Planned Next Steps
1. Add the actual UI adapter on top of `infinity_host`
2. Implement UDP transport with separate control and realtime sockets
3. Connect firmware driver backends after hardware validation
4. Add deterministic effect registry shared between host planning and firmware capability negotiation

40
docs/build_and_deploy.md Normal file
View File

@@ -0,0 +1,40 @@
# Build and Deploy
## Host Side
Required tools:
- Rust stable toolchain
- `cargo`
Suggested commands:
```powershell
cargo test
cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural
cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue
```
Before any live activation, run:
```powershell
cargo run -p infinity_host -- validate --config config/project.example.toml --mode activation
```
Activation mode is expected to fail until the hardware mapping has been confirmed and the config is updated from `pending_validation` to concrete driver references.
## Firmware Side
Required tools:
- ESP-IDF
- Xtensa or RISC-V toolchain matching the actual ESP32 variant
Suggested layout:
- `firmware/esp32_node/`
- build with `idf.py build`
- flash with `idf.py -p <serial-port> flash monitor`
The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`.

87
docs/config_schema.md Normal file
View File

@@ -0,0 +1,87 @@
# Config Schema
## Primary File
The example project file is [config/project.example.toml](../config/project.example.toml).
## Root Objects
- `metadata`
- `topology`
- `transport_profiles`
- `safety_profiles`
- `presets`
## `metadata`
- `project_name`
- `schema_version`
- `default_transport_profile`
- `default_safety_profile`
## `topology`
- `expected_node_count`
- `outputs_per_node`
- `leds_per_output`
- `nodes`
- `layout_panels`
- `groups`
## `topology.nodes[]`
- `node_id`
- `display_name`
- `network.reserved_ip`
- `network.telemetry_label`
- `outputs`
## `topology.nodes[].outputs[]`
Required:
- `panel_position`
- `physical_output_name`
- `driver_channel.kind`
- `driver_channel.reference`
- `led_count`
- `direction`
- `color_order`
- `enabled`
- `validation_state`
Optional:
- `logical_panel_name`
## Activation Rules
Structural validation accepts `pending_validation` so the system can model unresolved wiring.
Activation validation rejects any output that is still:
- `driver_channel.kind = "pending_validation"`
- `validation_state != "validated"`
This is intentional and prevents accidental deployment against guessed hardware assumptions.
## Groups
`topology.groups[]` keeps grouping explicit and simple:
- `group_id`
- `tags`
- `members[] = { node_id, panel_position }`
## Layout
`topology.layout_panels[]` is optional and only needed for preview or spatial effects:
- `node_id`
- `panel_position`
- `row`
- `column`
- `rotation_degrees`
- `mirror_x`
- `mirror_y`

View File

@@ -0,0 +1,26 @@
# Legacy XML Reference
Source reviewed:
- `c:\Users\janni\Nextcloud\Documents\Infinity Vis\sample_data\infinity_mirror_mapping_clean.xml`
Useful facts extracted from the legacy file:
- 3 rows by 6 columns logical preview layout
- 18 total tiles
- 106 LEDs per tile
- legacy transport metadata mentions WLED, Art-Net, and per-tile IP addresses
- each tile is described as four perimeter segments summing to 106 LEDs
What this file is **not** used for:
- it is not the new hardware-mapping schema
- it is not proof of the final ESP32 node-to-panel assignment
- it does not validate `UART 6`, `UART 5`, or `UART 4`
- it should not be imported as a generic slice engine
Recommended use:
- use it as a preview and spatial-reference aid only
- manually transfer final room layout after the new hardware mapping is physically validated

67
docs/protocol.md Normal file
View File

@@ -0,0 +1,67 @@
# Protocol
## Design Rules
- Separate control traffic from realtime traffic
- Version every envelope
- Keep realtime messages disposable
- Prefer idempotent control commands
- Let nodes recover independently after packet loss or reconnect
## Control Protocol
Purpose:
- Discovery
- Heartbeat
- Config sync
- Preset recall
- Panic and blackout
- Telemetry
Current envelope model:
- `protocol_version`
- `sequence`
- `sent_at_millis`
- `message`
Current control messages:
- `discovery_hello`
- `discovery_ack`
- `config_sync`
- `heartbeat_request`
- `heartbeat`
- `preset_recall`
- `blackout`
`config_sync` carries the authoritative per-node hardware assignment so the node can reject invalid activation before the first frame.
## Realtime Protocol
Purpose:
- Scene parameters for distributed rendering
- Explicit pixel frames for frame streaming mode
- Resync requests
Current realtime messages:
- `scene_parameters`
- `pixel_frame`
- `resync_request`
## Delivery Semantics
- Control messages must be small and replay-safe where possible
- Realtime messages use latest-state-wins semantics
- Nodes may drop stale realtime frames rather than replay them
- A reconnecting node must request or receive a clean config sync before resuming output
## DDP Compatibility
DDP is treated as an optional compatibility shell for frame streaming mode only.
The internal model is intentionally broader than DDP because the preferred operating path is distributed scene mode with time-based parameters and node-local rendering.

37
docs/testing.md Normal file
View File

@@ -0,0 +1,37 @@
# Testing
## Unit Tests
Host-side unit tests currently cover:
- fixed LED count validation
- duplicate driver reference rejection
- activation guard against unresolved hardware mapping
- protocol envelope version stamping
## Planned Integration Tests
1. Host to node config sync
2. Host to node preset recall during load
3. Reconnect and resync after heartbeat timeout
4. Frame streaming fallback without scene-mode support
## Soak Tests
Target procedure:
1. Run a continuous scene loop for 8 hours
2. Rotate presets on a schedule
3. Simulate packet loss and node reconnects
4. Log dropped frames, reconnect count, and jitter
## Hardware Validation Tests
Required before live deployment:
1. Walking pixel test across all 106 LEDs on each of the 18 outputs
2. Start pixel verification per output
3. Direction verification per output
4. Color order verification per output
5. Final confirmation of which physical channel maps to top, middle, and bottom on every node

View File

@@ -0,0 +1,14 @@
# Validation Open Points
These items must be resolved before the system can move from structural validation to activation validation:
1. Confirm the real meaning of `UART 6`, `UART 5`, and `UART 4`.
2. Confirm the exact LED chipset and required output timing backend.
3. Confirm whether every output truly has exactly 106 active LEDs, with no dummy or reserve pixels.
4. Confirm color order for each output.
5. Confirm direction and start pixel for each output.
6. Confirm which physical node controls which installed top, middle, and bottom panel.
7. Confirm whether transport runs over dedicated Wi-Fi or wired Ethernet.
8. Confirm whether DDP compatibility is optional or mandatory in the first deployment.
9. Confirm whether the first production UI is Tauri, egui, or another adapter on top of the host core.

View File

@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(infinity_vis_esp32_node)

View File

@@ -0,0 +1,25 @@
# ESP32 Node Firmware
This directory contains the ESP-IDF firmware skeleton for one ESP32 node that drives exactly three outputs:
- top
- middle
- bottom
The firmware is intentionally built around a driver abstraction. It does not assume that `UART 6`, `UART 5`, or `UART 4` are real UART peripherals.
## Planned Modules
- Network RX task
- Command decode task
- Render and apply task
- Output task
- Telemetry task
- Watchdog and recovery path
## Current Safety Posture
The skeleton blocks activation while output channels remain marked as `PANEL_DRIVER_KIND_UNVALIDATED`.
That is expected and desirable until the physical backend is confirmed.

View File

@@ -0,0 +1,8 @@
idf_component_register(
SRCS
"app_main.c"
"panel_driver.c"
INCLUDE_DIRS
"."
)

View File

@@ -0,0 +1,141 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_system.h"
#include "panel_driver.h"
static const char *TAG = "infinity_node";
typedef struct {
const char *node_id;
panel_output_config_t outputs[INFINITY_NODE_OUTPUT_COUNT];
} node_runtime_config_t;
typedef struct {
uint32_t heartbeat_count;
uint32_t reconnect_count;
} node_metrics_t;
static node_runtime_config_t make_default_runtime_config(void);
static esp_err_t validate_runtime_config(const node_runtime_config_t *config);
static void network_rx_task(void *arg);
static void render_task(void *arg);
static void output_task(void *arg);
static void telemetry_task(void *arg);
void app_main(void) {
static node_metrics_t metrics = {0};
node_runtime_config_t runtime_config = make_default_runtime_config();
esp_err_t validation = validate_runtime_config(&runtime_config);
if (validation != ESP_OK) {
ESP_LOGE(TAG, "startup halted until hardware mapping is validated: %s", esp_err_to_name(validation));
return;
}
xTaskCreate(network_rx_task, "network_rx", 4096, &metrics, 8, NULL);
xTaskCreate(render_task, "render", 4096, &metrics, 7, NULL);
xTaskCreate(output_task, "output", 4096, &metrics, 9, NULL);
xTaskCreate(telemetry_task, "telemetry", 4096, &metrics, 5, NULL);
}
static node_runtime_config_t make_default_runtime_config(void) {
node_runtime_config_t config = {
.node_id = "unassigned-node",
.outputs =
{
{
.panel_slot = PANEL_SLOT_TOP,
.physical_output_name = "UART 6",
.driver_reference = "UART 6",
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
.led_count = INFINITY_LEDS_PER_OUTPUT,
.reverse = false,
.enabled = true,
},
{
.panel_slot = PANEL_SLOT_MIDDLE,
.physical_output_name = "UART 5",
.driver_reference = "UART 5",
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
.led_count = INFINITY_LEDS_PER_OUTPUT,
.reverse = false,
.enabled = true,
},
{
.panel_slot = PANEL_SLOT_BOTTOM,
.physical_output_name = "UART 4",
.driver_reference = "UART 4",
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
.led_count = INFINITY_LEDS_PER_OUTPUT,
.reverse = false,
.enabled = true,
},
},
};
return config;
}
static esp_err_t validate_runtime_config(const node_runtime_config_t *config) {
if (config == NULL) {
return ESP_ERR_INVALID_ARG;
}
for (size_t index = 0; index < INFINITY_NODE_OUTPUT_COUNT; ++index) {
esp_err_t status = panel_driver_validate_output(&config->outputs[index]);
if (status != ESP_OK) {
ESP_LOGE(
TAG,
"output %u (%s) failed validation",
(unsigned int)index,
config->outputs[index].physical_output_name
);
return status;
}
}
return ESP_OK;
}
static void network_rx_task(void *arg) {
node_metrics_t *metrics = (node_metrics_t *)arg;
for (;;) {
metrics->heartbeat_count++;
vTaskDelay(pdMS_TO_TICKS(50));
}
}
static void render_task(void *arg) {
(void)arg;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(16));
}
}
static void output_task(void *arg) {
(void)arg;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(16));
}
}
static void telemetry_task(void *arg) {
node_metrics_t *metrics = (node_metrics_t *)arg;
for (;;) {
ESP_LOGI(
TAG,
"telemetry heartbeat_count=%u reconnect_count=%u free_heap=%u",
metrics->heartbeat_count,
metrics->reconnect_count,
(unsigned int)esp_get_free_heap_size()
);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

View File

@@ -0,0 +1,28 @@
#include "panel_driver.h"
#include "esp_check.h"
#include "esp_log.h"
static const char *TAG = "panel_driver";
esp_err_t panel_driver_validate_output(const panel_output_config_t *config) {
ESP_RETURN_ON_FALSE(config != NULL, ESP_ERR_INVALID_ARG, TAG, "config must not be null");
ESP_RETURN_ON_FALSE(config->led_count == INFINITY_LEDS_PER_OUTPUT, ESP_ERR_INVALID_ARG, TAG, "output must be configured for exactly 106 LEDs");
ESP_RETURN_ON_FALSE(config->enabled, ESP_ERR_INVALID_STATE, TAG, "disabled output cannot enter the active driver set");
ESP_RETURN_ON_FALSE(config->driver_kind != PANEL_DRIVER_KIND_UNVALIDATED, ESP_ERR_INVALID_STATE, TAG, "driver backend is still unvalidated");
ESP_RETURN_ON_FALSE(config->driver_reference != NULL, ESP_ERR_INVALID_ARG, TAG, "driver reference must not be null");
return ESP_OK;
}
esp_err_t panel_driver_self_test_all(panel_driver_t *driver, const panel_output_config_t *outputs, size_t output_count) {
ESP_RETURN_ON_FALSE(driver != NULL, ESP_ERR_INVALID_ARG, TAG, "driver must not be null");
ESP_RETURN_ON_FALSE(driver->vtable != NULL, ESP_ERR_INVALID_STATE, TAG, "driver vtable missing");
ESP_RETURN_ON_FALSE(driver->vtable->self_test != NULL, ESP_ERR_INVALID_STATE, TAG, "driver self test callback missing");
for (size_t index = 0; index < output_count; ++index) {
ESP_RETURN_ON_ERROR(panel_driver_validate_output(&outputs[index]), TAG, "output validation failed before self test");
}
return driver->vtable->self_test(driver);
}

View File

@@ -0,0 +1,61 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#define INFINITY_NODE_OUTPUT_COUNT 3
#define INFINITY_LEDS_PER_OUTPUT 106
typedef enum {
PANEL_SLOT_TOP = 0,
PANEL_SLOT_MIDDLE = 1,
PANEL_SLOT_BOTTOM = 2,
} panel_slot_t;
typedef enum {
PANEL_DRIVER_KIND_UNVALIDATED = 0,
PANEL_DRIVER_KIND_GPIO,
PANEL_DRIVER_KIND_RMT,
PANEL_DRIVER_KIND_I2S,
PANEL_DRIVER_KIND_UART,
PANEL_DRIVER_KIND_SPI,
PANEL_DRIVER_KIND_EXTERNAL,
} panel_driver_kind_t;
typedef struct {
uint8_t r;
uint8_t g;
uint8_t b;
} rgb8_t;
typedef struct {
panel_slot_t panel_slot;
const char *physical_output_name;
const char *driver_reference;
panel_driver_kind_t driver_kind;
uint16_t led_count;
bool reverse;
bool enabled;
} panel_output_config_t;
typedef struct panel_driver panel_driver_t;
typedef struct {
esp_err_t (*init)(panel_driver_t *driver);
esp_err_t (*configure)(panel_driver_t *driver, const panel_output_config_t *config);
esp_err_t (*submit_frame)(panel_driver_t *driver, panel_slot_t panel_slot, const rgb8_t *pixels, size_t pixel_count);
esp_err_t (*apply)(panel_driver_t *driver);
esp_err_t (*self_test)(panel_driver_t *driver);
} panel_driver_vtable_t;
struct panel_driver {
const panel_driver_vtable_t *vtable;
void *context;
};
esp_err_t panel_driver_validate_output(const panel_output_config_t *config);
esp_err_t panel_driver_self_test_all(panel_driver_t *driver, const panel_output_config_t *outputs, size_t output_count);

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]