Software-only show-control readiness baseline

This commit is contained in:
jan
2026-04-17 21:17:23 +02:00
commit a56cecb23d
51 changed files with 16340 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target/

4521
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

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

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# 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 core library, simulation engine, scene/preset API, and CLI
- `crates/infinity_host_ui`: native Rust desktop engineering GUI for mapping, diagnostics, and admin
- `firmware/esp32_node`: ESP-IDF firmware skeleton with explicit driver abstraction
- `docs/`: architecture, protocol, validation, deployment, testing, and acceptance artifacts
- `config/`: example configuration files
Current software priority:
- stable host-core first
- shared host API for every surface
- simulation and mock-first creative workflow
- web UI as the primary creative surface
- engineering GUI for technical operation
- grandMA planned later as an external show-control adapter, not as the system core
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. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`.
5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json`.
6. Open `http://127.0.0.1:9001/` for the creative surface.
7. Start the engineering GUI with `cargo run -p infinity_host_ui`.
8. Use the host CLI to validate the project config before attempting activation.
## Docs
- [Architecture](docs/architecture.md)
- [Host API](docs/host_api.md)
- [Local Software-Only Runbook](docs/local_software_only_runbook.md)
- [Show-Control Primitives](docs/show_control_primitives.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)

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

@@ -0,0 +1,396 @@
[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" },
]
[[topology.groups]]
group_id = "middle_panels"
tags = ["row_like", "hardware_safe"]
members = [
{ node_id = "node-01", panel_position = "middle" },
{ node_id = "node-02", panel_position = "middle" },
{ node_id = "node-03", panel_position = "middle" },
{ node_id = "node-04", panel_position = "middle" },
{ node_id = "node-05", panel_position = "middle" },
{ node_id = "node-06", panel_position = "middle" },
]
[[topology.groups]]
group_id = "bottom_panels"
tags = ["row_like", "hardware_safe"]
members = [
{ node_id = "node-01", panel_position = "bottom" },
{ node_id = "node-02", panel_position = "bottom" },
{ node_id = "node-03", panel_position = "bottom" },
{ node_id = "node-04", panel_position = "bottom" },
{ node_id = "node-05", panel_position = "bottom" },
{ node_id = "node-06", panel_position = "bottom" },
]
[[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
[[presets]]
preset_id = "ocean_gradient"
target_group = "all_panels"
transition_ms = 500
[presets.scene]
effect = "gradient"
seed = 1337
palette = ["#041F4A", "#0F8AA6", "#7FFFD4"]
speed = 0.35
intensity = 0.82
blackout = false
[[presets]]
preset_id = "amber_chase_top"
target_group = "top_panels"
transition_ms = 120
[presets.scene]
effect = "chase"
seed = 2026
palette = ["#FF7A00", "#FFD166"]
speed = 1.40
intensity = 1.0
blackout = false
[[presets]]
preset_id = "deep_pulse_bottom"
target_group = "bottom_panels"
transition_ms = 380
[presets.scene]
effect = "pulse"
seed = 404
palette = ["#240046", "#5A189A", "#9D4EDD"]
speed = 0.80
intensity = 0.88
blackout = false

View File

@@ -0,0 +1,12 @@
[package]
name = "infinity_config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
toml.workspace = true

View File

@@ -0,0 +1,26 @@
mod model;
mod validation;
pub use model::*;
pub use validation::*;
use std::{fs, path::Path};
#[derive(Debug, thiserror::Error)]
pub enum ProjectLoadError {
#[error("failed to read project config: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse project config: {0}")]
Parse(#[from] toml::de::Error),
}
impl ProjectConfig {
pub fn from_toml_str(raw: &str) -> Result<Self, toml::de::Error> {
toml::from_str(raw)
}
}
pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, ProjectLoadError> {
let raw = fs::read_to_string(path)?;
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
}

View File

@@ -0,0 +1,261 @@
use serde::{Deserialize, Serialize};
pub const REQUIRED_NODE_COUNT: usize = 6;
pub const REQUIRED_OUTPUTS_PER_NODE: usize = 3;
pub const REQUIRED_LED_COUNT_PER_OUTPUT: u16 = 106;
pub const REQUIRED_TOTAL_OUTPUTS: usize = REQUIRED_NODE_COUNT * REQUIRED_OUTPUTS_PER_NODE;
fn default_expected_node_count() -> usize {
REQUIRED_NODE_COUNT
}
fn default_outputs_per_node() -> usize {
REQUIRED_OUTPUTS_PER_NODE
}
fn default_led_count_per_output() -> u16 {
REQUIRED_LED_COUNT_PER_OUTPUT
}
fn default_true() -> bool {
true
}
fn default_transition_ms() -> u32 {
150
}
fn default_network_send_hz() -> u16 {
60
}
fn default_logic_hz() -> u16 {
120
}
fn default_preview_hz() -> u16 {
15
}
fn default_heartbeat_hz() -> u16 {
5
}
fn default_hold_last_frame_ms() -> u32 {
1500
}
fn default_brightness_limit() -> f32 {
0.35
}
fn default_start_brightness() -> f32 {
0.2
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectConfig {
pub metadata: ProjectMetadata,
pub topology: TopologyConfig,
#[serde(default)]
pub transport_profiles: Vec<TransportProfileConfig>,
#[serde(default)]
pub safety_profiles: Vec<SafetyProfileConfig>,
#[serde(default)]
pub presets: Vec<PresetConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectMetadata {
pub project_name: String,
pub schema_version: u32,
pub default_transport_profile: String,
pub default_safety_profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TopologyConfig {
#[serde(default = "default_expected_node_count")]
pub expected_node_count: usize,
#[serde(default = "default_outputs_per_node")]
pub outputs_per_node: usize,
#[serde(default = "default_led_count_per_output")]
pub leds_per_output: u16,
pub nodes: Vec<NodeConfig>,
#[serde(default)]
pub layout_panels: Vec<LayoutPanelConfig>,
#[serde(default)]
pub groups: Vec<GroupConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeConfig {
pub node_id: String,
pub display_name: String,
pub network: NodeNetworkConfig,
pub outputs: Vec<PanelOutputConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeNetworkConfig {
pub reserved_ip: Option<String>,
pub telemetry_label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelOutputConfig {
pub panel_position: PanelPosition,
pub physical_output_name: String,
pub driver_channel: DriverChannelRef,
pub led_count: u16,
pub direction: LedDirection,
pub color_order: ColorOrder,
#[serde(default = "default_true")]
pub enabled: bool,
pub validation_state: ValidationState,
pub logical_panel_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DriverChannelRef {
pub kind: DriverKind,
pub reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum PanelPosition {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum LedDirection {
Forward,
Reverse,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ColorOrder {
Rgb,
Rbg,
Grb,
Gbr,
Brg,
Bgr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DriverKind {
PendingValidation,
Gpio,
RmtChannel,
I2sLane,
UartPort,
SpiBus,
ExternalDriver,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ValidationState {
PendingHardwareValidation,
Validated,
Retired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelRef {
pub node_id: String,
pub panel_position: PanelPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LayoutPanelConfig {
pub node_id: String,
pub panel_position: PanelPosition,
pub row: usize,
pub column: usize,
#[serde(default)]
pub rotation_degrees: i16,
#[serde(default)]
pub mirror_x: bool,
#[serde(default)]
pub mirror_y: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupConfig {
pub group_id: String,
#[serde(default)]
pub tags: Vec<String>,
pub members: Vec<PanelRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransportProfileConfig {
pub profile_id: String,
pub mode: TransportMode,
#[serde(default = "default_logic_hz")]
pub logic_hz: u16,
#[serde(default = "default_network_send_hz")]
pub network_send_hz: u16,
#[serde(default = "default_preview_hz")]
pub preview_hz: u16,
#[serde(default = "default_heartbeat_hz")]
pub heartbeat_hz: u16,
#[serde(default)]
pub ddp_compatibility: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TransportMode {
DistributedScene,
FrameStreaming,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SafetyProfileConfig {
pub profile_id: String,
#[serde(default = "default_brightness_limit")]
pub master_brightness_limit: f32,
#[serde(default = "default_start_brightness")]
pub default_start_brightness: f32,
#[serde(default)]
pub allow_strobe: bool,
#[serde(default)]
pub max_strobe_hz: Option<f32>,
#[serde(default = "default_hold_last_frame_ms")]
pub hold_last_frame_ms: u32,
#[serde(default)]
pub fallback_preset_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PresetConfig {
pub preset_id: String,
pub scene: SceneConfig,
#[serde(default = "default_transition_ms")]
pub transition_ms: u32,
#[serde(default)]
pub target_group: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneConfig {
pub effect: String,
pub seed: u64,
#[serde(default)]
pub palette: Vec<String>,
#[serde(default)]
pub speed: f32,
#[serde(default)]
pub intensity: f32,
#[serde(default)]
pub blackout: bool,
}

View File

@@ -0,0 +1,673 @@
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, SafetyProfileConfig,
SceneConfig, TopologyConfig, TransportMode, TransportProfileConfig,
};
fn build_output(
position: PanelPosition,
label: &str,
driver_kind: DriverKind,
) -> PanelOutputConfig {
PanelOutputConfig {
panel_position: position,
physical_output_name: label.to_string(),
driver_channel: DriverChannelRef {
kind: driver_kind,
reference: label.to_string(),
},
led_count: 106,
direction: LedDirection::Forward,
color_order: ColorOrder::Grb,
enabled: true,
validation_state: ValidationState::Validated,
logical_panel_name: None,
}
}
fn build_project() -> ProjectConfig {
let mut nodes = Vec::new();
for index in 1..=6 {
nodes.push(NodeConfig {
node_id: format!("node-{index:02}"),
display_name: format!("Node {index:02}"),
network: NodeNetworkConfig {
reserved_ip: Some(format!("192.168.40.1{index:02}")),
telemetry_label: None,
},
outputs: vec![
build_output(PanelPosition::Top, "GPIO_1", DriverKind::Gpio),
build_output(PanelPosition::Middle, "GPIO_2", DriverKind::Gpio),
build_output(PanelPosition::Bottom, "GPIO_3", DriverKind::Gpio),
],
});
}
ProjectConfig {
metadata: ProjectMetadata {
project_name: "Test".to_string(),
schema_version: 1,
default_transport_profile: "default-scene".to_string(),
default_safety_profile: "live-safe".to_string(),
},
topology: TopologyConfig {
expected_node_count: 6,
outputs_per_node: 3,
leds_per_output: 106,
nodes,
layout_panels: Vec::new(),
groups: vec![GroupConfig {
group_id: "all".to_string(),
tags: vec!["global".to_string()],
members: vec![
PanelRef {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Top,
},
PanelRef {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Middle,
},
],
}],
},
transport_profiles: vec![TransportProfileConfig {
profile_id: "default-scene".to_string(),
mode: TransportMode::DistributedScene,
logic_hz: 120,
network_send_hz: 60,
preview_hz: 15,
heartbeat_hz: 5,
ddp_compatibility: false,
}],
safety_profiles: vec![SafetyProfileConfig {
profile_id: "live-safe".to_string(),
master_brightness_limit: 0.3,
default_start_brightness: 0.2,
allow_strobe: false,
max_strobe_hz: None,
hold_last_frame_ms: 1200,
fallback_preset_id: Some("safe-blue".to_string()),
}],
presets: vec![PresetConfig {
preset_id: "safe-blue".to_string(),
scene: SceneConfig {
effect: "solid_color".to_string(),
seed: 7,
palette: vec!["#003bff".to_string()],
speed: 0.0,
intensity: 1.0,
blackout: false,
},
transition_ms: 150,
target_group: None,
}],
}
}
#[test]
fn accepts_structurally_valid_project() {
let project = build_project();
let report = project.validate(ValidationMode::Structural);
assert!(report.is_ok(), "{report:#?}");
}
#[test]
fn rejects_wrong_led_count() {
let mut project = build_project();
project.topology.nodes[0].outputs[0].led_count = 105;
let report = project.validate(ValidationMode::Structural);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "invalid_led_count"));
}
#[test]
fn rejects_duplicate_driver_refs() {
let mut project = build_project();
project.topology.nodes[0].outputs[1]
.driver_channel
.reference = project.topology.nodes[0].outputs[0]
.driver_channel
.reference
.clone();
let report = project.validate(ValidationMode::Structural);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "duplicate_driver_reference"));
}
#[test]
fn activation_requires_hardware_validation() {
let mut project = build_project();
project.topology.nodes[0].outputs[0].driver_channel.kind = DriverKind::PendingValidation;
project.topology.nodes[0].outputs[0].validation_state =
ValidationState::PendingHardwareValidation;
let report = project.validate(ValidationMode::Activation);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "pending_driver_validation"));
assert!(report
.issues
.iter()
.any(|issue| issue.code == "pending_output_validation"));
}
}

View File

@@ -0,0 +1,14 @@
[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
thiserror.workspace = true
infinity_config = { path = "../infinity_config" }
infinity_protocol = { path = "../infinity_protocol" }

View File

@@ -0,0 +1,391 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use serde::{Deserialize, Serialize};
pub const HOST_API_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HostSnapshot {
pub api_version: u16,
pub backend_label: String,
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub catalog: CatalogSnapshot,
pub active_scene: ActiveSceneSnapshot,
pub preview: PreviewSnapshot,
pub available_patterns: Vec<String>,
pub nodes: Vec<NodeSnapshot>,
pub panels: Vec<PanelSnapshot>,
pub recent_events: Vec<StatusEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemSnapshot {
pub project_name: String,
pub schema_version: u32,
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobalControlSnapshot {
pub blackout: bool,
pub master_brightness: f32,
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EngineSnapshot {
pub logic_hz: u16,
pub frame_hz: u16,
pub preview_hz: u16,
pub uptime_ms: u64,
pub frame_index: u64,
pub dropped_frames: u64,
pub active_transition: Option<TransitionSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CatalogSnapshot {
pub patterns: Vec<PatternDefinition>,
pub presets: Vec<PresetSummary>,
pub groups: Vec<GroupSummary>,
pub creative_snapshots: Vec<CreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PatternDefinition {
pub pattern_id: String,
pub display_name: String,
pub description: String,
pub parameters: Vec<SceneParameterSpec>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PresetSummary {
pub preset_id: String,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActiveSceneSnapshot {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: Vec<SceneParameterState>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneParameterState {
pub key: String,
pub label: String,
pub kind: SceneParameterKind,
pub value: SceneParameterValue,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneParameterSpec {
pub key: String,
pub label: String,
pub kind: SceneParameterKind,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
pub default_value: SceneParameterValue,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SceneParameterKind {
Scalar,
Toggle,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum SceneParameterValue {
Scalar(f32),
Toggle(bool),
Text(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PreviewSnapshot {
pub panels: Vec<PreviewPanelSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PreviewPanelSnapshot {
pub target: PanelTarget,
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
pub preview_source: PreviewSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PreviewSource {
Scene,
Transition,
PanelTest,
Blackout,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransitionSnapshot {
pub style: SceneTransitionStyle,
pub from_pattern_id: String,
pub to_pattern_id: String,
pub duration_ms: u32,
pub progress: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SceneTransitionStyle {
Snap,
Crossfade,
Chase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeSnapshot {
pub node_id: String,
pub display_name: String,
pub reserved_ip: Option<String>,
pub connection: NodeConnectionState,
pub last_contact_ms: u64,
pub error_status: Option<String>,
pub panel_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelSnapshot {
pub target: PanelTarget,
pub physical_output_name: String,
pub driver_reference: String,
pub led_count: u16,
pub direction: LedDirection,
pub color_order: ColorOrder,
pub enabled: bool,
pub validation_state: ValidationState,
pub connection: NodeConnectionState,
pub last_test_trigger_ms: Option<u64>,
pub error_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusEvent {
pub at_millis: u64,
pub kind: StatusEventKind,
pub code: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StatusEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeConnectionState {
Online,
Degraded,
Offline,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelTarget {
pub node_id: String,
pub panel_position: PanelPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
pub enum HostCommand {
SetBlackout(bool),
SetMasterBrightness(f32),
SelectPattern(String),
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: SceneParameterValue,
},
SetTransitionDurationMs(u32),
SetTransitionStyle(SceneTransitionStyle),
TriggerPanelTest {
target: PanelTarget,
pattern: TestPatternKind,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TestPatternKind {
WalkingPixel106,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandOutcome {
pub generated_at_millis: u64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostCommandError {
pub code: String,
pub message: String,
}
pub trait HostApiPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError>;
}
pub trait HostUiPort: HostApiPort {}
impl<T: HostApiPort + ?Sized> HostUiPort for T {}
impl NodeConnectionState {
pub fn label(self) -> &'static str {
match self {
Self::Online => "online",
Self::Degraded => "degraded",
Self::Offline => "offline",
}
}
}
impl SceneTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl CatalogSource {
pub fn label(self) -> &'static str {
match self {
Self::BuiltIn => "built_in",
Self::RuntimeUser => "runtime_user",
}
}
}
impl StatusEventKind {
pub fn label(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
impl TestPatternKind {
pub fn label(self) -> &'static str {
match self {
Self::WalkingPixel106 => "walking_pixel_106",
}
}
}
impl SceneParameterValue {
pub fn as_scalar(&self) -> Option<f32> {
match self {
Self::Scalar(value) => Some(*value),
_ => None,
}
}
pub fn as_toggle(&self) -> Option<bool> {
match self {
Self::Toggle(value) => Some(*value),
_ => None,
}
}
pub fn text(value: impl Into<String>) -> Self {
Self::Text(value.into())
}
}
impl HostCommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}

View File

@@ -0,0 +1,699 @@
use crate::{
CommandOutcome, HostApiPort, HostCommand, HostCommandError, HostSnapshot, PanelTarget,
SceneParameterValue, SceneTransitionStyle,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
pub enum ShowControlPrimitive {
Blackout {
enabled: bool,
},
RecallPreset {
preset_id: String,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
SetMasterBrightness {
value: f32,
},
SetPattern {
pattern_id: String,
},
SetGroupParameter {
group_id: Option<String>,
key: String,
value: SceneParameterValue,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
SetTransitionStyle {
style: SceneTransitionStyle,
duration_ms: Option<u32>,
},
TriggerTransition,
RequestSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalAdapterCapabilities {
pub supports_blackout: bool,
pub supports_preset_recall: bool,
pub supports_creative_snapshot_recall: bool,
pub supports_master_brightness: bool,
pub supports_pattern_staging: bool,
pub supports_group_parameter_staging: bool,
pub supports_group_upsert: bool,
pub supports_transition_staging: bool,
pub supports_explicit_trigger: bool,
pub supports_snapshot_request: bool,
}
impl Default for ExternalAdapterCapabilities {
fn default() -> Self {
Self {
supports_blackout: true,
supports_preset_recall: true,
supports_creative_snapshot_recall: true,
supports_master_brightness: true,
supports_pattern_staging: true,
supports_group_parameter_staging: true,
supports_group_upsert: true,
supports_transition_staging: true,
supports_explicit_trigger: true,
supports_snapshot_request: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShowControlPrimitiveOutcome {
Buffered { summary: String },
Command(CommandOutcome),
Snapshot(HostSnapshot),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShowControlSession {
pending_pattern_id: Option<String>,
pending_group_id: Option<Option<String>>,
pending_parameters: BTreeMap<String, SceneParameterValue>,
pending_transition_style: Option<SceneTransitionStyle>,
pending_transition_duration_ms: Option<u32>,
}
pub trait ExternalShowControlPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn execute_primitive(
&self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
pub trait ExternalShowControlAdapter: Send {
fn adapter_id(&self) -> &str;
fn capabilities(&self) -> ExternalAdapterCapabilities {
ExternalAdapterCapabilities::default()
}
fn apply_primitive(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
impl ShowControlSession {
pub fn apply(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ShowControlPrimitive::SetPattern { pattern_id } => {
if pattern_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_pattern_id",
"pattern_id must not be empty",
));
}
self.pending_pattern_id = Some(pattern_id.clone());
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("pattern staged: {pattern_id}"),
})
}
ShowControlPrimitive::SetGroupParameter {
group_id,
key,
value,
} => {
if key.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_group_parameter_key",
"group parameter key must not be empty",
));
}
self.pending_group_id = Some(group_id.clone());
self.pending_parameters.insert(key.clone(), value);
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!(
"group parameter staged: {} for {}",
key,
group_id.as_deref().unwrap_or("current_group")
),
})
}
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::SetTransitionStyle { style, duration_ms } => {
self.pending_transition_style = Some(style);
if let Some(duration_ms) = duration_ms {
self.pending_transition_duration_ms = Some(duration_ms);
}
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("transition style staged: {}", style.label()),
})
}
ShowControlPrimitive::TriggerTransition => self.trigger_transition(port),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(port.snapshot()))
}
}
}
fn trigger_transition(
&mut self,
port: &dyn HostApiPort,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
let Some(pattern_id) = self.pending_pattern_id.clone() else {
return Err(HostCommandError::new(
"transition_pattern_required",
"trigger_transition requires a staged pattern",
));
};
if let Some(group_id) = self.pending_group_id.clone() {
port.send_command(HostCommand::SelectGroup { group_id })?;
}
if let Some(duration_ms) = self.pending_transition_duration_ms {
port.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
}
if let Some(style) = self.pending_transition_style {
port.send_command(HostCommand::SetTransitionStyle(style))?;
}
let mut outcome = port.send_command(HostCommand::SelectPattern(pattern_id.clone()))?;
for (key, value) in self.pending_parameters.clone() {
outcome = port.send_command(HostCommand::SetSceneParameter { key, value })?;
}
let summary = if let Some(group_id) = self.pending_group_id.as_ref() {
format!(
"transition triggered: {} on {}",
pattern_id,
group_id.as_deref().unwrap_or("all_panels")
)
} else {
format!("transition triggered: {pattern_id}")
};
self.clear_transition_buffer();
Ok(ShowControlPrimitiveOutcome::Command(CommandOutcome {
generated_at_millis: outcome.generated_at_millis,
summary,
}))
}
pub fn clear_transition_buffer(&mut self) {
self.pending_pattern_id = None;
self.pending_group_id = None;
self.pending_parameters.clear();
self.pending_transition_style = None;
self.pending_transition_duration_ms = None;
}
}
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(self)
}
fn execute_primitive(
&self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(self.snapshot()))
}
ShowControlPrimitive::SetPattern { .. }
| ShowControlPrimitive::SetGroupParameter { .. }
| ShowControlPrimitive::SetTransitionStyle { .. }
| ShowControlPrimitive::TriggerTransition => Err(HostCommandError::new(
"show_control_session_required",
"staged show-control primitives require a stateful ShowControlSession or adapter",
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct BufferedShowControlAdapter {
session: ShowControlSession,
}
impl BufferedShowControlAdapter {
pub fn new() -> Self {
Self::default()
}
pub fn session(&self) -> &ShowControlSession {
&self.session
}
}
impl ExternalShowControlAdapter for BufferedShowControlAdapter {
fn adapter_id(&self) -> &str {
"buffered_show_control"
}
fn apply_primitive(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
self.session.apply(port, primitive)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceShowControlMode {
StatefulSession,
StatelessPort,
}
#[derive(Debug, Clone)]
pub struct ReferenceShowControlClient<P> {
port: P,
mode: ReferenceShowControlMode,
adapter: BufferedShowControlAdapter,
}
impl<P: HostApiPort> ReferenceShowControlClient<P> {
pub fn stateful(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatefulSession,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn stateless(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatelessPort,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn mode(&self) -> ReferenceShowControlMode {
self.mode
}
pub fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(&self.port)
}
pub fn pending_session(&self) -> &ShowControlSession {
self.adapter.session()
}
pub fn apply_primitive(
&mut self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match self.mode {
ReferenceShowControlMode::StatefulSession => {
self.adapter.apply_primitive(&self.port, primitive)
}
ReferenceShowControlMode::StatelessPort => self.port.execute_primitive(primitive),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SimulationHostService;
use infinity_config::ProjectConfig;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn staged_pattern_and_transition_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
let outcome = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
match outcome {
ShowControlPrimitiveOutcome::Command(outcome) => {
assert!(outcome.summary.contains("transition triggered: noise"));
}
other => panic!("expected command outcome, got {other:?}"),
}
let snapshot = client.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn trigger_transition_requires_a_staged_pattern() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
let error = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect_err("trigger without pattern should fail");
assert_eq!(error.code, "transition_pattern_required");
}
#[test]
fn staged_primitives_only_mutate_host_when_transition_is_triggered() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let staged = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
staged,
ShowControlPrimitiveOutcome::Buffered {
summary: "pattern staged: noise".to_string(),
}
);
let staged_snapshot = client.snapshot();
assert_eq!(
staged_snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn group_update_parameter_change_and_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::UpsertGroup {
group_id: "focus_pair".to_string(),
tags: vec!["runtime".to_string(), "focus".to_string()],
members: vec![
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Top,
},
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Middle,
},
],
overwrite: true,
})
.expect("group upsert should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("focus_pair".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.81),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.global.selected_group.as_deref(),
Some("focus_pair")
);
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("focus_pair")
);
assert!(snapshot
.active_scene
.parameters
.iter()
.any(|parameter| parameter.key == "grain"
&& parameter.value == SceneParameterValue::Scalar(0.81)));
assert!(snapshot
.catalog
.groups
.iter()
.any(|group| group.group_id == "focus_pair"));
}
#[test]
fn preset_recall_interrupts_running_transition_with_a_new_transition() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Crossfade,
duration_ms: Some(1600),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let active_before_recall = client.snapshot();
assert!(active_before_recall.engine.active_transition.is_some());
client
.apply_primitive(ShowControlPrimitive::RecallPreset {
preset_id: "ocean_gradient".to_string(),
})
.expect("preset recall should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.active_scene.preset_id.as_deref(),
Some("ocean_gradient")
);
assert_eq!(snapshot.active_scene.pattern_id, "gradient");
assert!(snapshot.engine.active_transition.is_some());
assert!(snapshot
.recent_events
.iter()
.any(|event| event.message.contains("preset recalled: ocean_gradient")));
}
#[test]
fn blackout_during_staged_session_keeps_pending_transition_buffer() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("bottom_panels".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.74),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::Blackout { enabled: true })
.expect("blackout should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
assert_eq!(
client.pending_session().pending_group_id,
Some(Some("bottom_panels".to_string()))
);
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(snapshot.global.blackout, true);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("bottom_panels")
);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn request_snapshot_is_read_only() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let outcome = client
.apply_primitive(ShowControlPrimitive::RequestSnapshot)
.expect("snapshot request should succeed");
match outcome {
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
assert_eq!(
snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
other => panic!("expected snapshot outcome, got {other:?}"),
}
let after = client.snapshot();
assert_eq!(
after.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn staged_primitives_are_rejected_on_a_stateless_port() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateless(service);
let error = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect_err("staged primitive should require a session");
assert_eq!(error.code, "show_control_session_required");
}
#[test]
fn invalid_group_parameter_key_is_rejected() {
let service = SimulationHostService::new(sample_project());
let mut session = ShowControlSession::default();
let error = session
.apply(
&service,
ShowControlPrimitive::SetGroupParameter {
group_id: Some("top_panels".to_string()),
key: " ".to_string(),
value: SceneParameterValue::Scalar(0.4),
},
)
.expect_err("empty parameter key should fail");
assert_eq!(error.code, "invalid_group_parameter_key");
}
}

View File

@@ -0,0 +1,13 @@
pub mod control;
pub mod external_control;
pub mod runtime;
pub mod scene;
pub mod show_store;
pub mod simulation;
pub use control::*;
pub use external_control::*;
pub use runtime::*;
pub use scene::*;
pub use show_store::*;
pub use simulation::*;

View File

@@ -0,0 +1,161 @@
use clap::{Parser, Subcommand, ValueEnum};
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
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,
},
Snapshot {
#[arg(long)]
config: PathBuf,
},
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::Snapshot { config } => snapshot_command(config),
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 snapshot_command(config: PathBuf) -> 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 service = SimulationHostService::new(project);
match serde_json::to_string_pretty(&service.snapshot()) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Failed to serialize snapshot: {error}");
ExitCode::FAILURE
}
}
}
fn print_open_validation_points() {
for line in [
"Pending hardware validation gates:",
"1. Confirm whether 'UART 6 / 5 / 4' are GPIO labels, logical channels, or real UART peripherals.",
"2. Confirm the exact LED chipset and timing backend required per output.",
"3. Confirm color order, start pixel, and direction for all 18 outputs.",
"4. Confirm whether all outputs truly have 106 active LEDs without dummy or reserve pixels.",
"5. Confirm the final node-to-physical-panel mapping before enabling layout-specific scenes.",
] {
println!("{line}");
}
}

View File

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

View File

@@ -0,0 +1,772 @@
use crate::control::{
ActiveSceneSnapshot, CatalogSnapshot, CatalogSource, GroupSummary, PatternDefinition,
PresetSummary, SceneParameterKind, SceneParameterSpec, SceneParameterState,
SceneParameterValue, SceneTransitionStyle, TransitionSnapshot,
};
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
hash::{Hash, Hasher},
time::Instant,
};
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneRuntime {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: BTreeMap<String, SceneParameterValue>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone)]
pub struct TransitionRuntime {
pub style: SceneTransitionStyle,
pub duration_ms: u32,
pub started_at: Instant,
pub from_scene: SceneRuntime,
}
#[derive(Debug, Clone)]
pub struct RenderedPreview {
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
}
#[derive(Debug, Clone)]
pub struct PatternRegistry {
definitions: BTreeMap<String, PatternDefinition>,
}
#[derive(Debug, Clone, Copy)]
struct RgbColor {
r: u8,
g: u8,
b: u8,
}
impl PatternRegistry {
pub fn new() -> Self {
let definitions = default_pattern_definitions()
.into_iter()
.map(|definition| (definition.pattern_id.clone(), definition))
.collect();
Self { definitions }
}
pub fn catalog(&self, project: &ProjectConfig) -> CatalogSnapshot {
CatalogSnapshot {
patterns: self.definitions.values().cloned().collect(),
presets: project
.presets
.iter()
.map(|preset| PresetSummary {
preset_id: preset.preset_id.clone(),
pattern_id: preset.scene.effect.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_ms,
transition_style: transition_style_from_duration(preset.transition_ms),
source: CatalogSource::BuiltIn,
})
.collect(),
groups: project
.topology
.groups
.iter()
.map(|group| GroupSummary {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: CatalogSource::BuiltIn,
})
.collect(),
creative_snapshots: Vec::new(),
}
}
pub fn initial_scene(&self, project: &ProjectConfig) -> SceneRuntime {
project
.presets
.first()
.map(|preset| self.scene_from_preset(preset))
.unwrap_or_else(|| {
self.scene_for_pattern(
"solid_color",
Some("bootstrap-solid-color".to_string()),
None,
1,
vec!["#ffffff".to_string()],
false,
)
})
}
pub fn pattern_definitions(&self) -> Vec<PatternDefinition> {
self.definitions.values().cloned().collect()
}
pub fn scene_from_preset_id(
&self,
project: &ProjectConfig,
preset_id: &str,
) -> Option<SceneRuntime> {
project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| self.scene_from_preset(preset))
}
pub fn transition_style_for_preset(
&self,
project: &ProjectConfig,
preset_id: &str,
) -> SceneTransitionStyle {
project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| transition_style_from_duration(preset.transition_ms))
.unwrap_or(SceneTransitionStyle::Crossfade)
}
pub fn scene_for_pattern(
&self,
pattern_id: &str,
preset_id: Option<String>,
target_group: Option<String>,
seed: u64,
palette: Vec<String>,
blackout: bool,
) -> SceneRuntime {
let definition = self.definition_or_default(pattern_id);
let mut parameters = BTreeMap::new();
for spec in &definition.parameters {
parameters.insert(spec.key.clone(), spec.default_value.clone());
}
SceneRuntime {
preset_id,
pattern_id: definition.pattern_id.clone(),
seed,
palette: if palette.is_empty() {
vec!["#ffffff".to_string()]
} else {
palette
},
parameters,
target_group,
blackout,
}
}
pub fn set_scene_parameter(
&self,
scene: &mut SceneRuntime,
key: &str,
value: SceneParameterValue,
) {
scene.parameters.insert(key.to_string(), value);
}
pub fn active_scene_snapshot(&self, scene: &SceneRuntime) -> ActiveSceneSnapshot {
let definition = self.definition_or_default(&scene.pattern_id);
let parameters = definition
.parameters
.iter()
.map(|spec| SceneParameterState {
key: spec.key.clone(),
label: spec.label.clone(),
kind: spec.kind,
value: scene
.parameters
.get(&spec.key)
.cloned()
.unwrap_or_else(|| spec.default_value.clone()),
min_scalar: spec.min_scalar,
max_scalar: spec.max_scalar,
step: spec.step,
})
.collect();
ActiveSceneSnapshot {
preset_id: scene.preset_id.clone(),
pattern_id: scene.pattern_id.clone(),
seed: scene.seed,
palette: scene.palette.clone(),
parameters,
target_group: scene.target_group.clone(),
blackout: scene.blackout,
}
}
pub fn transition_snapshot(
&self,
scene: &SceneRuntime,
transition: &TransitionRuntime,
) -> TransitionSnapshot {
let elapsed_ms = transition.started_at.elapsed().as_millis() as u64;
let progress = if transition.duration_ms == 0 {
1.0
} else {
(elapsed_ms as f32 / transition.duration_ms as f32).clamp(0.0, 1.0)
};
TransitionSnapshot {
style: transition.style,
from_pattern_id: transition.from_scene.pattern_id.clone(),
to_pattern_id: scene.pattern_id.clone(),
duration_ms: transition.duration_ms,
progress,
}
}
pub fn transition_finished(&self, transition: &TransitionRuntime) -> bool {
transition.started_at.elapsed().as_millis() as u32 >= transition.duration_ms
}
pub fn render_preview(
&self,
scene: &SceneRuntime,
panel_index: usize,
panel_count: usize,
elapsed_ms: u64,
) -> RenderedPreview {
let colors = palette_from_scene(scene);
let sample_leds = (0..DEFAULT_SAMPLE_LED_COUNT)
.map(|sample_index| {
self.render_led_color(
scene,
&colors,
panel_index,
panel_count,
sample_index,
DEFAULT_SAMPLE_LED_COUNT,
elapsed_ms,
)
})
.collect::<Vec<_>>();
let representative = RgbColor::average(&sample_leds);
let energy_percent = sample_leds
.iter()
.map(|color| color.energy_percent() as u32)
.sum::<u32>()
/ sample_leds.len().max(1) as u32;
RenderedPreview {
representative_color_hex: representative.to_hex(),
sample_led_hex: sample_leds
.into_iter()
.map(|color| color.to_hex())
.collect(),
energy_percent: energy_percent.min(100) as u8,
}
}
pub fn scene_from_preset_config(&self, preset: &PresetConfig) -> SceneRuntime {
let mut scene = self.scene_for_pattern(
&preset.scene.effect,
Some(preset.preset_id.clone()),
preset.target_group.clone(),
preset.scene.seed,
preset.scene.palette.clone(),
preset.scene.blackout,
);
self.set_scene_parameter(
&mut scene,
"speed",
SceneParameterValue::Scalar(preset.scene.speed),
);
self.set_scene_parameter(
&mut scene,
"intensity",
SceneParameterValue::Scalar(preset.scene.intensity),
);
scene
}
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
self.scene_from_preset_config(preset)
}
fn definition_or_default(&self, pattern_id: &str) -> &PatternDefinition {
self.definitions
.get(pattern_id)
.or_else(|| self.definitions.get("solid_color"))
.expect("pattern registry must contain a solid_color definition")
}
fn render_led_color(
&self,
scene: &SceneRuntime,
palette: &[RgbColor],
panel_index: usize,
panel_count: usize,
sample_index: usize,
sample_count: usize,
elapsed_ms: u64,
) -> RgbColor {
let speed = scene
.parameters
.get("speed")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(1.0);
let intensity = scene
.parameters
.get("intensity")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let spread = scene
.parameters
.get("spread")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.55);
let width = scene
.parameters
.get("width")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.25)
.clamp(0.05, 1.0);
let grain = scene
.parameters
.get("grain")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.65)
.clamp(0.0, 1.0);
let trail = scene
.parameters
.get("trail")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.45)
.clamp(0.05, 1.0);
let panel_phase = if panel_count == 0 {
0.0
} else {
panel_index as f32 / panel_count as f32
};
let led_phase = if sample_count == 0 {
0.0
} else {
sample_index as f32 / sample_count as f32
};
let time_phase = elapsed_ms as f32 / 1000.0 * speed.max(0.01);
let raw = match scene.pattern_id.as_str() {
"solid_color" => palette.first().copied().unwrap_or(RgbColor::WHITE),
"gradient" => {
let from = palette.first().copied().unwrap_or(RgbColor::BLACK);
let to = palette.get(1).copied().unwrap_or(from);
let blend = (panel_phase + led_phase * spread + time_phase * 0.12).fract();
from.blend(to, blend)
}
"chase" => {
let position = (time_phase * 0.65 + panel_phase + led_phase).fract();
let highlight = distance_wrap(position, 0.0);
let strength = smooth_peak(highlight, width);
palette
.first()
.copied()
.unwrap_or(RgbColor::WHITE)
.scale(strength)
}
"pulse" => {
let wave = ((time_phase * 2.8 + panel_phase * 0.6).sin() + 1.0) * 0.5;
palette
.first()
.copied()
.unwrap_or(RgbColor::WHITE)
.scale(wave)
}
"noise" => {
let noise = hashed_noise(scene.seed, panel_index, sample_index, elapsed_ms);
let color_index = ((noise * palette.len().max(1) as f32).floor() as usize)
.min(palette.len().saturating_sub(1));
palette
.get(color_index)
.copied()
.unwrap_or(RgbColor::WHITE)
.scale((0.3 + noise * grain).clamp(0.0, 1.0))
}
"walking_pixel" => {
let head = (time_phase * 0.8 + panel_phase * 0.4).fract();
let sample = led_phase;
let distance = distance_wrap(sample, head);
let strength = smooth_peak(distance, trail);
let base = palette.first().copied().unwrap_or(RgbColor::WHITE);
base.scale(strength.max(0.08))
}
_ => palette.first().copied().unwrap_or(RgbColor::WHITE),
};
raw.scale(intensity)
}
}
pub fn transition_style_from_duration(duration_ms: u32) -> SceneTransitionStyle {
if duration_ms == 0 {
SceneTransitionStyle::Snap
} else if duration_ms <= 120 {
SceneTransitionStyle::Chase
} else {
SceneTransitionStyle::Crossfade
}
}
pub fn apply_group_gate(preview: &RenderedPreview, active_in_group: bool) -> RenderedPreview {
if active_in_group {
return preview.clone();
}
let dimmed = preview
.sample_led_hex
.iter()
.map(|hex| parse_color(hex).scale(0.18).to_hex())
.collect::<Vec<_>>();
let representative = parse_color(&preview.representative_color_hex).scale(0.18);
RenderedPreview {
representative_color_hex: representative.to_hex(),
sample_led_hex: dimmed,
energy_percent: ((preview.energy_percent as f32) * 0.18) as u8,
}
}
pub fn blend_previews(
from: &RenderedPreview,
to: &RenderedPreview,
progress: f32,
) -> RenderedPreview {
let blend = progress.clamp(0.0, 1.0);
let from_color = parse_color(&from.representative_color_hex);
let to_color = parse_color(&to.representative_color_hex);
let sample_count = from.sample_led_hex.len().min(to.sample_led_hex.len());
let mut sample_led_hex = Vec::with_capacity(sample_count);
for index in 0..sample_count {
let left = parse_color(&from.sample_led_hex[index]);
let right = parse_color(&to.sample_led_hex[index]);
sample_led_hex.push(left.blend(right, blend).to_hex());
}
RenderedPreview {
representative_color_hex: from_color.blend(to_color, blend).to_hex(),
sample_led_hex,
energy_percent: ((from.energy_percent as f32)
+ (to.energy_percent as f32 - from.energy_percent as f32) * blend)
as u8,
}
}
pub fn panel_test_preview(elapsed_since_trigger_ms: u64) -> RenderedPreview {
let phase = ((elapsed_since_trigger_ms / 120) % DEFAULT_SAMPLE_LED_COUNT as u64) as usize;
let mut sample_led_hex = Vec::with_capacity(DEFAULT_SAMPLE_LED_COUNT);
for index in 0..DEFAULT_SAMPLE_LED_COUNT {
let strength = if index == phase {
1.0
} else if distance_wrap(
index as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
phase as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
) < 0.20
{
0.35
} else {
0.06
};
sample_led_hex.push(RgbColor::WHITE.scale(strength).to_hex());
}
RenderedPreview {
representative_color_hex: RgbColor::WHITE.scale(0.42).to_hex(),
sample_led_hex,
energy_percent: 42,
}
}
pub fn blackout_preview() -> RenderedPreview {
RenderedPreview {
representative_color_hex: "#000000".to_string(),
sample_led_hex: vec!["#000000".to_string(); DEFAULT_SAMPLE_LED_COUNT],
energy_percent: 0,
}
}
pub fn build_group_members(project: &ProjectConfig) -> BTreeMap<String, BTreeSet<String>> {
project
.topology
.groups
.iter()
.map(|group| {
let members = group
.members
.iter()
.map(|member| {
format!(
"{}:{}",
member.node_id,
panel_position_key(&member.panel_position)
)
})
.collect();
(group.group_id.clone(), members)
})
.collect()
}
pub fn panel_membership_key(node_id: &str, panel_position: &str) -> String {
format!("{node_id}:{panel_position}")
}
fn panel_position_key(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn default_pattern_definitions() -> Vec<PatternDefinition> {
vec![
PatternDefinition {
pattern_id: "solid_color".to_string(),
display_name: "Solid Color".to_string(),
description: "Static palette color for calm base looks and blackout recovery scenes."
.to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
],
},
PatternDefinition {
pattern_id: "gradient".to_string(),
display_name: "Gradient Drift".to_string(),
description: "Spatial gradient with slow temporal drift for mood development."
.to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.35),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.85),
scalar_spec("spread", "Spread", 0.1, 1.5, 0.05, 0.55),
],
},
PatternDefinition {
pattern_id: "chase".to_string(),
display_name: "Chase".to_string(),
description: "Directional chase motion that is easy to time and group.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 1.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
scalar_spec("width", "Width", 0.05, 0.8, 0.01, 0.25),
],
},
PatternDefinition {
pattern_id: "pulse".to_string(),
display_name: "Pulse".to_string(),
description: "Breathing pulse for soft transitions and level checks.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 0.75),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.9),
],
},
PatternDefinition {
pattern_id: "noise".to_string(),
display_name: "Noise".to_string(),
description: "Organic shimmer driven by deterministic pseudo noise.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 4.0, 0.05, 0.7),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.8),
scalar_spec("grain", "Grain", 0.0, 1.0, 0.01, 0.65),
],
},
PatternDefinition {
pattern_id: "walking_pixel".to_string(),
display_name: "Walking Pixel".to_string(),
description: "Single-pixel scan for mapping tests and alignment checks.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 8.0, 0.05, 1.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
scalar_spec("trail", "Trail", 0.05, 0.8, 0.01, 0.45),
],
},
]
}
fn scalar_spec(
key: &str,
label: &str,
min_scalar: f32,
max_scalar: f32,
step: f32,
default_value: f32,
) -> SceneParameterSpec {
SceneParameterSpec {
key: key.to_string(),
label: label.to_string(),
kind: SceneParameterKind::Scalar,
min_scalar: Some(min_scalar),
max_scalar: Some(max_scalar),
step: Some(step),
default_value: SceneParameterValue::Scalar(default_value),
}
}
fn palette_from_scene(scene: &SceneRuntime) -> Vec<RgbColor> {
if scene.palette.is_empty() {
vec![RgbColor::WHITE]
} else {
scene
.palette
.iter()
.map(|entry| parse_color(entry))
.collect()
}
}
fn parse_color(hex: &str) -> RgbColor {
let raw = hex.trim().trim_start_matches('#');
if raw.len() == 6 {
if let Ok(value) = u32::from_str_radix(raw, 16) {
return RgbColor {
r: ((value >> 16) & 0xff) as u8,
g: ((value >> 8) & 0xff) as u8,
b: (value & 0xff) as u8,
};
}
}
RgbColor::WHITE
}
fn hashed_noise(seed: u64, panel_index: usize, sample_index: usize, elapsed_ms: u64) -> f32 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
panel_index.hash(&mut hasher);
sample_index.hash(&mut hasher);
(elapsed_ms / 110).hash(&mut hasher);
let value = hasher.finish();
(value % 10_000) as f32 / 10_000.0
}
fn distance_wrap(a: f32, b: f32) -> f32 {
let diff = (a - b).abs();
diff.min(1.0 - diff)
}
fn smooth_peak(distance: f32, width: f32) -> f32 {
let normalized = (1.0 - distance / width.max(0.0001)).clamp(0.0, 1.0);
normalized * normalized
}
impl RgbColor {
const BLACK: Self = Self { r: 0, g: 0, b: 0 };
const WHITE: Self = Self {
r: 255,
g: 255,
b: 255,
};
fn blend(self, other: Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
Self {
r: (self.r as f32 + (other.r as f32 - self.r as f32) * t) as u8,
g: (self.g as f32 + (other.g as f32 - self.g as f32) * t) as u8,
b: (self.b as f32 + (other.b as f32 - self.b as f32) * t) as u8,
}
}
fn scale(self, amount: f32) -> Self {
let amount = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f32 * amount) as u8,
g: (self.g as f32 * amount) as u8,
b: (self.b as f32 * amount) as u8,
}
}
fn average(colors: &[Self]) -> Self {
if colors.is_empty() {
return Self::BLACK;
}
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
for color in colors {
r += color.r as u32;
g += color.g as u32;
b += color.b as u32;
}
let count = colors.len() as u32;
Self {
r: (r / count) as u8,
g: (g / count) as u8,
b: (b / count) as u8,
}
}
fn energy_percent(self) -> u8 {
(((self.r as u32 + self.g as u32 + self.b as u32) as f32 / (255.0 * 3.0)) * 100.0) as u8
}
fn to_hex(self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
}
#[cfg(test)]
mod tests {
use super::*;
use infinity_config::ProjectConfig;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn registry_builds_catalog_for_project() {
let registry = PatternRegistry::new();
let catalog = registry.catalog(&sample_project());
assert!(catalog
.patterns
.iter()
.any(|pattern| pattern.pattern_id == "solid_color"));
assert!(catalog
.presets
.iter()
.any(|preset| preset.preset_id == "ocean_gradient"));
assert!(catalog
.groups
.iter()
.any(|group| group.group_id == "bottom_panels"));
}
#[test]
fn preset_scene_uses_speed_and_intensity() {
let registry = PatternRegistry::new();
let project = sample_project();
let scene = registry
.scene_from_preset_id(&project, "mapping_walk_test")
.expect("preset must exist");
assert_eq!(scene.pattern_id, "walking_pixel");
assert_eq!(
scene.parameters.get("speed"),
Some(&SceneParameterValue::Scalar(1.0))
);
}
#[test]
fn preview_render_produces_hex_output() {
let registry = PatternRegistry::new();
let project = sample_project();
let scene = registry.initial_scene(&project);
let preview = registry.render_preview(&scene, 0, 18, 450);
assert_eq!(preview.sample_led_hex.len(), 6);
assert!(preview.representative_color_hex.starts_with('#'));
}
}

View File

@@ -0,0 +1,801 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{PatternRegistry, SceneRuntime},
};
use infinity_config::{PanelPosition, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
pub const RUNTIME_STATE_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredPreset {
pub preset_id: String,
pub scene: SceneRuntime,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub source: CatalogSource,
pub updated_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoredGroup {
pub group_id: String,
pub tags: Vec<String>,
pub members: Vec<PanelTarget>,
pub source: CatalogSource,
pub updated_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredCreativeSnapshot {
pub snapshot_id: String,
pub label: Option<String>,
pub scene: SceneRuntime,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
pub source_preset_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PersistedGlobalState {
pub blackout: bool,
pub master_brightness: f32,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
impl Default for PersistedGlobalState {
fn default() -> Self {
Self {
blackout: false,
master_brightness: 0.20,
transition_duration_ms: 150,
transition_style: SceneTransitionStyle::Crossfade,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PersistedRuntimeState {
pub active_scene: Option<SceneRuntime>,
pub global: PersistedGlobalState,
pub user_presets: Vec<StoredPreset>,
pub user_groups: Vec<StoredGroup>,
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct RuntimeStateEnvelope {
schema_version: u16,
saved_at_unix_ms: u64,
runtime: PersistedRuntimeState,
}
#[derive(Debug, Clone)]
pub struct RuntimeStateStorage {
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeStateLoadWarning {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeStateLoadResult {
pub runtime: PersistedRuntimeState,
pub loaded_from_disk: bool,
pub warnings: Vec<RuntimeStateLoadWarning>,
}
#[derive(Debug, thiserror::Error)]
pub enum ShowStoreError {
#[error("runtime state I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("runtime state parse failed: {0}")]
Parse(#[from] serde_json::Error),
#[error("{0}")]
Validation(String),
}
#[derive(Debug, Clone, Default)]
pub struct ShowStore {
presets: Vec<StoredPreset>,
groups: Vec<StoredGroup>,
creative_snapshots: Vec<StoredCreativeSnapshot>,
}
impl RuntimeStateStorage {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<PersistedRuntimeState, ShowStoreError> {
if !self.path.exists() {
return Ok(PersistedRuntimeState::default());
}
let raw = fs::read_to_string(&self.path)?;
parse_runtime_state(&raw, &self.path)
}
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
if !self.path.exists() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: Vec::new(),
};
}
let raw = match fs::read_to_string(&self.path) {
Ok(raw) => raw,
Err(error) => {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
};
}
};
if raw.trim().is_empty() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_empty",
format!(
"runtime state at {} was empty and was reset to defaults",
self.path.display()
),
)],
};
}
match parse_runtime_state(&raw, &self.path) {
Ok(runtime) => RuntimeStateLoadResult {
runtime,
loaded_from_disk: true,
warnings: Vec::new(),
},
Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_parse_failed",
format!(
"runtime state at {} could not be parsed and was reset to defaults: {error}",
self.path.display()
),
)],
},
Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_schema_unsupported",
format!("{message}; runtime state was reset to defaults"),
)],
},
Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
},
}
}
pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let envelope = RuntimeStateEnvelope {
schema_version: RUNTIME_STATE_SCHEMA_VERSION,
saved_at_unix_ms: now_unix_ms(),
runtime: runtime.clone(),
};
let payload = serde_json::to_string_pretty(&envelope)?;
fs::write(&self.path, payload)?;
Ok(())
}
}
impl RuntimeStateLoadWarning {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
fn parse_runtime_state(raw: &str, path: &Path) -> Result<PersistedRuntimeState, ShowStoreError> {
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(raw)?;
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
return Err(ShowStoreError::Validation(format!(
"unsupported runtime state schema version {} at {}",
envelope.schema_version,
path.display()
)));
}
Ok(envelope.runtime)
}
impl ShowStore {
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
let presets = project
.presets
.iter()
.map(|preset| StoredPreset {
preset_id: preset.preset_id.clone(),
scene: registry.scene_from_preset_config(preset),
transition_duration_ms: preset.transition_ms,
transition_style: crate::scene::transition_style_from_duration(
preset.transition_ms,
),
source: CatalogSource::BuiltIn,
updated_at_unix_ms: None,
})
.collect::<Vec<_>>();
let groups = project
.topology
.groups
.iter()
.map(|group| StoredGroup {
group_id: group.group_id.clone(),
tags: group.tags.clone(),
members: group
.members
.iter()
.map(|member| PanelTarget {
node_id: member.node_id.clone(),
panel_position: member.panel_position.clone(),
})
.collect(),
source: CatalogSource::BuiltIn,
updated_at_unix_ms: None,
})
.collect::<Vec<_>>();
Self {
presets,
groups,
creative_snapshots: Vec::new(),
}
}
pub fn apply_persisted(&mut self, runtime: PersistedRuntimeState) {
for preset in runtime.user_presets {
replace_or_append_by(&mut self.presets, preset, |left, right| {
left.preset_id == right.preset_id
});
}
for group in runtime.user_groups {
replace_or_append_by(&mut self.groups, group, |left, right| {
left.group_id == right.group_id
});
}
self.creative_snapshots = runtime.creative_snapshots;
}
pub fn catalog(&self, registry: &PatternRegistry) -> CatalogSnapshot {
CatalogSnapshot {
patterns: registry.pattern_definitions(),
presets: self
.presets
.iter()
.map(|preset| PresetSummary {
preset_id: preset.preset_id.clone(),
pattern_id: preset.scene.pattern_id.clone(),
target_group: preset.scene.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: preset.transition_style,
source: preset.source,
})
.collect(),
groups: self
.groups
.iter()
.map(|group| GroupSummary {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: group.source,
})
.collect(),
creative_snapshots: self
.creative_snapshots
.iter()
.map(|snapshot| CreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.scene.pattern_id.clone(),
target_group: snapshot.scene.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: snapshot.transition_style,
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
}
pub fn initial_scene(&self, registry: &PatternRegistry) -> SceneRuntime {
self.presets
.first()
.map(|preset| preset.scene.clone())
.unwrap_or_else(|| {
registry.scene_for_pattern(
"solid_color",
Some("bootstrap-solid-color".to_string()),
None,
1,
vec!["#ffffff".to_string()],
false,
)
})
}
pub fn available_patterns(&self, registry: &PatternRegistry) -> Vec<String> {
registry
.pattern_definitions()
.into_iter()
.map(|pattern| pattern.pattern_id)
.collect()
}
pub fn scene_from_preset_id(&self, preset_id: &str) -> Option<SceneRuntime> {
self.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| preset.scene.clone())
}
pub fn transition_for_preset(&self, preset_id: &str) -> Option<(u32, SceneTransitionStyle)> {
self.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
}
pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option<StoredCreativeSnapshot> {
self.creative_snapshots
.iter()
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
.cloned()
}
pub fn has_group(&self, group_id: &str) -> bool {
self.groups.iter().any(|group| group.group_id == group_id)
}
pub fn group_members_map(&self) -> BTreeMap<String, BTreeSet<String>> {
self.groups
.iter()
.map(|group| {
let members = group
.members
.iter()
.map(|member| {
format!(
"{}:{}",
member.node_id,
panel_position_key(&member.panel_position)
)
})
.collect();
(group.group_id.clone(), members)
})
.collect()
}
pub fn save_preset_from_scene(
&mut self,
preset_id: &str,
scene: &SceneRuntime,
transition_duration_ms: u32,
transition_style: SceneTransitionStyle,
overwrite: bool,
) -> Result<(), HostCommandError> {
if preset_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_preset_id",
"preset_id must not be empty",
));
}
if let Some(existing) = self
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
{
if !overwrite {
return Err(HostCommandError::new(
"preset_exists",
format!("preset '{preset_id}' already exists"),
));
}
if existing.source == CatalogSource::BuiltIn {
// Overwriting a built-in preset becomes a runtime overlay with the same id.
}
}
let preset = StoredPreset {
preset_id: preset_id.to_string(),
scene: scene.clone(),
transition_duration_ms,
transition_style,
source: CatalogSource::RuntimeUser,
updated_at_unix_ms: Some(now_unix_ms()),
};
replace_or_append_by(&mut self.presets, preset, |left, right| {
left.preset_id == right.preset_id
});
Ok(())
}
pub fn save_creative_snapshot(
&mut self,
snapshot_id: &str,
label: Option<String>,
scene: &SceneRuntime,
transition_duration_ms: u32,
transition_style: SceneTransitionStyle,
overwrite: bool,
) -> Result<(), HostCommandError> {
if snapshot_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_snapshot_id",
"snapshot_id must not be empty",
));
}
if self
.creative_snapshots
.iter()
.any(|snapshot| snapshot.snapshot_id == snapshot_id)
&& !overwrite
{
return Err(HostCommandError::new(
"snapshot_exists",
format!("creative snapshot '{snapshot_id}' already exists"),
));
}
let snapshot = StoredCreativeSnapshot {
snapshot_id: snapshot_id.to_string(),
label,
scene: scene.clone(),
transition_duration_ms,
transition_style,
saved_at_unix_ms: now_unix_ms(),
source_preset_id: scene.preset_id.clone(),
};
replace_or_append_by(&mut self.creative_snapshots, snapshot, |left, right| {
left.snapshot_id == right.snapshot_id
});
Ok(())
}
pub fn upsert_group(
&mut self,
group_id: &str,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
) -> Result<(), HostCommandError> {
if group_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_group_id",
"group_id must not be empty",
));
}
if members.is_empty() {
return Err(HostCommandError::new(
"invalid_group_members",
"group must contain at least one panel target",
));
}
if self.groups.iter().any(|group| group.group_id == group_id) && !overwrite {
return Err(HostCommandError::new(
"group_exists",
format!("group '{group_id}' already exists"),
));
}
let group = StoredGroup {
group_id: group_id.to_string(),
tags,
members,
source: CatalogSource::RuntimeUser,
updated_at_unix_ms: Some(now_unix_ms()),
};
replace_or_append_by(&mut self.groups, group, |left, right| {
left.group_id == right.group_id
});
Ok(())
}
pub fn persisted_runtime(
&self,
active_scene: &SceneRuntime,
global: PersistedGlobalState,
) -> PersistedRuntimeState {
PersistedRuntimeState {
active_scene: Some(active_scene.clone()),
global,
user_presets: self
.presets
.iter()
.filter(|preset| preset.source == CatalogSource::RuntimeUser)
.cloned()
.collect(),
user_groups: self
.groups
.iter()
.filter(|group| group.source == CatalogSource::RuntimeUser)
.cloned()
.collect(),
creative_snapshots: self.creative_snapshots.clone(),
}
}
}
fn panel_position_key(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn replace_or_append_by<T, F>(items: &mut Vec<T>, item: T, predicate: F)
where
F: Fn(&T, &T) -> bool,
{
if let Some(index) = items.iter().position(|existing| predicate(existing, &item)) {
items[index] = item;
} else {
items.push(item);
}
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn show_store_builds_runtime_catalog() {
let registry = PatternRegistry::new();
let store = ShowStore::from_project(&sample_project(), &registry);
let catalog = store.catalog(&registry);
assert!(catalog
.presets
.iter()
.any(|preset| preset.preset_id == "ocean_gradient"));
assert!(catalog
.groups
.iter()
.any(|group| group.group_id == "top_panels"));
}
#[test]
fn runtime_presets_and_snapshots_can_be_saved() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"gradient",
None,
Some("top_panels".to_string()),
77,
vec!["#112233".to_string(), "#445566".to_string()],
false,
);
store
.save_preset_from_scene(
"user_gradient",
&scene,
420,
SceneTransitionStyle::Crossfade,
false,
)
.expect("preset save should succeed");
store
.save_creative_snapshot(
"variant_a",
Some("Variant A".to_string()),
&scene,
240,
SceneTransitionStyle::Chase,
false,
)
.expect("snapshot save should succeed");
assert!(store.scene_from_preset_id("user_gradient").is_some());
assert!(store.recall_creative_snapshot("variant_a").is_some());
}
#[test]
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"noise",
None,
Some("bottom_panels".to_string()),
99,
vec!["#AA8844".to_string()],
false,
);
store
.save_preset_from_scene(
"roundtrip_noise",
&scene,
220,
SceneTransitionStyle::Chase,
false,
)
.expect("preset save should succeed");
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
let runtime = store.persisted_runtime(
&scene,
PersistedGlobalState {
blackout: false,
master_brightness: 0.42,
transition_duration_ms: 220,
transition_style: SceneTransitionStyle::Chase,
},
);
storage.save(&runtime).expect("save should work");
let loaded = storage.load().expect("load should work");
assert_eq!(loaded.active_scene, Some(scene));
assert!(loaded
.user_presets
.iter()
.any(|preset| preset.preset_id == "roundtrip_noise"));
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_empty_file() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_empty_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "").expect("empty file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_empty");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_invalid_json() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_invalid_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "{ definitely not json").expect("invalid file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_unsupported_schema() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_schema_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(
&path,
r#"{
"schema_version": 99,
"saved_at_unix_ms": 1,
"runtime": {
"active_scene": null,
"global": {
"blackout": false,
"master_brightness": 0.2,
"transition_duration_ms": 150,
"transition_style": "crossfade"
},
"user_presets": [],
"user_groups": [],
"creative_snapshots": []
}
}"#,
)
.expect("schema file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported");
let _ = std::fs::remove_file(path);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "infinity_host_api"
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_host = { path = "../infinity_host" }

View File

@@ -0,0 +1,989 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
TestPatternKind,
};
use serde::{Deserialize, Serialize};
pub const API_VERSION: &str = "v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSnapshotResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse {
pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>,
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPresetListResponse {
pub api_version: &'static str,
pub presets: Vec<ApiPresetSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGroupListResponse {
pub api_version: &'static str,
pub groups: Vec<ApiGroupSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCommandRequest {
#[serde(default)]
pub request_id: Option<String>,
pub command: ApiCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCommandResponse {
pub api_version: &'static str,
pub accepted: bool,
pub request_id: Option<String>,
pub generated_at_millis: u64,
pub command_type: String,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorResponse {
pub api_version: &'static str,
pub error: ApiErrorBody,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorBody {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateSnapshot {
pub system: ApiSystemInfo,
pub global: ApiGlobalState,
pub engine: ApiEngineState,
pub active_scene: ApiActiveScene,
pub nodes: Vec<ApiNodeStatus>,
pub panels: Vec<ApiPanelStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewSnapshot {
pub generated_at_millis: u64,
pub panels: Vec<ApiPreviewPanel>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStreamEnvelope {
pub api_version: &'static str,
pub sequence: u64,
pub generated_at_millis: u64,
pub message: ApiStreamMessage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ApiStreamMessage {
Snapshot(ApiStateSnapshot),
Preview(ApiPreviewSnapshot),
Event(ApiEventNotice),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice {
pub kind: ApiEventKind,
pub code: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSystemInfo {
pub project_name: String,
pub schema_version: u32,
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGlobalState {
pub blackout: bool,
pub master_brightness: f32,
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEngineState {
pub logic_hz: u16,
pub frame_hz: u16,
pub preview_hz: u16,
pub uptime_ms: u64,
pub frame_index: u64,
pub dropped_frames: u64,
pub active_transition: Option<ApiTransitionState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiTransitionState {
pub style: ApiTransitionStyle,
pub from_pattern_id: String,
pub to_pattern_id: String,
pub duration_ms: u32,
pub progress: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiTransitionStyle {
Snap,
Crossfade,
Chase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiActiveScene {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: Vec<ApiSceneParameter>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSceneParameter {
pub key: String,
pub label: String,
pub kind: ApiParameterKind,
pub value: ApiParameterValue,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPatternCatalogEntry {
pub pattern_id: String,
pub display_name: String,
pub description: String,
pub parameters: Vec<ApiPatternParameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPatternParameter {
pub key: String,
pub label: String,
pub kind: ApiParameterKind,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
pub default_value: ApiParameterValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPresetSummary {
pub preset_id: String,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiNodeStatus {
pub node_id: String,
pub display_name: String,
pub reserved_ip: Option<String>,
pub connection: ApiConnectionState,
pub last_contact_ms: u64,
pub error_status: Option<String>,
pub panel_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPanelStatus {
pub node_id: String,
pub panel_position: ApiPanelPosition,
pub physical_output_name: String,
pub driver_reference: String,
pub led_count: u16,
pub direction: ApiLedDirection,
pub color_order: ApiColorOrder,
pub enabled: bool,
pub validation_state: ApiValidationState,
pub connection: ApiConnectionState,
pub last_test_trigger_ms: Option<u64>,
pub error_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewPanel {
pub node_id: String,
pub panel_position: ApiPanelPosition,
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
pub source: ApiPreviewSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiPanelPosition {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiConnectionState {
Online,
Degraded,
Offline,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiPreviewSource {
Scene,
Transition,
PanelTest,
Blackout,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiParameterKind {
Scalar,
Toggle,
Text,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiCatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiLedDirection {
Forward,
Reverse,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiColorOrder {
Rgb,
Rbg,
Grb,
Gbr,
Brg,
Bgr,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiValidationState {
PendingHardwareValidation,
Validated,
Retired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum ApiParameterValue {
Scalar(f32),
Toggle(bool),
Text(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ApiCommand {
SetBlackout {
enabled: bool,
},
SetMasterBrightness {
value: f32,
},
SelectPattern {
pattern_id: String,
},
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: ApiParameterValue,
},
SetTransitionDurationMs {
duration_ms: u32,
},
SetTransitionStyle {
style: ApiTransitionStyle,
},
TriggerPanelTest {
node_id: String,
panel_position: ApiPanelPosition,
pattern: ApiTestPattern,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<ApiPanelRef>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiPanelRef {
pub node_id: String,
pub panel_position: ApiPanelPosition,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiTestPattern {
WalkingPixel106,
}
impl ApiSnapshotResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
let state = ApiStateSnapshot::from_snapshot(snapshot);
let preview = ApiPreviewSnapshot::from_snapshot(snapshot);
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state,
preview,
}
}
}
impl ApiStateResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state: ApiStateSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiPreviewResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
preview: ApiPreviewSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
patterns: snapshot
.catalog
.patterns
.iter()
.map(|pattern| ApiPatternCatalogEntry {
pattern_id: pattern.pattern_id.clone(),
display_name: pattern.display_name.clone(),
description: pattern.description.clone(),
parameters: pattern
.parameters
.iter()
.map(|parameter| ApiPatternParameter {
key: parameter.key.clone(),
label: parameter.label.clone(),
kind: map_parameter_kind(parameter.kind),
min_scalar: parameter.min_scalar,
max_scalar: parameter.max_scalar,
step: parameter.step,
default_value: map_parameter_value(&parameter.default_value),
})
.collect(),
})
.collect(),
presets: snapshot
.catalog
.presets
.iter()
.map(|preset| ApiPresetSummary {
preset_id: preset.preset_id.clone(),
pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: map_transition_style(preset.transition_style),
source: map_catalog_source(preset.source),
})
.collect(),
groups: snapshot
.catalog
.groups
.iter()
.map(|group| ApiGroupSummary {
group_id: group.group_id.clone(),
member_count: group.member_count,
tags: group.tags.clone(),
source: map_catalog_source(group.source),
})
.collect(),
creative_snapshots: snapshot
.catalog
.creative_snapshots
.iter()
.map(|snapshot| ApiCreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.pattern_id.clone(),
target_group: snapshot.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: map_transition_style(snapshot.transition_style),
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
}
}
impl ApiPresetListResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
presets: ApiCatalogResponse::from_snapshot(snapshot).presets,
}
}
}
impl ApiGroupListResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
groups: ApiCatalogResponse::from_snapshot(snapshot).groups,
}
}
}
impl ApiStateSnapshot {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
system: ApiSystemInfo {
project_name: snapshot.system.project_name.clone(),
schema_version: snapshot.system.schema_version,
topology_label: snapshot.system.topology_label.clone(),
},
global: ApiGlobalState {
blackout: snapshot.global.blackout,
master_brightness: snapshot.global.master_brightness,
selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms,
transition_style: map_transition_style(snapshot.global.transition_style),
},
engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz,
frame_hz: snapshot.engine.frame_hz,
preview_hz: snapshot.engine.preview_hz,
uptime_ms: snapshot.engine.uptime_ms,
frame_index: snapshot.engine.frame_index,
dropped_frames: snapshot.engine.dropped_frames,
active_transition: snapshot
.engine
.active_transition
.as_ref()
.map(|transition| ApiTransitionState {
style: map_transition_style(transition.style),
from_pattern_id: transition.from_pattern_id.clone(),
to_pattern_id: transition.to_pattern_id.clone(),
duration_ms: transition.duration_ms,
progress: transition.progress,
}),
},
active_scene: ApiActiveScene {
preset_id: snapshot.active_scene.preset_id.clone(),
pattern_id: snapshot.active_scene.pattern_id.clone(),
seed: snapshot.active_scene.seed,
palette: snapshot.active_scene.palette.clone(),
parameters: snapshot
.active_scene
.parameters
.iter()
.map(|parameter| ApiSceneParameter {
key: parameter.key.clone(),
label: parameter.label.clone(),
kind: map_parameter_kind(parameter.kind),
value: map_parameter_value(&parameter.value),
min_scalar: parameter.min_scalar,
max_scalar: parameter.max_scalar,
step: parameter.step,
})
.collect(),
target_group: snapshot.active_scene.target_group.clone(),
blackout: snapshot.active_scene.blackout,
},
nodes: snapshot
.nodes
.iter()
.map(|node| ApiNodeStatus {
node_id: node.node_id.clone(),
display_name: node.display_name.clone(),
reserved_ip: node.reserved_ip.clone(),
connection: map_connection_state(node.connection),
last_contact_ms: node.last_contact_ms,
error_status: node.error_status.clone(),
panel_count: node.panel_count,
})
.collect(),
panels: snapshot
.panels
.iter()
.map(|panel| ApiPanelStatus {
node_id: panel.target.node_id.clone(),
panel_position: map_panel_position(&panel.target.panel_position),
physical_output_name: panel.physical_output_name.clone(),
driver_reference: panel.driver_reference.clone(),
led_count: panel.led_count,
direction: map_led_direction(panel.direction.clone()),
color_order: map_color_order(panel.color_order.clone()),
enabled: panel.enabled,
validation_state: map_validation_state(panel.validation_state.clone()),
connection: map_connection_state(panel.connection),
last_test_trigger_ms: panel.last_test_trigger_ms,
error_status: panel.error_status.clone(),
})
.collect(),
}
}
}
impl ApiPreviewSnapshot {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
generated_at_millis: snapshot.generated_at_millis,
panels: snapshot
.preview
.panels
.iter()
.map(|panel| ApiPreviewPanel {
node_id: panel.target.node_id.clone(),
panel_position: map_panel_position(&panel.target.panel_position),
representative_color_hex: panel.representative_color_hex.clone(),
sample_led_hex: panel.sample_led_hex.clone(),
energy_percent: panel.energy_percent,
source: map_preview_source(panel.preview_source),
})
.collect(),
}
}
}
impl ApiCommandRequest {
pub fn into_host_command(self) -> Result<HostCommand, String> {
match self.command {
ApiCommand::SetBlackout { enabled } => Ok(HostCommand::SetBlackout(enabled)),
ApiCommand::SetMasterBrightness { value } => {
Ok(HostCommand::SetMasterBrightness(value))
}
ApiCommand::SelectPattern { pattern_id } => Ok(HostCommand::SelectPattern(pattern_id)),
ApiCommand::RecallPreset { preset_id } => Ok(HostCommand::RecallPreset { preset_id }),
ApiCommand::SelectGroup { group_id } => Ok(HostCommand::SelectGroup { group_id }),
ApiCommand::SetSceneParameter { key, value } => Ok(HostCommand::SetSceneParameter {
key,
value: map_command_parameter_value(value),
}),
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::SetTransitionStyle { style } => Ok(HostCommand::SetTransitionStyle(
map_command_transition_style(style),
)),
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
pattern,
} => Ok(HostCommand::TriggerPanelTest {
target: infinity_host::PanelTarget {
node_id,
panel_position: map_command_panel_position(panel_position),
},
pattern: match pattern {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106,
},
}),
ApiCommand::SavePreset {
preset_id,
overwrite,
} => Ok(HostCommand::SavePreset {
preset_id,
overwrite,
}),
ApiCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => Ok(HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
}),
ApiCommand::RecallCreativeSnapshot { snapshot_id } => {
Ok(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ApiCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(HostCommand::UpsertGroup {
group_id,
tags,
members: members
.into_iter()
.map(|member| infinity_host::PanelTarget {
node_id: member.node_id,
panel_position: map_command_panel_position(member.panel_position),
})
.collect(),
overwrite,
}),
}
}
pub fn summary(&self) -> String {
self.command.summary()
}
}
fn map_panel_position(position: &PanelPosition) -> ApiPanelPosition {
match position {
PanelPosition::Top => ApiPanelPosition::Top,
PanelPosition::Middle => ApiPanelPosition::Middle,
PanelPosition::Bottom => ApiPanelPosition::Bottom,
}
}
fn map_command_panel_position(position: ApiPanelPosition) -> PanelPosition {
match position {
ApiPanelPosition::Top => PanelPosition::Top,
ApiPanelPosition::Middle => PanelPosition::Middle,
ApiPanelPosition::Bottom => PanelPosition::Bottom,
}
}
fn map_connection_state(state: NodeConnectionState) -> ApiConnectionState {
match state {
NodeConnectionState::Online => ApiConnectionState::Online,
NodeConnectionState::Degraded => ApiConnectionState::Degraded,
NodeConnectionState::Offline => ApiConnectionState::Offline,
}
}
fn map_led_direction(direction: LedDirection) -> ApiLedDirection {
match direction {
LedDirection::Forward => ApiLedDirection::Forward,
LedDirection::Reverse => ApiLedDirection::Reverse,
}
}
fn map_color_order(color_order: ColorOrder) -> ApiColorOrder {
match color_order {
ColorOrder::Rgb => ApiColorOrder::Rgb,
ColorOrder::Rbg => ApiColorOrder::Rbg,
ColorOrder::Grb => ApiColorOrder::Grb,
ColorOrder::Gbr => ApiColorOrder::Gbr,
ColorOrder::Brg => ApiColorOrder::Brg,
ColorOrder::Bgr => ApiColorOrder::Bgr,
}
}
fn map_validation_state(state: ValidationState) -> ApiValidationState {
match state {
ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation,
ValidationState::Validated => ApiValidationState::Validated,
ValidationState::Retired => ApiValidationState::Retired,
}
}
fn map_preview_source(source: PreviewSource) -> ApiPreviewSource {
match source {
PreviewSource::Scene => ApiPreviewSource::Scene,
PreviewSource::Transition => ApiPreviewSource::Transition,
PreviewSource::PanelTest => ApiPreviewSource::PanelTest,
PreviewSource::Blackout => ApiPreviewSource::Blackout,
}
}
fn map_transition_style(style: SceneTransitionStyle) -> ApiTransitionStyle {
match style {
SceneTransitionStyle::Snap => ApiTransitionStyle::Snap,
SceneTransitionStyle::Crossfade => ApiTransitionStyle::Crossfade,
SceneTransitionStyle::Chase => ApiTransitionStyle::Chase,
}
}
fn map_command_transition_style(style: ApiTransitionStyle) -> SceneTransitionStyle {
match style {
ApiTransitionStyle::Snap => SceneTransitionStyle::Snap,
ApiTransitionStyle::Crossfade => SceneTransitionStyle::Crossfade,
ApiTransitionStyle::Chase => SceneTransitionStyle::Chase,
}
}
fn map_catalog_source(source: CatalogSource) -> ApiCatalogSource {
match source {
CatalogSource::BuiltIn => ApiCatalogSource::BuiltIn,
CatalogSource::RuntimeUser => ApiCatalogSource::RuntimeUser,
}
}
fn map_event_kind(kind: StatusEventKind) -> ApiEventKind {
match kind {
StatusEventKind::Info => ApiEventKind::Info,
StatusEventKind::Warning => ApiEventKind::Warning,
StatusEventKind::Error => ApiEventKind::Error,
}
}
fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar,
SceneParameterKind::Toggle => ApiParameterKind::Toggle,
SceneParameterKind::Text => ApiParameterKind::Text,
}
}
fn map_parameter_value(value: &SceneParameterValue) -> ApiParameterValue {
match value {
SceneParameterValue::Scalar(value) => ApiParameterValue::Scalar(*value),
SceneParameterValue::Toggle(value) => ApiParameterValue::Toggle(*value),
SceneParameterValue::Text(value) => ApiParameterValue::Text(value.clone()),
}
}
fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue {
match value {
ApiParameterValue::Scalar(value) => SceneParameterValue::Scalar(value),
ApiParameterValue::Toggle(value) => SceneParameterValue::Toggle(value),
ApiParameterValue::Text(value) => SceneParameterValue::Text(value),
}
}
impl ApiCommand {
pub fn kind_label(&self) -> &'static str {
match self {
Self::SetBlackout { .. } => "set_blackout",
Self::SetMasterBrightness { .. } => "set_master_brightness",
Self::SelectPattern { .. } => "select_pattern",
Self::RecallPreset { .. } => "recall_preset",
Self::SelectGroup { .. } => "select_group",
Self::SetSceneParameter { .. } => "set_scene_parameter",
Self::SetTransitionDurationMs { .. } => "set_transition_duration_ms",
Self::SetTransitionStyle { .. } => "set_transition_style",
Self::TriggerPanelTest { .. } => "trigger_panel_test",
Self::SavePreset { .. } => "save_preset",
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
Self::UpsertGroup { .. } => "upsert_group",
}
}
pub fn summary(&self) -> String {
match self {
Self::SetBlackout { enabled } => {
if *enabled {
"blackout enabled".to_string()
} else {
"blackout released".to_string()
}
}
Self::SetMasterBrightness { value } => {
format!(
"master brightness set to {:.0}%",
value.clamp(0.0, 1.0) * 100.0
)
}
Self::SelectPattern { pattern_id } => format!("pattern selected: {pattern_id}"),
Self::RecallPreset { preset_id } => format!("preset recalled: {preset_id}"),
Self::SelectGroup { group_id } => format!(
"group selected: {}",
group_id.as_deref().unwrap_or("all_panels")
),
Self::SetSceneParameter { key, .. } => format!("scene parameter updated: {key}"),
Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms")
}
Self::SetTransitionStyle { style } => {
format!("transition style set to {}", style.label())
}
Self::TriggerPanelTest {
node_id,
panel_position,
pattern,
} => format!(
"panel test {} on {}:{}",
pattern.label(),
node_id,
panel_position.label()
),
Self::SavePreset {
preset_id,
overwrite,
} => {
if *overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
}
}
Self::SaveCreativeSnapshot {
snapshot_id,
overwrite,
..
} => {
if *overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
}
}
Self::RecallCreativeSnapshot { snapshot_id } => {
format!("creative snapshot recalled: {snapshot_id}")
}
Self::UpsertGroup {
group_id,
overwrite,
..
} => {
if *overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
}
}
}
}
}
impl ApiPanelPosition {
pub fn label(self) -> &'static str {
match self {
Self::Top => "top",
Self::Middle => "middle",
Self::Bottom => "bottom",
}
}
}
impl ApiTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl ApiTestPattern {
pub fn label(self) -> &'static str {
match self {
Self::WalkingPixel106 => "walking_pixel_106",
}
}
}
impl ApiErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
api_version: API_VERSION,
error: ApiErrorBody {
code: code.into(),
message: message.into(),
},
}
}
}
impl From<infinity_host::StatusEvent> for ApiEventNotice {
fn from(event: infinity_host::StatusEvent) -> Self {
Self {
kind: map_event_kind(event.kind),
code: event.code,
message: event.message,
}
}
}

View File

@@ -0,0 +1,6 @@
mod dto;
mod server;
mod websocket;
pub use dto::*;
pub use server::*;

View File

@@ -0,0 +1,39 @@
use clap::Parser;
use infinity_config::{load_project_from_path, ProjectConfig};
use infinity_host::{HostApiPort, SimulationHostService};
use infinity_host_api::HostApiServer;
use std::{path::PathBuf, sync::Arc, thread, time::Duration};
#[derive(Debug, Parser)]
#[command(author, version, about = "Infinity Vis host API server")]
struct Cli {
#[arg(long, default_value = "config/project.example.toml")]
config: PathBuf,
#[arg(long, default_value = "127.0.0.1:9001")]
bind: String,
#[arg(long, default_value = "data/runtime_state.json")]
runtime_state: PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let project = load_project(&cli.config)?;
let service: Arc<dyn HostApiPort> =
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
let server = HostApiServer::bind(&cli.bind, service)?;
println!(
"Infinity Vis host API listening on http://{}",
server.local_addr()
);
println!("Web UI available at http://{}/", server.local_addr());
println!("Runtime state persistence: {}", cli.runtime_state.display());
loop {
thread::sleep(Duration::from_secs(60));
}
}
fn load_project(path: &std::path::Path) -> Result<ProjectConfig, Box<dyn std::error::Error>> {
Ok(load_project_from_path(path)?)
}

View File

@@ -0,0 +1,467 @@
use crate::dto::{
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
};
use crate::websocket::{websocket_accept_value, write_text_frame};
use infinity_host::HostApiPort;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct HostApiServer {
local_addr: SocketAddr,
shutdown: Arc<AtomicBool>,
accept_thread: Option<JoinHandle<()>>,
}
#[derive(Debug)]
struct ApiRequestError {
status: u16,
code: String,
message: String,
}
impl HostApiServer {
pub fn bind(bind: &str, service: Arc<dyn HostApiPort>) -> io::Result<Self> {
let listener = TcpListener::bind(bind)?;
listener.set_nonblocking(true)?;
let local_addr = listener.local_addr()?;
let shutdown = Arc::new(AtomicBool::new(false));
let thread_shutdown = Arc::clone(&shutdown);
let accept_thread = thread::spawn(move || accept_loop(listener, service, thread_shutdown));
Ok(Self {
local_addr,
shutdown,
accept_thread: Some(accept_thread),
})
}
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
}
pub fn shutdown(mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.accept_thread.take() {
let _ = handle.join();
}
}
}
impl Drop for HostApiServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.accept_thread.take() {
let _ = handle.join();
}
}
}
fn accept_loop(listener: TcpListener, service: Arc<dyn HostApiPort>, shutdown: Arc<AtomicBool>) {
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let service = Arc::clone(&service);
thread::spawn(move || {
let _ = handle_connection(stream, service);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
}
fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let request = read_request(&mut stream)?;
if request.path == "/api/v1/stream" && request.is_websocket() {
return handle_websocket(stream, request, service);
}
match (request.method.as_str(), request.path.as_str()) {
("GET", "/api/v1/snapshot") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiSnapshotResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/state") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiStateResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/preview") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiPreviewResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiCatalogResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/presets") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiPresetListResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/groups") => {
let snapshot = service.snapshot();
respond_json(
&mut stream,
200,
&ApiGroupListResponse::from_snapshot(&snapshot),
)
}
("POST", "/api/v1/command") => match handle_command_post(&mut stream, request, service) {
Ok(()) => Ok(()),
Err(error) => respond_error(&mut stream, error.status, error.code, error.message),
},
("GET", "/") => respond_text(
&mut stream,
200,
"text/html; charset=utf-8",
include_str!("../../../web/v1/index.html"),
),
("GET", "/index.html") => respond_text(
&mut stream,
200,
"text/html; charset=utf-8",
include_str!("../../../web/v1/index.html"),
),
("GET", "/app.js") => respond_text(
&mut stream,
200,
"application/javascript; charset=utf-8",
include_str!("../../../web/v1/app.js"),
),
("GET", "/styles.css") => respond_text(
&mut stream,
200,
"text/css; charset=utf-8",
include_str!("../../../web/v1/styles.css"),
),
_ => respond_text(
&mut stream,
404,
"application/json; charset=utf-8",
&serde_json::to_string_pretty(&ApiErrorResponse::new(
"not_found",
format!(
"no route registered for {} {}",
request.method, request.path
),
))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
),
}
}
fn handle_command_post(
stream: &mut TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> Result<(), ApiRequestError> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body).map_err(|error| {
ApiRequestError {
status: 400,
code: "invalid_request_json".to_string(),
message: format!("command request body could not be parsed: {error}"),
}
})?;
let request_id = parsed.request_id.clone();
let command_type = parsed.command.kind_label().to_string();
let command = parsed
.into_host_command()
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_command".to_string(),
message: error,
})?;
let outcome = service
.send_command(command)
.map_err(|error| ApiRequestError {
status: 400,
code: error.code,
message: error.message,
})?;
respond_json(
stream,
200,
&ApiCommandResponse {
api_version: API_VERSION,
accepted: true,
request_id,
generated_at_millis: outcome.generated_at_millis,
command_type,
summary: outcome.summary,
},
)
.map_err(|error| ApiRequestError {
status: 500,
code: "response_write_failed".to_string(),
message: error.to_string(),
})
}
fn handle_websocket(
mut stream: TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
let Some(key) = request.header("sec-websocket-key") else {
return respond_error(
&mut stream,
400,
"missing_websocket_key",
"websocket upgrade requires sec-websocket-key",
);
};
let accept = websocket_accept_value(key);
let response = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n"
);
stream.write_all(response.as_bytes())?;
let mut sequence = 1u64;
let mut last_event_millis = None::<u64>;
let mut last_event_signatures = Vec::<(Option<String>, String)>::new();
loop {
let snapshot = service.snapshot();
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
let mut new_events = snapshot
.recent_events
.iter()
.filter(|event| match last_event_millis {
None => true,
Some(last_millis) if event.at_millis > last_millis => true,
Some(last_millis) if event.at_millis == last_millis => !last_event_signatures
.iter()
.any(|signature| signature.0 == event.code && signature.1 == event.message),
Some(_) => false,
})
.cloned()
.collect::<Vec<_>>();
new_events.sort_by_key(|event| event.at_millis);
for event in new_events {
let event_millis = event.at_millis;
let current_signature = (event.code.clone(), event.message.clone());
send_stream_message(
&mut stream,
sequence,
event_millis,
ApiStreamMessage::Event(event.into()),
)?;
sequence += 1;
match last_event_millis {
Some(last_millis) if last_millis == event_millis => {
last_event_signatures.push(current_signature);
}
_ => {
last_event_millis = Some(event_millis);
last_event_signatures = vec![current_signature];
}
}
}
thread::sleep(Duration::from_millis(250));
}
}
fn send_stream_message(
stream: &mut TcpStream,
sequence: u64,
generated_at_millis: u64,
message: ApiStreamMessage,
) -> io::Result<()> {
let payload = serde_json::to_string(&ApiStreamEnvelope {
api_version: API_VERSION,
sequence,
generated_at_millis,
message,
})
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
write_text_frame(stream, &payload)
}
fn respond_json<T: serde::Serialize>(
stream: &mut TcpStream,
status: u16,
body: &T,
) -> io::Result<()> {
let payload = serde_json::to_string_pretty(body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
respond_text(stream, status, "application/json; charset=utf-8", &payload)
}
fn respond_error(
stream: &mut TcpStream,
status: u16,
code: impl Into<String>,
message: impl Into<String>,
) -> io::Result<()> {
respond_json(stream, status, &ApiErrorResponse::new(code, message))
}
fn respond_text(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &str,
) -> io::Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(response.as_bytes())
}
#[derive(Debug)]
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}
impl HttpRequest {
fn header(&self, key: &str) -> Option<&str> {
self.headers
.get(&key.to_ascii_lowercase())
.map(|value| value.as_str())
}
fn is_websocket(&self) -> bool {
self.header("upgrade")
.map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false)
}
}
fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
let mut buffer = Vec::new();
let mut temp = [0u8; 4096];
let mut header_end = None;
let mut expected_len = None;
loop {
let read = stream.read(&mut temp)?;
if read == 0 {
break;
}
buffer.extend_from_slice(&temp[..read]);
if header_end.is_none() {
header_end = find_header_end(&buffer);
if let Some(end) = header_end {
let header_text = String::from_utf8_lossy(&buffer[..end]);
expected_len = parse_content_length(&header_text);
if expected_len == Some(0) || expected_len.is_none() {
break;
}
}
}
if let (Some(end), Some(content_len)) = (header_end, expected_len) {
if buffer.len() >= end + 4 + content_len {
break;
}
}
}
let header_end = header_end
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing header end"))?;
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.lines();
let request_line = lines
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
.to_string();
let path = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
.split('?')
.next()
.unwrap_or("/")
.to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
let body = buffer.get(body_start..).unwrap_or_default().to_vec();
Ok(HttpRequest {
method,
path,
headers,
body,
})
}
fn parse_content_length(header_text: &str) -> Option<usize> {
header_text.lines().find_map(|line| {
line.split_once(':').and_then(|(key, value)| {
if key.trim().eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
})
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}

View File

@@ -0,0 +1,148 @@
use std::io::{self, Write};
use std::net::TcpStream;
const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
pub fn websocket_accept_value(key: &str) -> String {
let digest = sha1(format!("{key}{WEBSOCKET_GUID}").as_bytes());
base64_encode(&digest)
}
pub fn write_text_frame(stream: &mut TcpStream, payload: &str) -> io::Result<()> {
let payload = payload.as_bytes();
let mut frame = Vec::with_capacity(payload.len() + 10);
frame.push(0x81);
match payload.len() {
0..=125 => frame.push(payload.len() as u8),
126..=65535 => {
frame.push(126);
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
}
_ => {
frame.push(127);
frame.extend_from_slice(&(payload.len() as u64).to_be_bytes());
}
}
frame.extend_from_slice(payload);
stream.write_all(&frame)
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::new();
let mut index = 0;
while index < bytes.len() {
let first = bytes[index];
let second = if index + 1 < bytes.len() {
bytes[index + 1]
} else {
0
};
let third = if index + 2 < bytes.len() {
bytes[index + 2]
} else {
0
};
encoded.push(TABLE[(first >> 2) as usize] as char);
encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
if index + 1 < bytes.len() {
encoded.push(TABLE[((second & 0b0000_1111) << 2 | (third >> 6)) as usize] as char);
} else {
encoded.push('=');
}
if index + 2 < bytes.len() {
encoded.push(TABLE[(third & 0b0011_1111) as usize] as char);
} else {
encoded.push('=');
}
index += 3;
}
encoded
}
fn sha1(bytes: &[u8]) -> [u8; 20] {
let mut h0: u32 = 0x67452301;
let mut h1: u32 = 0xEFCDAB89;
let mut h2: u32 = 0x98BADCFE;
let mut h3: u32 = 0x10325476;
let mut h4: u32 = 0xC3D2E1F0;
let mut message = bytes.to_vec();
let bit_len = (message.len() as u64) * 8;
message.push(0x80);
while (message.len() % 64) != 56 {
message.push(0x00);
}
message.extend_from_slice(&bit_len.to_be_bytes());
for chunk in message.chunks(64) {
let mut words = [0u32; 80];
for index in 0..16 {
let start = index * 4;
words[index] = u32::from_be_bytes([
chunk[start],
chunk[start + 1],
chunk[start + 2],
chunk[start + 3],
]);
}
for index in 16..80 {
words[index] =
(words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16])
.rotate_left(1);
}
let mut a = h0;
let mut b = h1;
let mut c = h2;
let mut d = h3;
let mut e = h4;
for index in 0..80 {
let (f, k) = match index {
0..=19 => (((b & c) | ((!b) & d)), 0x5A827999),
20..=39 => ((b ^ c ^ d), 0x6ED9EBA1),
40..=59 => (((b & c) | (b & d) | (c & d)), 0x8F1BBCDC),
_ => ((b ^ c ^ d), 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(words[index]);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h0 = h0.wrapping_add(a);
h1 = h1.wrapping_add(b);
h2 = h2.wrapping_add(c);
h3 = h3.wrapping_add(d);
h4 = h4.wrapping_add(e);
}
let mut digest = [0u8; 20];
digest[0..4].copy_from_slice(&h0.to_be_bytes());
digest[4..8].copy_from_slice(&h1.to_be_bytes());
digest[8..12].copy_from_slice(&h2.to_be_bytes());
digest[12..16].copy_from_slice(&h3.to_be_bytes());
digest[16..20].copy_from_slice(&h4.to_be_bytes());
digest
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn websocket_accept_matches_rfc_example() {
let accept = websocket_accept_value("dGhlIHNhbXBsZSBub25jZQ==");
assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
}
}

View File

@@ -0,0 +1,701 @@
use infinity_config::ProjectConfig;
use infinity_host::{HostApiPort, SimulationHostService};
use infinity_host_api::HostApiServer;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(SimulationHostService::new(sample_project()));
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(
SimulationHostService::try_new_with_persistence(sample_project(), path)
.expect("persistent service must initialize"),
);
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
struct HttpResponse {
status_code: u16,
headers: HashMap<String, String>,
body: String,
}
#[test]
fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server();
let html = send_http_request(server.local_addr(), "GET", "/", None);
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
let styles = send_http_request(server.local_addr(), "GET", "/styles.css", None);
assert_eq!(html.status_code, 200);
assert!(html
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(html.body.contains("Preset Capture"));
assert!(html.body.contains("Creative Snapshots"));
assert!(html.body.contains("Event Stream"));
assert!(html.body.contains("Pending Transition"));
assert!(html.body.contains("Trigger Transition"));
assert_eq!(app_js.status_code, 200);
assert!(app_js.body.contains("/api/v1/state"));
assert!(app_js.body.contains("/api/v1/preview"));
assert!(app_js.body.contains("save_preset"));
assert!(app_js.body.contains("save_creative_snapshot"));
assert!(app_js.body.contains("show_control_session_required"));
assert!(app_js.body.contains("trigger_transition"));
assert_eq!(styles.status_code, 200);
assert!(styles
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/css"));
assert!(styles.body.contains(".preview-grid"));
server.shutdown();
}
#[test]
fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
let server = start_server();
let html = send_http_request(server.local_addr(), "GET", "/", None);
let mut stream = open_websocket(server.local_addr());
assert_eq!(html.status_code, 200);
assert!(html.body.contains("Infinity Vis"));
assert!(html.body.contains("connection-pill"));
assert!(html.body.contains("preview-grid"));
let first_frame = read_websocket_text_frame(&mut stream);
let second_frame = read_websocket_text_frame(&mut stream);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(state.status_code, 200);
assert_eq!(state_body["api_version"], "v1");
assert!(state_body.get("state").is_some());
assert!(state_body.get("preview").is_none());
assert_eq!(
state_body["state"]["nodes"].as_array().map(Vec::len),
Some(6)
);
assert_eq!(preview.status_code, 200);
assert_eq!(preview_body["api_version"], "v1");
assert!(preview_body.get("preview").is_some());
assert!(preview_body.get("state").is_none());
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(snapshot.status_code, 200);
assert_eq!(snapshot_body["api_version"], "v1");
assert!(snapshot_body.get("state").is_some());
assert!(snapshot_body.get("preview").is_some());
server.shutdown();
}
#[test]
fn command_flow_updates_group_parameters_transition_and_blackout() {
let server = start_server();
let responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"top_panels"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"speed","value":{"kind":"scalar","value":2.25}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":320}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"gradient"}}}"#,
),
];
for response in responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"top_panels"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"gradient"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "speed" && parameter["value"]["value"] == 2.25));
let blackout = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
);
let blackout_body: Value = serde_json::from_str(&blackout.body).expect("blackout json");
assert_eq!(blackout.status_code, 200);
assert_eq!(blackout_body["command_type"], "set_blackout");
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
server.shutdown();
}
#[test]
fn presets_and_creative_snapshots_persist_across_restart() {
let runtime_state_path = unique_runtime_state_path("persistence");
let server = start_server_with_runtime_state(&runtime_state_path);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"bottom_panels"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.93}}}}"#,
);
let save_preset = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"user_noise_floor","overwrite":false}}}"#,
);
let save_snapshot = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"variant_floor","label":"Variant Floor","overwrite":false}}}"#,
);
assert_eq!(save_preset.status_code, 200);
assert_eq!(save_snapshot.status_code, 200);
server.shutdown();
let restarted = start_server_with_runtime_state(&runtime_state_path);
let catalog = send_http_request(restarted.local_addr(), "GET", "/api/v1/catalog", None);
let state = send_http_request(restarted.local_addr(), "GET", "/api/v1/state", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "user_noise_floor"
&& preset["source"] == "runtime_user"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"bottom_panels"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.93));
restarted.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn show_control_flows_cover_runtime_group_preset_snapshot_transition_blackout_and_eventfeed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let flow_responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"upsert_group","payload":{"group_id":"focus_pair","tags":["runtime","focus"],"members":[{"node_id":"node-a","panel_position":"top"},{"node_id":"node-a","panel_position":"middle"}],"overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"focus_pair"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":480}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.67}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.81}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":true}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_preset","payload":{"preset_id":"focus_noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"focus_variant","label":"Focus Variant","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"pulse"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"focus_variant"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
),
];
for response in flow_responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"focus_pair"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 480);
assert_eq!(state_body["state"]["global"]["blackout"], true);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"focus_pair"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.81));
assert!(catalog_body["groups"]
.as_array()
.expect("group array")
.iter()
.any(|group| group["group_id"] == "focus_pair" && group["source"] == "runtime_user"));
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "focus_noise"
&& preset["source"] == "runtime_user"
&& preset["transition_style"] == "chase"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "focus_variant"
&& snapshot["label"] == "Focus Variant"));
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
let mut event_messages = Vec::new();
for _ in 0..24 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
if let Some(message) = payload["message"]["payload"]["message"].as_str() {
event_messages.push(message.to_string());
}
}
}
assert!(event_messages
.iter()
.any(|message| message.contains("group saved: focus_pair")));
assert!(event_messages
.iter()
.any(|message| message.contains("preset overwritten: focus_noise")));
assert!(event_messages
.iter()
.any(|message| message.contains("creative snapshot recalled: focus_variant")));
assert!(event_messages
.iter()
.any(|message| message.contains("global blackout enabled")));
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn invalid_runtime_state_file_falls_back_without_blocking_server_start() {
let runtime_state_path = unique_runtime_state_path("invalid_runtime");
std::fs::write(&runtime_state_path, "{ broken").expect("invalid runtime state should write");
let server = start_server_with_runtime_state(&runtime_state_path);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let mut stream = open_websocket(server.local_addr());
assert_eq!(state.status_code, 200);
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"solid_color"
);
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let mut saw_recovery_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "runtime_state_parse_failed"
{
saw_recovery_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
break;
}
}
assert!(
saw_recovery_warning,
"expected recovery warning event after invalid runtime state"
);
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
let first_frame = read_websocket_text_frame(&mut stream);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
let second_frame = read_websocket_text_frame(&mut stream);
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let invalid = send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
);
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid json");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
let mut saw_warning = false;
for _ in 0..12 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "unknown_creative_snapshot"
{
saw_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
assert_eq!(
payload["message"]["payload"]["code"],
"unknown_creative_snapshot"
);
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
.contains("does_not_exist"));
break;
}
}
assert!(saw_warning, "expected warning event after failed command");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
#[ignore = "longer load-oriented sequence for platform hardening"]
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let server = start_server();
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
let groups = [
None,
Some("top_panels"),
Some("middle_panels"),
Some("bottom_panels"),
];
for index in 0..80 {
let pattern = patterns[index % patterns.len()];
let group = groups[index % groups.len()];
let brightness = ((index % 10) as f32) / 10.0;
let speed = 0.5 + (index % 6) as f32 * 0.25;
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_pattern","payload":{{"pattern_id":"{pattern}"}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_master_brightness","payload":{{"value":{brightness}}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_scene_parameter","payload":{{"key":"speed","value":{{"kind":"scalar","value":{speed}}}}}}}}}"#
),
);
let group_json = match group {
Some(group_id) => format!(r#""{group_id}""#),
None => "null".to_string(),
};
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_group","payload":{{"group_id":{group_json}}}}}}}"#
),
);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(
state_body["state"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert!(
catalog_body["patterns"]
.as_array()
.map(Vec::len)
.unwrap_or_default()
>= 5
);
}
server.shutdown();
}
fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
send_http_request(addr, "POST", "/api/v1/command", Some(body))
}
fn send_http_request(
addr: SocketAddr,
method: &str,
path: &str,
body: Option<&str>,
) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.as_bytes().len(),
body,
host = addr
);
let mut stream = TcpStream::connect(addr).expect("http connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
stream.write_all(request.as_bytes()).expect("write request");
stream.shutdown(Shutdown::Write).expect("shutdown write");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).expect("read response");
parse_http_response(&raw)
}
fn parse_http_response(raw: &[u8]) -> HttpResponse {
let delimiter = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("http header delimiter");
let header_text = String::from_utf8(raw[..delimiter].to_vec()).expect("header utf8");
let body = String::from_utf8(raw[delimiter + 4..].to_vec()).expect("body utf8");
let mut lines = header_text.lines();
let status_line = lines.next().expect("status line");
let status_code = status_line
.split_whitespace()
.nth(1)
.expect("status code")
.parse::<u16>()
.expect("valid status code");
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect::<HashMap<_, _>>();
HttpResponse {
status_code,
headers,
body,
}
}
fn open_websocket(addr: SocketAddr) -> TcpStream {
let mut stream = TcpStream::connect(addr).expect("websocket connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
let request = format!(
"GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
host = addr
);
stream
.write_all(request.as_bytes())
.expect("write handshake");
let header = read_until_header_end(&mut stream);
let header_text = String::from_utf8(header).expect("handshake utf8");
assert!(header_text.starts_with("HTTP/1.1 101"));
assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
stream
}
fn read_until_header_end(stream: &mut TcpStream) -> Vec<u8> {
let mut buffer = Vec::new();
loop {
let mut byte = [0u8; 1];
let read = stream.read(&mut byte).expect("read handshake");
assert!(read > 0, "unexpected eof while reading handshake");
buffer.push(byte[0]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
let end = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("header end")
+ 4;
return buffer[..end].to_vec();
}
}
}
fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
let mut header = [0u8; 2];
stream.read_exact(&mut header).expect("frame header");
assert_eq!(header[0] & 0x0f, 0x1, "expected text frame");
let payload_len = match header[1] & 0x7f {
len @ 0..=125 => len as usize,
126 => {
let mut extended = [0u8; 2];
stream.read_exact(&mut extended).expect("extended payload");
u16::from_be_bytes(extended) as usize
}
127 => {
let mut extended = [0u8; 8];
stream.read_exact(&mut extended).expect("extended payload");
u64::from_be_bytes(extended) as usize
}
_ => unreachable!("masked length bit should already be stripped"),
};
let mut payload = vec![0u8; payload_len];
stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8")
}
fn unique_runtime_state_path(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis();
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
}

View File

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

View File

@@ -0,0 +1,319 @@
use eframe::egui::{self, Color32, RichText};
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
HostCommand, HostSnapshot, HostUiPort, NodeConnectionState, PanelTarget, TestPatternKind,
};
use std::{sync::Arc, time::Duration};
pub struct HostUiApp {
service: Arc<dyn HostUiPort>,
snapshot: HostSnapshot,
}
impl HostUiApp {
pub fn new(service: Arc<dyn HostUiPort>) -> Self {
let snapshot = service.snapshot();
Self { service, snapshot }
}
}
impl eframe::App for HostUiApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.snapshot = self.service.snapshot();
ctx.request_repaint_after(Duration::from_millis(100));
egui::TopBottomPanel::top("global_controls").show(ctx, |ui| {
draw_global_controls(ui, &self.snapshot, &self.service);
});
egui::SidePanel::right("status_panel")
.default_width(320.0)
.resizable(true)
.show(ctx, |ui| {
draw_status_panel(ui, &self.snapshot);
});
egui::CentralPanel::default().show(ctx, |ui| {
draw_node_overview(ui, &self.snapshot);
ui.add_space(12.0);
draw_panel_mapping(ui, &self.snapshot, &self.service);
});
}
}
fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
ui.horizontal_wrapped(|ui| {
ui.heading("Infinity Vis Host UI");
ui.label(
RichText::new(format!("backend: {}", snapshot.backend_label))
.color(Color32::from_rgb(110, 169, 255)),
);
ui.separator();
ui.label(format!("nodes: {}", snapshot.nodes.len()));
ui.label(format!("outputs: {}", snapshot.panels.len()));
ui.label(format!("schema time: {} ms", snapshot.generated_at_millis));
});
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
let blackout = snapshot.global.blackout;
let blackout_button = egui::Button::new(if blackout {
RichText::new("Blackout ACTIVE")
.strong()
.color(Color32::WHITE)
} else {
RichText::new("Blackout").strong()
})
.fill(if blackout {
Color32::from_rgb(190, 32, 32)
} else {
Color32::from_rgb(70, 24, 24)
});
if ui.add(blackout_button).clicked() {
let _ = service.send_command(HostCommand::SetBlackout(!blackout));
}
let mut brightness = snapshot.global.master_brightness;
if ui
.add(egui::Slider::new(&mut brightness, 0.0..=1.0).text("Master Brightness"))
.changed()
{
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
}
let selected_pattern = snapshot.global.selected_pattern.clone();
egui::ComboBox::from_id_salt("pattern_selector")
.width(180.0)
.selected_text(selected_pattern.clone())
.show_ui(ui, |ui| {
for pattern in &snapshot.available_patterns {
if ui
.selectable_label(selected_pattern == *pattern, pattern)
.clicked()
{
let _ = service.send_command(HostCommand::SelectPattern(pattern.clone()));
}
}
});
});
}
fn draw_node_overview(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
ui.heading("Node Overview");
ui.label("Simulation snapshots are shown here without making the UI thread the timing master.");
ui.add_space(6.0);
ui.columns(3, |columns| {
for (index, node) in snapshot.nodes.iter().enumerate() {
let column = &mut columns[index % 3];
column.group(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&node.display_name).strong());
ui.label(connection_badge(node.connection));
});
ui.label(format!("Node ID: {}", node.node_id));
ui.label(format!(
"IP: {}",
node.reserved_ip.as_deref().unwrap_or("unassigned")
));
ui.label(format!("Panels: {}", node.panel_count));
ui.label(format!("Last contact: {} ms", node.last_contact_ms));
if let Some(error) = &node.error_status {
ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140)));
} else {
ui.label(
RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142)),
);
}
});
}
});
}
fn draw_panel_mapping(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
ui.separator();
ui.heading("Panel Mapping");
ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology.");
ui.add_space(6.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("panel_mapping_grid")
.num_columns(11)
.striped(true)
.min_col_width(72.0)
.show(ui, |ui| {
header_cell(ui, "Node");
header_cell(ui, "Panel");
header_cell(ui, "Output");
header_cell(ui, "GPIO / Channel");
header_cell(ui, "LEDs");
header_cell(ui, "Direction");
header_cell(ui, "Color");
header_cell(ui, "Enabled");
header_cell(ui, "Validation");
header_cell(ui, "Status");
header_cell(ui, "Test");
ui.end_row();
for panel in &snapshot.panels {
ui.label(&panel.target.node_id);
ui.label(panel_position_label(&panel.target.panel_position));
ui.label(&panel.physical_output_name);
ui.label(&panel.driver_reference);
ui.label(panel.led_count.to_string());
ui.label(direction_label(&panel.direction));
ui.label(color_order_label(&panel.color_order));
ui.label(bool_flag(panel.enabled));
ui.label(validation_label(&panel.validation_state));
ui.vertical(|ui| {
ui.label(connection_badge(panel.connection));
if let Some(last_test_ms) = panel.last_test_trigger_ms {
ui.label(format!(
"last test: {} ms",
snapshot.generated_at_millis.saturating_sub(last_test_ms)
));
}
if let Some(error) = &panel.error_status {
ui.label(
RichText::new(error).color(Color32::from_rgb(255, 140, 140)),
);
}
});
if ui.button("Walk 106").clicked() {
let _ = service.send_command(HostCommand::TriggerPanelTest {
target: PanelTarget {
node_id: panel.target.node_id.clone(),
panel_position: panel.target.panel_position.clone(),
},
pattern: TestPatternKind::WalkingPixel106,
});
}
ui.end_row();
}
});
});
}
fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
ui.heading("Status");
ui.label("Node connectivity, last contact, and recent control activity.");
ui.add_space(8.0);
ui.group(|ui| {
ui.label(RichText::new("Engine").strong());
ui.label(format!("Pattern: {}", snapshot.active_scene.pattern_id));
ui.label(format!(
"Preset: {}",
snapshot
.active_scene
.preset_id
.as_deref()
.unwrap_or("custom")
));
ui.label(format!(
"Group: {}",
snapshot
.active_scene
.target_group
.as_deref()
.unwrap_or("all_panels")
));
ui.label(format!(
"Frames: {} at {} Hz",
snapshot.engine.frame_index, snapshot.engine.frame_hz
));
if let Some(transition) = &snapshot.engine.active_transition {
ui.label(format!(
"Transition: {:?} {:.0}%",
transition.style,
transition.progress * 100.0
));
} else {
ui.label("Transition: idle");
}
});
ui.add_space(8.0);
for node in &snapshot.nodes {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&node.display_name).strong());
ui.label(connection_badge(node.connection));
});
ui.label(format!("Last contact: {} ms", node.last_contact_ms));
ui.label(format!(
"Error status: {}",
node.error_status.as_deref().unwrap_or("none")
));
});
ui.add_space(6.0);
}
ui.separator();
ui.heading("Recent Events");
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for event in &snapshot.recent_events {
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
}
});
}
fn header_cell(ui: &mut egui::Ui, label: &str) {
ui.label(RichText::new(label).strong());
}
fn connection_badge(state: NodeConnectionState) -> RichText {
let (label, color) = match state {
NodeConnectionState::Online => ("online", Color32::from_rgb(78, 194, 120)),
NodeConnectionState::Degraded => ("degraded", Color32::from_rgb(242, 182, 59)),
NodeConnectionState::Offline => ("offline", Color32::from_rgb(220, 76, 76)),
};
RichText::new(label).strong().color(color)
}
fn panel_position_label(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn direction_label(direction: &LedDirection) -> &'static str {
match direction {
LedDirection::Forward => "forward",
LedDirection::Reverse => "reverse",
}
}
fn color_order_label(color_order: &ColorOrder) -> &'static str {
match color_order {
ColorOrder::Rgb => "RGB",
ColorOrder::Rbg => "RBG",
ColorOrder::Grb => "GRB",
ColorOrder::Gbr => "GBR",
ColorOrder::Brg => "BRG",
ColorOrder::Bgr => "BGR",
}
}
fn validation_label(state: &ValidationState) -> &'static str {
match state {
ValidationState::PendingHardwareValidation => "pending_validation",
ValidationState::Validated => "validated",
ValidationState::Retired => "retired",
}
}
fn bool_flag(value: bool) -> &'static str {
if value {
"true"
} else {
"false"
}
}

View File

@@ -0,0 +1,46 @@
mod app;
use app::HostUiApp;
use clap::Parser;
use eframe::egui;
use infinity_config::{load_project_from_path, ProjectConfig};
use infinity_host::SimulationHostService;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(author, version, about = "Infinity Vis native host UI")]
struct Cli {
#[arg(long)]
config: Option<PathBuf>,
}
fn main() -> eframe::Result<()> {
let cli = Cli::parse();
let project = load_project(cli.config.as_deref());
let service = SimulationHostService::spawn_shared(project);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Infinity Vis Host UI")
.with_inner_size([1500.0, 940.0])
.with_min_inner_size([1200.0, 760.0]),
..Default::default()
};
eframe::run_native(
"Infinity Vis Host UI",
options,
Box::new(move |_creation_context| Ok(Box::new(HostUiApp::new(service.clone())))),
)
}
fn load_project(path: Option<&std::path::Path>) -> ProjectConfig {
if let Some(path) = path {
if let Ok(project) = load_project_from_path(path) {
return project;
}
}
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("embedded example config must parse")
}

View File

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

View File

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

View File

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

115
docs/architecture.md Normal file
View File

@@ -0,0 +1,115 @@
# 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.
## Current Priority
The current delivery order is intentionally software-first:
1. host-core and shared API
2. scene, preset, group, parameter, transition, and simulation model
3. web UI as the primary creative surface
4. engineering GUI as the technical surface
5. external show-control adapters such as grandMA
6. hardware validation and real node activation later
## Layer Split
1. Control layer
- Shared host API first
- Creative web UI later
- Engineering GUI already implemented in `crates/infinity_host_ui`
- Monitoring, mapping, diagnostics, and admin
- 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.
## Shared Surface Model
Every surface must talk to the same host API:
- engineering GUI
- future creative web UI
- CLI inspection
- future grandMA adapter
The host core now also carries a runtime show store and persistence layer for:
- saved presets
- runtime user groups
- active scene state
- creative snapshots and variants
The current software-first implementation uses a simulation-backed host API so looks, presets, parameters, and grouping can be developed before real node activation.
## 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. Expand creative authoring on top of the now-versioned host API and web UI
2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin
3. Implement transport adapters without coupling them to any single frontend
4. Add future external show-control bridges such as grandMA on the same API boundary and generic adapter interface
5. Keep hardware activation behind explicit later validation gates

46
docs/build_and_deploy.md Normal file
View File

@@ -0,0 +1,46 @@
# Build and Deploy
## Host Side
Required tools:
- Rust stable toolchain
- `cargo`
Suggested commands:
```powershell
cargo test
cargo run -p infinity_host -- snapshot --config config/project.example.toml
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
cargo run -p infinity_host_ui
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
```
The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`. Runtime creative data such as saved presets, groups, active scene state, and creative snapshots are persisted to `data/runtime_state.json` by default.
The native engineering UI and the CLI snapshot continue to run against the same simulation-backed host core so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete.
Before any live activation, run:
```powershell
cargo run -p infinity_host -- validate --config config/project.example.toml --mode activation
```
Activation mode is expected to fail until the hardware mapping has been confirmed and the config is updated from `pending_validation` to concrete driver references.
## Firmware Side
Required tools:
- ESP-IDF
- Xtensa or RISC-V toolchain matching the actual ESP32 variant
Suggested layout:
- `firmware/esp32_node/`
- build with `idf.py build`
- flash with `idf.py -p <serial-port> flash monitor`
The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`.

87
docs/config_schema.md Normal file
View File

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

397
docs/host_api.md Normal file
View File

@@ -0,0 +1,397 @@
# Host API
## Purpose
The host API is the stable external product boundary for:
- the creative web UI
- the native engineering GUI
- future remote operator clients
- later external show-control adapters such as a grandMA bridge
The realtime rule remains strict:
- the API is a control and observation layer
- the host core remains the timing authority
- no frontend or external adapter is allowed to become the LED clock
## Runtime Components
Core and API implementation:
- `crates/infinity_host/src/control.rs`
- `crates/infinity_host/src/scene.rs`
- `crates/infinity_host/src/show_store.rs`
- `crates/infinity_host/src/simulation.rs`
- `crates/infinity_host/src/external_control.rs`
- `crates/infinity_host_api/src/dto.rs`
- `crates/infinity_host_api/src/server.rs`
Server startup:
```powershell
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
Creative web UI V1 is served by the same process:
```text
http://127.0.0.1:9001/
```
## Versioning Policy
The current public contract is `v1`.
Rules:
- all public HTTP and WebSocket routes are namespaced under `/api/v1`
- every response body carries `api_version: "v1"`
- additive fields are allowed inside `v1`
- semantic breaking changes require a new version namespace
- external consumers must treat undocumented internal-only fields as unstable and ignore them
## Stable External Models
Stable external response families:
- command response
- state snapshot
- preview snapshot
- combined snapshot
- catalog
- event stream
- typed error object
Stable external command families:
- global control
- pattern and preset selection
- group targeting
- scene parameter updates
- transition configuration
- preset persistence
- creative snapshot persistence and recall
- panel test trigger
## Internal Versus External Fields
External and stable in `v1`:
- every field defined in `crates/infinity_host_api/src/dto.rs`
- route names and payload shapes documented below
- error object shape `{ api_version, error: { code, message } }`
- event stream envelope shape `{ api_version, sequence, generated_at_millis, message }`
Internal and not part of the API contract:
- the exact shape of `HostSnapshot` in `crates/infinity_host/src/control.rs`
- simulation-only storage layout in `data/runtime_state.json`
- internal event history buffering size
- internal scene library structures in `show_store.rs`
- engineering-GUI-specific rendering or polling behavior
## HTTP Endpoints
### GET `/api/v1/state`
Returns only the stable state snapshot.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 512,
"state": {
"system": {
"project_name": "Infinity Vis",
"schema_version": 1,
"topology_label": "6 nodes / 18 outputs / 106 LEDs"
},
"global": {
"blackout": false,
"master_brightness": 0.2,
"selected_pattern": "gradient",
"selected_group": "top_panels",
"transition_duration_ms": 320,
"transition_style": "chase"
},
"engine": {
"logic_hz": 120,
"frame_hz": 60,
"preview_hz": 15,
"uptime_ms": 512,
"frame_index": 30,
"dropped_frames": 0,
"active_transition": {
"style": "chase",
"from_pattern_id": "solid_color",
"to_pattern_id": "gradient",
"duration_ms": 320,
"progress": 0.28
}
}
}
}
```
### GET `/api/v1/preview`
Returns only the stable preview snapshot.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 512,
"preview": {
"generated_at_millis": 512,
"panels": [
{
"node_id": "node-01",
"panel_position": "top",
"representative_color_hex": "#FF8A5B",
"sample_led_hex": [
"#FF8A5B",
"#F36E43",
"#D85A2F"
],
"energy_percent": 47,
"source": "transition"
}
]
}
}
```
### GET `/api/v1/snapshot`
Returns the convenience composition of `state` plus `preview`.
This route exists for lightweight clients and debugging. Consumers that want strict separation should prefer `GET /api/v1/state` and `GET /api/v1/preview`.
### GET `/api/v1/catalog`
Returns the stable creative library:
- patterns
- presets
- groups
- creative snapshots
Example preset summary:
```json
{
"preset_id": "ocean_gradient",
"pattern_id": "gradient",
"target_group": null,
"transition_duration_ms": 320,
"transition_style": "crossfade",
"source": "built_in"
}
```
Example creative snapshot summary:
```json
{
"snapshot_id": "variant_floor",
"label": "Variant Floor",
"pattern_id": "noise",
"target_group": "bottom_panels",
"transition_duration_ms": 220,
"transition_style": "chase",
"saved_at_unix_ms": 1760000000000
}
```
### GET `/api/v1/presets`
Returns only preset summaries.
### GET `/api/v1/groups`
Returns only group summaries.
### POST `/api/v1/command`
Accepts one versioned command envelope.
Example request:
```json
{
"request_id": "web-1713352662000",
"command": {
"type": "save_creative_snapshot",
"payload": {
"snapshot_id": "variant_floor",
"label": "Variant Floor",
"overwrite": false
}
}
}
```
Example response:
```json
{
"api_version": "v1",
"accepted": true,
"request_id": "web-1713352662000",
"generated_at_millis": 522,
"command_type": "save_creative_snapshot",
"summary": "creative snapshot saved: variant_floor"
}
```
## Stable Error Object
All API failures return:
```json
{
"api_version": "v1",
"error": {
"code": "unknown_creative_snapshot",
"message": "creative snapshot 'does_not_exist' does not exist"
}
}
```
Stable `v1` error families currently include:
- `invalid_request_json`
- `invalid_command`
- `unknown_group`
- `unknown_preset`
- `unknown_creative_snapshot`
- `preset_exists`
- `snapshot_exists`
- `group_exists`
- `persist_failed`
- `missing_websocket_key`
- `not_found`
## WebSocket Event Stream
### WS `/api/v1/stream`
The stream emits a typed envelope with a monotonic sequence counter:
```json
{
"api_version": "v1",
"sequence": 19,
"generated_at_millis": 880,
"message": {
"type": "event",
"payload": {
"kind": "warning",
"code": "unknown_creative_snapshot",
"message": "creative snapshot 'does_not_exist' does not exist"
}
}
}
```
Stable message types:
- `snapshot`
- `preview`
- `event`
Stable event kinds:
- `info`
- `warning`
- `error`
## Guaranteed Commands In `v1`
Guaranteed control commands:
- `set_blackout`
- `set_master_brightness`
- `select_pattern`
- `recall_preset`
- `select_group`
- `set_scene_parameter`
- `set_transition_duration_ms`
- `set_transition_style`
- `trigger_panel_test`
Guaranteed persistence and creative-library commands:
- `save_preset`
- `save_creative_snapshot`
- `recall_creative_snapshot`
- `upsert_group`
## Persistence Behavior
The simulation-backed host service now persists runtime-facing creative data to `data/runtime_state.json` by default.
Persisted data includes:
- active scene
- global blackout and brightness state
- transition duration and style
- runtime user presets
- runtime user groups
- creative snapshots and variants
This persistence file is an internal runtime artifact, not the public API contract.
## External Show Control Adapter Boundary
The generic internal adapter surface lives in:
- `crates/infinity_host/src/external_control.rs`
Key rule:
- future adapters may only translate external intent into the defined host command surface
- they must not reach into simulation internals, UI state, or hardware driver details directly
## Contract And Integration Coverage
Current software-side hardening lives in:
- `crates/infinity_host_api/tests/contract.rs`
- `crates/infinity_host/src/show_store.rs` tests
- `crates/infinity_host/src/simulation.rs` tests
Covered flows include:
- state, preview, snapshot, catalog, presets, and groups endpoints
- command success and typed command failure
- WebSocket snapshot, preview, and event messages
- group targeting
- parameter updates
- transition configuration
- blackout
- preset save
- creative snapshot save and recall
- persistence across restart
- a longer ignored load-oriented sequence for platform hardening
## Web UI Scope
The current web UI intentionally focuses on creative use:
- pattern and preset selection
- group targeting
- transition configuration
- scene parameters
- preset save and overwrite
- creative snapshot save and recall
- preview
- raw snapshot display
- filterable event feed
Mapping, topology diagnostics, panel-test administration, and low-level node status remain primarily in the native engineering GUI.

View File

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

View File

@@ -0,0 +1,50 @@
# Local Software-Only Runbook
## Voraussetzungen
- Rust `stable` Toolchain mit `cargo`, `rustc`, `rustfmt` und `clippy`
- dieses Repo ist lokal aktuell **kein echter Git-Clone**, sondern nur ein Arbeitsbaum ohne `.git`
- keine Hardware ist fuer den software-only Betrieb noetig
Beispiel fuer eine user-lokale Rust-Installation:
```bash
curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
. "$HOME/.cargo/env"
rustup component add rustfmt clippy
```
## Start
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
## Lokale URLs
- Creative Web-UI: `http://127.0.0.1:9001/`
- State API: `http://127.0.0.1:9001/api/v1/state`
- Preview API: `http://127.0.0.1:9001/api/v1/preview`
- Snapshot API: `http://127.0.0.1:9001/api/v1/snapshot`
- WebSocket-Stream: `ws://127.0.0.1:9001/api/v1/stream`
## Minimale Smoke-Checks
1. Web-UI laedt unter `http://127.0.0.1:9001/`.
2. `GET /api/v1/state` antwortet mit `api_version: "v1"`.
3. `ws://127.0.0.1:9001/api/v1/stream` verbindet und liefert zuerst `snapshot`, dann `preview`.
4. In der Web-UI oder ueber `POST /api/v1/command` funktionieren diese Basisfluesse:
- preset recall
- preset save / overwrite
- creative snapshot save / recall
- blackout
## Runtime-State und Recovery
- Runtime-Persistenz liegt standardmaessig unter `data/runtime_state.json`.
- Beim Schreiben werden aktiver Scene-State, Runtime-Presets, Runtime-Gruppen, Creative Snapshots und globale Steuerwerte persistiert.
- Fehlende Dateien sind okay.
- Leere, defekte oder schema-inkompatible Persistenzdateien blockieren den Serverstart nicht mehr.
- In diesen Recovery-Faellen startet der Host mit Default-State und erzeugt Warning-/Info-Events im Eventfeed statt abzubrechen.

67
docs/protocol.md Normal file
View File

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

View File

@@ -0,0 +1,113 @@
# Show-Control Primitives
## Ziel
Diese Primitive-Menge ist die kleine, dauerhafte interne Steuersemantik fuer software-only Show-Control. Sie bleibt bewusst generisch:
- keine UI-spezifischen Sonderfaelle
- keine grandMA-spezifische Kopplung
- keine zweite Architektur neben Host-Core und API
Der Implementierungspfad liegt in `crates/infinity_host/src/external_control.rs`.
## Primitive
### `blackout`
- Typ: direkt, mutierend
- Semantik: setzt globalen Blackout an oder aus
- Idempotenz: ja, bezogen auf den Zielzustand
- Fehlercodes: unterliegende Host-Fehler nur bei Persistenzproblemen, typischerweise `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `recall_preset`
- Typ: direkt, mutierend
- Semantik: recalled ein Preset inklusive seiner Zielgruppe und Transition-Metadaten
- Idempotenz: praktisch ja, wenn dasselbe Preset erneut recalled wird
- Fehlercodes: `unknown_preset`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Preset
### `recall_creative_snapshot`
- Typ: direkt, mutierend
- Semantik: recalled einen gespeicherten Creative Snapshot inklusive Scene- und Transition-State
- Idempotenz: praktisch ja, wenn derselbe Snapshot erneut recalled wird
- Fehlercodes: `unknown_creative_snapshot`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Snapshot
### `set_master_brightness`
- Typ: direkt, mutierend
- Semantik: setzt globale Helligkeit, intern auf `0.0..1.0` geklemmt
- Idempotenz: ja, bezogen auf den geklemmten Zielwert
- Fehlercodes: typischerweise nur `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `set_pattern`
- Typ: staged
- Semantik: staged das Pattern fuer die naechste explizite Transition
- Idempotenz: ja, letzter Wert gewinnt
- Fehlercodes: `invalid_pattern_id`
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `set_group_parameter`
- Typ: staged
- Semantik: staged einen Scene-Parameter fuer die naechste Transition und kann optional gleichzeitig die Zielgruppe fuer diese Transition setzen
- Idempotenz: ja, letzter Wert pro Parameter-Key gewinnt
- Fehlercodes: `invalid_group_parameter_key`
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `upsert_group`
- Typ: direkt, mutierend
- Semantik: legt eine Runtime-Gruppe an oder ueberschreibt sie bewusst mit `overwrite: true`
- Idempotenz: ja mit `overwrite: true`, nein mit `overwrite: false`
- Fehlercodes: `invalid_group_id`, `invalid_group_members`, `group_exists`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `set_transition_style`
- Typ: staged
- Semantik: staged Transition-Style und optional Duration fuer die naechste explizite Transition
- Idempotenz: ja, letzter Wert gewinnt
- Fehlercodes: keine zusaetzlichen Primitive-Fehler
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `trigger_transition`
- Typ: ausfuehrend, mutierend
- Semantik: materialisiert den aktuell gestagten Transition-Intent in den Host
- Ausfuehrungsreihenfolge:
1. `select_group` nur wenn ueber staged Parameter ein Gruppenkontext gesetzt wurde
2. `set_transition_duration_ms` nur wenn staged
3. `set_transition_style` nur wenn staged
4. `select_pattern`
5. `set_scene_parameter` fuer alle staged Parameter
- Idempotenz: nein, erfolgreicher Trigger konsumiert den gestagten Intent
- Fehlercodes: `transition_pattern_required` plus unterliegende Host-Fehler wie `unknown_group` oder `persist_failed`
- Event-Auswirkung: die unterliegenden Host-Kommandos erzeugen die sichtbaren Info-/Warning-Events
### `request_snapshot`
- Typ: read-only
- Semantik: liefert den aktuellen Host-Snapshot ohne Host-Mutation
- Idempotenz: ja
- Fehlercodes: keine Primitive-eigenen
- Event-Auswirkung: keine
## Hinweis zur Adapter-Nutzung
Die staged Primitive sind fuer externe Show-Control-Adapter gedacht, die absichtlich mehrere kleine Eingaben sammeln und erst mit `trigger_transition` in einen Host-seitigen Uebergang umsetzen. Direkte UI- oder API-Kommandos koennen weiterhin eager bleiben; die stabile interne Adapter-Semantik wird davon nicht aufgeblasen.
Ein stateless Port darf staged Primitive nicht stillschweigend akzeptieren. Wenn `set_pattern`, `set_group_parameter`, `set_transition_style` oder `trigger_transition` ohne Session-/Adapter-Kontext direkt an einem Port landen, ist der erwartete Fehlercode `show_control_session_required`.
## Referenz-Client
Der sehr duenne generische Referenzpfad liegt in `crates/infinity_host/src/external_control.rs`:
- `ReferenceShowControlClient::stateful(...)` fuer direkte plus staged Flows
- `ReferenceShowControlClient::stateless(...)` zum bewussten Nachweis, dass staged Primitive am nackten Port mit `show_control_session_required` abgewiesen werden
- `BufferedShowControlAdapter` und `ShowControlSession` als kleine Buffer-/Commit-Implementierung ohne neue Grundarchitektur

37
docs/testing.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
rust-toolchain.toml Normal file
View File

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

1212
web/v1/app.js Normal file

File diff suppressed because it is too large Load Diff

224
web/v1/index.html Normal file
View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Infinity Vis Creative Console</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Infinity Vis / Creative Surface</p>
<h1 id="project-name">Loading project...</h1>
<p id="topology-label" class="hero-subtitle">
Shared host API bootstrap in progress.
</p>
</div>
<div class="hero-status">
<div class="status-card">
<span class="status-label">API stream</span>
<span id="connection-pill" class="pill pill-offline">connecting</span>
</div>
<div class="status-card">
<span class="status-label">Preview refresh</span>
<span id="preview-updated">waiting for data</span>
</div>
<button id="refresh-button" class="ghost-button" type="button">
Refresh snapshot
</button>
</div>
</header>
<main class="layout">
<section class="panel controls-panel">
<div class="section-heading">
<h2>Global Look</h2>
<p>Pattern, preset, group and transition control against the shared host API.</p>
</div>
<div class="control-grid">
<label class="field">
<span>Pattern</span>
<select id="pattern-select"></select>
</label>
<label class="field">
<span>Transition Duration</span>
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
<strong id="transition-value">0 ms</strong>
</label>
<label class="field">
<span>Transition Style</span>
<select id="transition-style-select">
<option value="snap">Snap</option>
<option value="crossfade">Crossfade</option>
<option value="chase">Chase</option>
</select>
</label>
<label class="field">
<span>Master Brightness</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
<strong id="brightness-value">0%</strong>
</label>
<div class="field">
<span>Blackout</span>
<button id="blackout-button" class="danger-button" type="button">
Enable blackout
</button>
</div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Pending Transition</h3>
<p>Stage primitives locally and commit them with one explicit trigger.</p>
</div>
<div class="session-panel">
<div class="session-status-row">
<div class="status-card">
<span class="status-label">Control mode</span>
<span id="control-mode-pill" class="pill pill-online">stateful</span>
</div>
<div class="status-card">
<span class="status-label">Commit state</span>
<span id="pending-commit-pill" class="pill pill-offline">idle</span>
</div>
</div>
<div id="pending-session-summary" class="pending-session-summary"></div>
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
<div class="session-actions">
<button id="trigger-transition-button" class="ghost-button" type="button">
Trigger Transition
</button>
<button id="clear-staged-button" class="ghost-button" type="button">
Clear Staged
</button>
</div>
</div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Presets</h3>
<p>Recall look snapshots without leaving the creative console.</p>
</div>
<div id="preset-list" class="pill-row"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Preset Capture</h3>
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Preset ID</span>
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="preset-overwrite-input" type="checkbox" />
</label>
<button id="save-preset-button" class="ghost-button" type="button">
Save Current Scene As Preset
</button>
</div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Groups</h3>
<p>Focus looks on a subset while keeping the core scene model shared.</p>
</div>
<input
id="group-filter-input"
class="filter-input"
type="text"
placeholder="Filter groups by id or tag"
/>
<div id="group-list" class="pill-row"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Creative Snapshots</h3>
<p>Capture exploratory variants without replacing curated presets.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Snapshot ID</span>
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
</label>
<label class="field">
<span>Label</span>
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="snapshot-overwrite-input" type="checkbox" />
</label>
<button id="save-snapshot-button" class="ghost-button" type="button">
Save Creative Snapshot
</button>
</div>
<div id="snapshot-list" class="snapshot-list"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Scene Parameters</h3>
<p>Rendered from the active scene schema, not hardcoded per frontend.</p>
</div>
<div id="scene-params" class="parameter-grid"></div>
</div>
</section>
<section class="panel preview-panel">
<div class="section-heading">
<h2>Preview</h2>
<p>Live panel previews from the host snapshot and stream feed.</p>
</div>
<div id="preview-grid" class="preview-grid"></div>
</section>
<section class="panel summary-panel">
<div class="section-heading">
<h2>Snapshot</h2>
<p>Operator-friendly scene state with a raw API view underneath.</p>
</div>
<div id="summary-cards" class="summary-cards"></div>
<pre id="snapshot-json" class="snapshot-json"></pre>
</section>
<section class="panel event-panel">
<div class="section-heading">
<h2>Event Stream</h2>
<p>Recent notices from the websocket feed.</p>
</div>
<div class="event-filter-bar">
<select id="event-kind-filter">
<option value="all">All kinds</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input
id="event-search-filter"
class="filter-input"
type="text"
placeholder="Filter by code or message"
/>
</div>
<div id="event-list" class="event-list"></div>
</section>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

672
web/v1/styles.css Normal file
View File

@@ -0,0 +1,672 @@
:root {
--bg: #f3ede2;
--bg-secondary: #efe2cd;
--surface: rgba(255, 251, 244, 0.82);
--surface-strong: rgba(255, 248, 238, 0.94);
--line: rgba(56, 63, 61, 0.12);
--text: #1f2424;
--muted: #596463;
--accent: #ea6a36;
--accent-strong: #c34d1c;
--accent-cool: #198c8f;
--danger: #bc2f2f;
--shadow: 0 24px 60px rgba(91, 63, 38, 0.12);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--radius-sm: 12px;
--font-sans: "Segoe UI Variable", "Aptos", "Trebuchet MS", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background:
radial-gradient(circle at 10% 15%, rgba(234, 106, 54, 0.22), transparent 28%),
radial-gradient(circle at 88% 12%, rgba(25, 140, 143, 0.16), transparent 24%),
radial-gradient(circle at 84% 78%, rgba(239, 202, 130, 0.24), transparent 22%),
linear-gradient(160deg, var(--bg) 0%, var(--bg-secondary) 52%, #f6f2ea 100%);
color: var(--text);
font-family: var(--font-sans);
}
body::before,
body::after {
content: "";
position: fixed;
inset: auto;
pointer-events: none;
border-radius: 999px;
filter: blur(32px);
opacity: 0.55;
}
body::before {
width: 280px;
height: 280px;
top: 18%;
right: -80px;
background: rgba(234, 106, 54, 0.16);
}
body::after {
width: 320px;
height: 320px;
bottom: -120px;
left: -60px;
background: rgba(25, 140, 143, 0.12);
}
.page-shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.hero,
.panel {
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr);
gap: 24px;
padding: 28px;
border-radius: var(--radius-xl);
animation: rise-in 520ms ease-out;
}
.hero-copy h1,
.section-heading h2,
.subsection-heading h3 {
margin: 0;
letter-spacing: -0.03em;
}
.hero-copy h1 {
font-size: clamp(2rem, 3vw, 3.6rem);
line-height: 0.95;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent-strong);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.hero-subtitle,
.section-heading p,
.subsection-heading p,
.field span,
.event-meta,
.status-label,
.preview-meta,
.pill-subtext {
color: var(--muted);
}
.hero-status {
display: grid;
gap: 14px;
align-content: start;
}
.status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--surface-strong);
border-radius: var(--radius-md);
border: 1px solid rgba(56, 63, 61, 0.08);
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 98px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pill-online {
background: rgba(25, 140, 143, 0.14);
color: #0f6c6d;
}
.pill-offline {
background: rgba(188, 47, 47, 0.14);
color: var(--danger);
}
.pill-warning {
background: rgba(234, 106, 54, 0.14);
color: var(--accent-strong);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 20px;
margin-top: 22px;
}
.panel {
border-radius: var(--radius-lg);
padding: 22px;
}
.controls-panel,
.summary-panel {
grid-column: span 1;
}
.preview-panel,
.event-panel {
grid-column: span 1;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: end;
gap: 18px;
margin-bottom: 18px;
}
.section-heading p,
.subsection-heading p {
margin: 0;
max-width: 34rem;
}
.control-grid,
.capture-grid,
.parameter-grid,
.summary-cards {
display: grid;
gap: 14px;
}
.control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.capture-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.parameter-grid,
.summary-cards {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.field,
.parameter-card,
.summary-card,
.preview-card,
.event-item {
background: var(--surface-strong);
border: 1px solid rgba(56, 63, 61, 0.08);
border-radius: var(--radius-md);
}
.field {
display: grid;
gap: 10px;
padding: 14px;
}
.field strong {
color: var(--accent-strong);
font-size: 1rem;
}
.subsection {
margin-top: 24px;
}
.session-panel {
display: grid;
gap: 14px;
}
.session-status-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.pending-session-summary {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.pending-card {
padding: 14px;
border-radius: var(--radius-md);
background: rgba(255, 249, 241, 0.9);
border: 1px solid rgba(56, 63, 61, 0.08);
}
.pending-card strong {
display: block;
margin-bottom: 6px;
color: var(--accent-strong);
}
.pending-card span {
color: var(--muted);
font-size: 0.9rem;
}
.session-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.primitive-error-banner {
padding: 14px;
border-radius: var(--radius-md);
background: rgba(188, 47, 47, 0.1);
border: 1px solid rgba(188, 47, 47, 0.16);
color: var(--danger);
}
.primitive-error-banner strong {
display: block;
margin-bottom: 4px;
}
.hidden {
display: none !important;
}
.subsection-heading {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: baseline;
margin-bottom: 12px;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button,
select,
input,
textarea {
font: inherit;
}
button,
select {
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
button {
cursor: pointer;
transition:
transform 140ms ease,
box-shadow 140ms ease,
background-color 140ms ease,
border-color 140ms ease;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
transform: none;
}
select,
input[type="text"] {
width: 100%;
padding: 12px 14px;
background: #fffdfa;
border: 1px solid rgba(56, 63, 61, 0.14);
color: var(--text);
}
.filter-input {
margin-bottom: 12px;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.inline-checkbox {
align-content: start;
}
.inline-checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 4px 0 0;
accent-color: var(--accent-cool);
}
.ghost-button,
.preset-button,
.group-button {
padding: 11px 14px;
background: #fff9f1;
border-color: rgba(56, 63, 61, 0.12);
color: var(--text);
}
.preset-button.active,
.group-button.active {
background: linear-gradient(135deg, rgba(234, 106, 54, 0.16), rgba(25, 140, 143, 0.16));
border-color: rgba(234, 106, 54, 0.35);
}
.preset-button.staged,
.group-button.staged,
.ghost-button.staged {
background: linear-gradient(135deg, rgba(25, 140, 143, 0.16), rgba(234, 106, 54, 0.1));
border-color: rgba(25, 140, 143, 0.35);
box-shadow: inset 0 0 0 1px rgba(25, 140, 143, 0.08);
}
.danger-button {
padding: 12px 16px;
background: rgba(188, 47, 47, 0.1);
color: var(--danger);
border-color: rgba(188, 47, 47, 0.18);
}
.danger-button.is-active {
background: var(--danger);
color: #fff8f5;
box-shadow: 0 16px 30px rgba(188, 47, 47, 0.24);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.preview-card {
padding: 14px;
position: relative;
overflow: hidden;
animation: rise-in 380ms ease-out;
}
.preview-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 8px;
background: var(--preview-color, #999999);
}
.preview-card-header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.preview-card h3 {
margin: 0;
font-size: 1rem;
}
.preview-meta {
margin-top: 2px;
font-size: 0.86rem;
}
.preview-swatch {
height: 56px;
margin-top: 14px;
border-radius: var(--radius-sm);
background: var(--preview-color, #999999);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
}
.energy-bar {
height: 8px;
margin-top: 12px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
overflow: hidden;
}
.energy-bar > span {
display: block;
height: 100%;
width: var(--energy-width, 0%);
background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84));
}
.sample-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.sample-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--sample-color, #999999);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.48);
}
.parameter-card,
.summary-card,
.event-item {
padding: 14px;
}
.parameter-card label {
display: grid;
gap: 10px;
}
.parameter-card strong,
.summary-card strong {
display: block;
margin-bottom: 4px;
font-size: 1.04rem;
}
.summary-card span {
color: var(--muted);
font-size: 0.9rem;
}
.snapshot-json {
margin: 18px 0 0;
max-height: 360px;
overflow: auto;
padding: 18px;
border-radius: var(--radius-md);
background: #1d2222;
color: #e8eceb;
font-size: 0.86rem;
line-height: 1.5;
}
.event-list {
display: grid;
gap: 12px;
}
.event-filter-bar {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.event-item {
display: grid;
gap: 8px;
}
.event-item.event-info strong {
color: var(--accent-strong);
}
.event-item.event-warning strong {
color: #a7631c;
}
.event-item.event-error strong {
color: var(--danger);
}
.event-code {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
color: var(--muted);
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.snapshot-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.snapshot-card {
display: grid;
gap: 8px;
padding: 14px;
border-radius: var(--radius-md);
background: var(--surface-strong);
border: 1px solid rgba(56, 63, 61, 0.08);
}
.snapshot-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.snapshot-card-header strong {
display: block;
margin-bottom: 2px;
}
.snapshot-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.meta-chip {
display: inline-flex;
padding: 5px 8px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
color: var(--muted);
font-size: 0.82rem;
}
.event-item strong {
color: var(--accent-strong);
}
.empty-state {
padding: 18px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.42);
color: var(--muted);
border: 1px dashed rgba(56, 63, 61, 0.16);
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1080px) {
.layout,
.hero,
.control-grid,
.event-filter-bar {
grid-template-columns: 1fr;
}
.section-heading,
.subsection-heading {
align-items: start;
flex-direction: column;
}
}
@media (max-width: 720px) {
.page-shell {
width: min(100vw - 18px, 100%);
padding-top: 18px;
}
.hero,
.panel {
padding: 18px;
}
.preview-grid,
.parameter-grid,
.summary-cards {
grid-template-columns: 1fr;
}
}