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

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