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