commit 1f6543bd7ad57cd603956c834a4b50e8ac4f57b4 Author: JFly02 Date: Fri Apr 17 01:33:23 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bef0923 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target/ +/build/ +/.idea/ +/.vscode/ +*.swp +*.tmp +*.log +sdkconfig +sdkconfig.old +firmware/esp32_node/build/ + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e13231a --- /dev/null +++ b/Cargo.toml @@ -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" + diff --git a/README.md b/README.md new file mode 100644 index 0000000..51c8ae4 --- /dev/null +++ b/README.md @@ -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) + diff --git a/config/project.example.toml b/config/project.example.toml new file mode 100644 index 0000000..c81f034 --- /dev/null +++ b/config/project.example.toml @@ -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 + diff --git a/crates/infinity_config/Cargo.toml b/crates/infinity_config/Cargo.toml new file mode 100644 index 0000000..0961d79 --- /dev/null +++ b/crates/infinity_config/Cargo.toml @@ -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 + diff --git a/crates/infinity_config/src/lib.rs b/crates/infinity_config/src/lib.rs new file mode 100644 index 0000000..8000de0 --- /dev/null +++ b/crates/infinity_config/src/lib.rs @@ -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 { + toml::from_str(raw) + } +} + +pub fn load_project_from_path(path: impl AsRef) -> Result { + let raw = fs::read_to_string(path)?; + ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from) +} + diff --git a/crates/infinity_config/src/model.rs b/crates/infinity_config/src/model.rs new file mode 100644 index 0000000..48e49b8 --- /dev/null +++ b/crates/infinity_config/src/model.rs @@ -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, + #[serde(default)] + pub safety_profiles: Vec, + #[serde(default)] + pub presets: Vec, +} + +#[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, + #[serde(default)] + pub layout_panels: Vec, + #[serde(default)] + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeConfig { + pub node_id: String, + pub display_name: String, + pub network: NodeNetworkConfig, + pub outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeNetworkConfig { + pub reserved_ip: Option, + pub telemetry_label: Option, +} + +#[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, +} + +#[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, + pub members: Vec, +} + +#[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, + #[serde(default = "default_hold_last_frame_ms")] + pub hold_last_frame_ms: u32, + #[serde(default)] + pub fallback_preset_id: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SceneConfig { + pub effect: String, + pub seed: u64, + #[serde(default)] + pub palette: Vec, + #[serde(default)] + pub speed: f32, + #[serde(default)] + pub intensity: f32, + #[serde(default)] + pub blackout: bool, +} diff --git a/crates/infinity_config/src/validation.rs b/crates/infinity_config/src/validation.rs new file mode 100644 index 0000000..5bc4349 --- /dev/null +++ b/crates/infinity_config/src/validation.rs @@ -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, +} + +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, + message: impl Into, + ) { + 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, + 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")); + } +} diff --git a/crates/infinity_host/Cargo.toml b/crates/infinity_host/Cargo.toml new file mode 100644 index 0000000..39f614e --- /dev/null +++ b/crates/infinity_host/Cargo.toml @@ -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" } diff --git a/crates/infinity_host/src/main.rs b/crates/infinity_host/src/main.rs new file mode 100644 index 0000000..8255e42 --- /dev/null +++ b/crates/infinity_host/src/main.rs @@ -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 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}"); + } +} + diff --git a/crates/infinity_host/src/runtime.rs b/crates/infinity_host/src/runtime.rs new file mode 100644 index 0000000..8351cd1 --- /dev/null +++ b/crates/infinity_host/src/runtime.rs @@ -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, + pub realtime: Vec, +} + +#[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 { + 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::>(); + + let targets = assignments + .iter() + .map(|assignment| assignment.panel.clone()) + .collect::>(); + + 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, + } +} diff --git a/crates/infinity_protocol/Cargo.toml b/crates/infinity_protocol/Cargo.toml new file mode 100644 index 0000000..937a3ea --- /dev/null +++ b/crates/infinity_protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "infinity_protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true + diff --git a/crates/infinity_protocol/src/lib.rs b/crates/infinity_protocol/src/lib.rs new file mode 100644 index 0000000..5f0a659 --- /dev/null +++ b/crates/infinity_protocol/src/lib.rs @@ -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, + }, + 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, + 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, + pub temperature_celsius: Option, +} + +#[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, + pub effect: String, + pub seed: u64, + pub palette: Vec, + pub master_brightness: f32, + pub speed: f32, + pub intensity: f32, + pub target_group: Option, + pub target_outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PixelFrame { + pub node_id: String, + pub frame_index: u64, + pub target_outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OutputFrame { + pub panel: PanelAddress, + pub pixels: Vec, +} + +#[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); + } +} + diff --git a/docs/acceptance_template.md b/docs/acceptance_template.md new file mode 100644 index 0000000..24df88f --- /dev/null +++ b/docs/acceptance_template.md @@ -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: + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..744ff97 --- /dev/null +++ b/docs/architecture.md @@ -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 + diff --git a/docs/build_and_deploy.md b/docs/build_and_deploy.md new file mode 100644 index 0000000..c74f1d9 --- /dev/null +++ b/docs/build_and_deploy.md @@ -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 flash monitor` + +The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`. + diff --git a/docs/config_schema.md b/docs/config_schema.md new file mode 100644 index 0000000..4889f77 --- /dev/null +++ b/docs/config_schema.md @@ -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` + diff --git a/docs/legacy_xml_reference.md b/docs/legacy_xml_reference.md new file mode 100644 index 0000000..30a5c17 --- /dev/null +++ b/docs/legacy_xml_reference.md @@ -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 + diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..f115b21 --- /dev/null +++ b/docs/protocol.md @@ -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. + diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a280f75 --- /dev/null +++ b/docs/testing.md @@ -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 + diff --git a/docs/validation_open_points.md b/docs/validation_open_points.md new file mode 100644 index 0000000..16bafa7 --- /dev/null +++ b/docs/validation_open_points.md @@ -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. + diff --git a/firmware/esp32_node/CMakeLists.txt b/firmware/esp32_node/CMakeLists.txt new file mode 100644 index 0000000..10c2c38 --- /dev/null +++ b/firmware/esp32_node/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(infinity_vis_esp32_node) + diff --git a/firmware/esp32_node/README.md b/firmware/esp32_node/README.md new file mode 100644 index 0000000..7728bdc --- /dev/null +++ b/firmware/esp32_node/README.md @@ -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. + diff --git a/firmware/esp32_node/main/CMakeLists.txt b/firmware/esp32_node/main/CMakeLists.txt new file mode 100644 index 0000000..526b6e7 --- /dev/null +++ b/firmware/esp32_node/main/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "app_main.c" + "panel_driver.c" + INCLUDE_DIRS + "." +) + diff --git a/firmware/esp32_node/main/app_main.c b/firmware/esp32_node/main/app_main.c new file mode 100644 index 0000000..c3af6ea --- /dev/null +++ b/firmware/esp32_node/main/app_main.c @@ -0,0 +1,141 @@ +#include +#include +#include + +#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)); + } +} diff --git a/firmware/esp32_node/main/panel_driver.c b/firmware/esp32_node/main/panel_driver.c new file mode 100644 index 0000000..54142c3 --- /dev/null +++ b/firmware/esp32_node/main/panel_driver.c @@ -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); +} + diff --git a/firmware/esp32_node/main/panel_driver.h b/firmware/esp32_node/main/panel_driver.h new file mode 100644 index 0000000..d50cdc8 --- /dev/null +++ b/firmware/esp32_node/main/panel_driver.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +#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); + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8546a37 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +