Initial commit
This commit is contained in:
12
crates/infinity_config/Cargo.toml
Normal file
12
crates/infinity_config/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "infinity_config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
27
crates/infinity_config/src/lib.rs
Normal file
27
crates/infinity_config/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
mod model;
|
||||
mod validation;
|
||||
|
||||
pub use model::*;
|
||||
pub use validation::*;
|
||||
|
||||
use std::{fs, path::Path};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectLoadError {
|
||||
#[error("failed to read project config: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("failed to parse project config: {0}")]
|
||||
Parse(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn from_toml_str(raw: &str) -> Result<Self, toml::de::Error> {
|
||||
toml::from_str(raw)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, ProjectLoadError> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
|
||||
}
|
||||
|
||||
261
crates/infinity_config/src/model.rs
Normal file
261
crates/infinity_config/src/model.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const REQUIRED_NODE_COUNT: usize = 6;
|
||||
pub const REQUIRED_OUTPUTS_PER_NODE: usize = 3;
|
||||
pub const REQUIRED_LED_COUNT_PER_OUTPUT: u16 = 106;
|
||||
pub const REQUIRED_TOTAL_OUTPUTS: usize = REQUIRED_NODE_COUNT * REQUIRED_OUTPUTS_PER_NODE;
|
||||
|
||||
fn default_expected_node_count() -> usize {
|
||||
REQUIRED_NODE_COUNT
|
||||
}
|
||||
|
||||
fn default_outputs_per_node() -> usize {
|
||||
REQUIRED_OUTPUTS_PER_NODE
|
||||
}
|
||||
|
||||
fn default_led_count_per_output() -> u16 {
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_transition_ms() -> u32 {
|
||||
150
|
||||
}
|
||||
|
||||
fn default_network_send_hz() -> u16 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_logic_hz() -> u16 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_preview_hz() -> u16 {
|
||||
15
|
||||
}
|
||||
|
||||
fn default_heartbeat_hz() -> u16 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_hold_last_frame_ms() -> u32 {
|
||||
1500
|
||||
}
|
||||
|
||||
fn default_brightness_limit() -> f32 {
|
||||
0.35
|
||||
}
|
||||
|
||||
fn default_start_brightness() -> f32 {
|
||||
0.2
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectConfig {
|
||||
pub metadata: ProjectMetadata,
|
||||
pub topology: TopologyConfig,
|
||||
#[serde(default)]
|
||||
pub transport_profiles: Vec<TransportProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub safety_profiles: Vec<SafetyProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub presets: Vec<PresetConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ProjectMetadata {
|
||||
pub project_name: String,
|
||||
pub schema_version: u32,
|
||||
pub default_transport_profile: String,
|
||||
pub default_safety_profile: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TopologyConfig {
|
||||
#[serde(default = "default_expected_node_count")]
|
||||
pub expected_node_count: usize,
|
||||
#[serde(default = "default_outputs_per_node")]
|
||||
pub outputs_per_node: usize,
|
||||
#[serde(default = "default_led_count_per_output")]
|
||||
pub leds_per_output: u16,
|
||||
pub nodes: Vec<NodeConfig>,
|
||||
#[serde(default)]
|
||||
pub layout_panels: Vec<LayoutPanelConfig>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<GroupConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeConfig {
|
||||
pub node_id: String,
|
||||
pub display_name: String,
|
||||
pub network: NodeNetworkConfig,
|
||||
pub outputs: Vec<PanelOutputConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeNetworkConfig {
|
||||
pub reserved_ip: Option<String>,
|
||||
pub telemetry_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PanelOutputConfig {
|
||||
pub panel_position: PanelPosition,
|
||||
pub physical_output_name: String,
|
||||
pub driver_channel: DriverChannelRef,
|
||||
pub led_count: u16,
|
||||
pub direction: LedDirection,
|
||||
pub color_order: ColorOrder,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub validation_state: ValidationState,
|
||||
pub logical_panel_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DriverChannelRef {
|
||||
pub kind: DriverKind,
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PanelPosition {
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LedDirection {
|
||||
Forward,
|
||||
Reverse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColorOrder {
|
||||
Rgb,
|
||||
Rbg,
|
||||
Grb,
|
||||
Gbr,
|
||||
Brg,
|
||||
Bgr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriverKind {
|
||||
PendingValidation,
|
||||
Gpio,
|
||||
RmtChannel,
|
||||
I2sLane,
|
||||
UartPort,
|
||||
SpiBus,
|
||||
ExternalDriver,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidationState {
|
||||
PendingHardwareValidation,
|
||||
Validated,
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PanelRef {
|
||||
pub node_id: String,
|
||||
pub panel_position: PanelPosition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LayoutPanelConfig {
|
||||
pub node_id: String,
|
||||
pub panel_position: PanelPosition,
|
||||
pub row: usize,
|
||||
pub column: usize,
|
||||
#[serde(default)]
|
||||
pub rotation_degrees: i16,
|
||||
#[serde(default)]
|
||||
pub mirror_x: bool,
|
||||
#[serde(default)]
|
||||
pub mirror_y: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GroupConfig {
|
||||
pub group_id: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub members: Vec<PanelRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TransportProfileConfig {
|
||||
pub profile_id: String,
|
||||
pub mode: TransportMode,
|
||||
#[serde(default = "default_logic_hz")]
|
||||
pub logic_hz: u16,
|
||||
#[serde(default = "default_network_send_hz")]
|
||||
pub network_send_hz: u16,
|
||||
#[serde(default = "default_preview_hz")]
|
||||
pub preview_hz: u16,
|
||||
#[serde(default = "default_heartbeat_hz")]
|
||||
pub heartbeat_hz: u16,
|
||||
#[serde(default)]
|
||||
pub ddp_compatibility: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportMode {
|
||||
DistributedScene,
|
||||
FrameStreaming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SafetyProfileConfig {
|
||||
pub profile_id: String,
|
||||
#[serde(default = "default_brightness_limit")]
|
||||
pub master_brightness_limit: f32,
|
||||
#[serde(default = "default_start_brightness")]
|
||||
pub default_start_brightness: f32,
|
||||
#[serde(default)]
|
||||
pub allow_strobe: bool,
|
||||
#[serde(default)]
|
||||
pub max_strobe_hz: Option<f32>,
|
||||
#[serde(default = "default_hold_last_frame_ms")]
|
||||
pub hold_last_frame_ms: u32,
|
||||
#[serde(default)]
|
||||
pub fallback_preset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PresetConfig {
|
||||
pub preset_id: String,
|
||||
pub scene: SceneConfig,
|
||||
#[serde(default = "default_transition_ms")]
|
||||
pub transition_ms: u32,
|
||||
#[serde(default)]
|
||||
pub target_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SceneConfig {
|
||||
pub effect: String,
|
||||
pub seed: u64,
|
||||
#[serde(default)]
|
||||
pub palette: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub speed: f32,
|
||||
#[serde(default)]
|
||||
pub intensity: f32,
|
||||
#[serde(default)]
|
||||
pub blackout: bool,
|
||||
}
|
||||
660
crates/infinity_config/src/validation.rs
Normal file
660
crates/infinity_config/src/validation.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
use crate::{
|
||||
DriverKind, GroupConfig, PanelPosition, PanelRef, ProjectConfig, ValidationState,
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT, REQUIRED_NODE_COUNT, REQUIRED_OUTPUTS_PER_NODE,
|
||||
REQUIRED_TOTAL_OUTPUTS,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationMode {
|
||||
Structural,
|
||||
Activation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationSeverity {
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationIssue {
|
||||
pub severity: ValidationSeverity,
|
||||
pub code: &'static str,
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ValidationReport {
|
||||
pub issues: Vec<ValidationIssue>,
|
||||
}
|
||||
|
||||
impl ValidationReport {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.error_count() == 0
|
||||
}
|
||||
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| issue.severity == ValidationSeverity::Error)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn warning_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| issue.severity == ValidationSeverity::Warning)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
severity: ValidationSeverity,
|
||||
code: &'static str,
|
||||
path: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
self.issues.push(ValidationIssue {
|
||||
severity,
|
||||
code,
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn validate(&self, mode: ValidationMode) -> ValidationReport {
|
||||
let mut report = ValidationReport::default();
|
||||
self.validate_profiles(&mut report);
|
||||
self.validate_topology(mode, &mut report);
|
||||
self.validate_groups(&mut report);
|
||||
self.validate_safety_profiles(&mut report);
|
||||
self.validate_presets(&mut report);
|
||||
report
|
||||
}
|
||||
|
||||
fn validate_profiles(&self, report: &mut ValidationReport) {
|
||||
let transport_profiles: BTreeSet<_> = self
|
||||
.transport_profiles
|
||||
.iter()
|
||||
.map(|profile| profile.profile_id.as_str())
|
||||
.collect();
|
||||
let safety_profiles: BTreeSet<_> = self
|
||||
.safety_profiles
|
||||
.iter()
|
||||
.map(|profile| profile.profile_id.as_str())
|
||||
.collect();
|
||||
|
||||
if !transport_profiles.contains(self.metadata.default_transport_profile.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_default_transport_profile",
|
||||
"metadata.default_transport_profile",
|
||||
format!(
|
||||
"default transport profile '{}' does not exist",
|
||||
self.metadata.default_transport_profile
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !safety_profiles.contains(self.metadata.default_safety_profile.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_default_safety_profile",
|
||||
"metadata.default_safety_profile",
|
||||
format!(
|
||||
"default safety profile '{}' does not exist",
|
||||
self.metadata.default_safety_profile
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_topology(&self, mode: ValidationMode, report: &mut ValidationReport) {
|
||||
let topology = &self.topology;
|
||||
if topology.expected_node_count != REQUIRED_NODE_COUNT {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"expected_node_count_override",
|
||||
"topology.expected_node_count",
|
||||
format!(
|
||||
"expected node count is {}, project overrides to {}",
|
||||
REQUIRED_NODE_COUNT, topology.expected_node_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.outputs_per_node != REQUIRED_OUTPUTS_PER_NODE {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"outputs_per_node_override",
|
||||
"topology.outputs_per_node",
|
||||
format!(
|
||||
"expected outputs per node is {}, project overrides to {}",
|
||||
REQUIRED_OUTPUTS_PER_NODE, topology.outputs_per_node
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.leds_per_output != REQUIRED_LED_COUNT_PER_OUTPUT {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_leds_per_output",
|
||||
"topology.leds_per_output",
|
||||
format!(
|
||||
"fixed system requirement is {} LEDs per output",
|
||||
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if topology.nodes.len() != topology.expected_node_count {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unexpected_node_count",
|
||||
"topology.nodes",
|
||||
format!(
|
||||
"expected {} nodes, found {}",
|
||||
topology.expected_node_count,
|
||||
topology.nodes.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let mut node_ids = BTreeSet::new();
|
||||
let mut panel_refs = BTreeSet::new();
|
||||
let mut output_count = 0usize;
|
||||
|
||||
for (node_index, node) in topology.nodes.iter().enumerate() {
|
||||
let node_path = format!("topology.nodes[{node_index}]");
|
||||
if !node_ids.insert(node.node_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_node_id",
|
||||
format!("{node_path}.node_id"),
|
||||
format!("duplicate node id '{}'", node.node_id),
|
||||
);
|
||||
}
|
||||
|
||||
if node.outputs.len() != topology.outputs_per_node {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unexpected_output_count",
|
||||
format!("{node_path}.outputs"),
|
||||
format!(
|
||||
"expected {} outputs for node '{}', found {}",
|
||||
topology.outputs_per_node,
|
||||
node.node_id,
|
||||
node.outputs.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let mut panel_positions = BTreeSet::new();
|
||||
let mut driver_refs = BTreeSet::new();
|
||||
|
||||
for (output_index, output) in node.outputs.iter().enumerate() {
|
||||
output_count += 1;
|
||||
let output_path = format!("{node_path}.outputs[{output_index}]");
|
||||
panel_positions.insert(output.panel_position.clone());
|
||||
let panel_ref = PanelRef {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_position: output.panel_position.clone(),
|
||||
};
|
||||
if !panel_refs.insert(panel_ref) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_panel_position",
|
||||
format!("{output_path}.panel_position"),
|
||||
format!(
|
||||
"node '{}' has multiple '{}' outputs",
|
||||
node.node_id,
|
||||
display_panel_position(&output.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if output.led_count != topology.leds_per_output {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_led_count",
|
||||
format!("{output_path}.led_count"),
|
||||
format!(
|
||||
"expected {} LEDs, found {}",
|
||||
topology.leds_per_output, output.led_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let driver_key = format!(
|
||||
"{}::{}",
|
||||
output.driver_channel.kind_label(),
|
||||
output.driver_channel.reference
|
||||
);
|
||||
if !driver_refs.insert(driver_key.clone()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_driver_reference",
|
||||
format!("{output_path}.driver_channel.reference"),
|
||||
format!(
|
||||
"node '{}' reuses driver reference '{}'",
|
||||
node.node_id, driver_key
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if mode == ValidationMode::Activation && output.enabled {
|
||||
if output.driver_channel.kind == DriverKind::PendingValidation {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"pending_driver_validation",
|
||||
format!("{output_path}.driver_channel.kind"),
|
||||
format!(
|
||||
"output '{}' cannot be activated while driver kind is pending validation",
|
||||
output.physical_output_name
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if output.validation_state != ValidationState::Validated {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"pending_output_validation",
|
||||
format!("{output_path}.validation_state"),
|
||||
format!(
|
||||
"output '{}' is not hardware-validated yet",
|
||||
output.physical_output_name
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for required_position in [
|
||||
PanelPosition::Top,
|
||||
PanelPosition::Middle,
|
||||
PanelPosition::Bottom,
|
||||
] {
|
||||
if !panel_positions.contains(&required_position) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"missing_panel_position",
|
||||
format!("{node_path}.outputs"),
|
||||
format!(
|
||||
"node '{}' is missing the '{}' output",
|
||||
node.node_id,
|
||||
display_panel_position(&required_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output_count != REQUIRED_TOTAL_OUTPUTS {
|
||||
report.push(
|
||||
ValidationSeverity::Warning,
|
||||
"unexpected_total_output_count",
|
||||
"topology.nodes",
|
||||
format!(
|
||||
"system target is {} total outputs, current config defines {}",
|
||||
REQUIRED_TOTAL_OUTPUTS, output_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let known_panels = panel_refs;
|
||||
let mut layout_refs = BTreeSet::new();
|
||||
for (index, panel) in topology.layout_panels.iter().enumerate() {
|
||||
let panel_ref = PanelRef {
|
||||
node_id: panel.node_id.clone(),
|
||||
panel_position: panel.panel_position.clone(),
|
||||
};
|
||||
if !known_panels.contains(&panel_ref) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_layout_panel_reference",
|
||||
format!("topology.layout_panels[{index}]"),
|
||||
format!(
|
||||
"layout references unknown panel '{}:{}'",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !layout_refs.insert((panel.node_id.as_str(), &panel.panel_position)) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_layout_panel_reference",
|
||||
format!("topology.layout_panels[{index}]"),
|
||||
format!(
|
||||
"layout contains a duplicate reference for '{}:{}'",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_groups(&self, report: &mut ValidationReport) {
|
||||
let known_panels: BTreeSet<_> = self
|
||||
.topology
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|node| {
|
||||
node.outputs.iter().map(move |output| PanelRef {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_position: output.panel_position.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut group_ids = BTreeSet::new();
|
||||
for (group_index, group) in self.topology.groups.iter().enumerate() {
|
||||
if !group_ids.insert(group.group_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_group_id",
|
||||
format!("topology.groups[{group_index}].group_id"),
|
||||
format!("duplicate group id '{}'", group.group_id),
|
||||
);
|
||||
}
|
||||
|
||||
validate_group_members(group, group_index, &known_panels, report);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
|
||||
let preset_ids: BTreeSet<_> = self.presets.iter().map(|preset| preset.preset_id.as_str()).collect();
|
||||
for (index, profile) in self.safety_profiles.iter().enumerate() {
|
||||
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_brightness_limit",
|
||||
format!("safety_profiles[{index}].master_brightness_limit"),
|
||||
"master brightness limit must be between 0.0 and 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"invalid_start_brightness",
|
||||
format!("safety_profiles[{index}].default_start_brightness"),
|
||||
"default start brightness must not exceed the master brightness limit",
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(preset_id) = &profile.fallback_preset_id {
|
||||
if !preset_ids.contains(preset_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_fallback_preset",
|
||||
format!("safety_profiles[{index}].fallback_preset_id"),
|
||||
format!("fallback preset '{}' does not exist", preset_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_presets(&self, report: &mut ValidationReport) {
|
||||
let group_ids: BTreeSet<_> = self
|
||||
.topology
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.group_id.as_str())
|
||||
.collect();
|
||||
let mut preset_ids = BTreeSet::new();
|
||||
|
||||
for (index, preset) in self.presets.iter().enumerate() {
|
||||
if !preset_ids.insert(preset.preset_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_preset_id",
|
||||
format!("presets[{index}].preset_id"),
|
||||
format!("duplicate preset id '{}'", preset.preset_id),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(group_id) = &preset.target_group {
|
||||
if !group_ids.contains(group_id.as_str()) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_target_group",
|
||||
format!("presets[{index}].target_group"),
|
||||
format!("preset references unknown group '{}'", group_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::DriverChannelRef {
|
||||
fn kind_label(&self) -> &'static str {
|
||||
match self.kind {
|
||||
DriverKind::PendingValidation => "pending_validation",
|
||||
DriverKind::Gpio => "gpio",
|
||||
DriverKind::RmtChannel => "rmt_channel",
|
||||
DriverKind::I2sLane => "i2s_lane",
|
||||
DriverKind::UartPort => "uart_port",
|
||||
DriverKind::SpiBus => "spi_bus",
|
||||
DriverKind::ExternalDriver => "external_driver",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_group_members(
|
||||
group: &GroupConfig,
|
||||
group_index: usize,
|
||||
known_panels: &BTreeSet<PanelRef>,
|
||||
report: &mut ValidationReport,
|
||||
) {
|
||||
let mut unique_members = BTreeSet::new();
|
||||
for (member_index, member) in group.members.iter().enumerate() {
|
||||
if !known_panels.contains(member) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"unknown_group_member",
|
||||
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||
format!(
|
||||
"group '{}' references unknown panel '{}:{}'",
|
||||
group.group_id,
|
||||
member.node_id,
|
||||
display_panel_position(&member.panel_position)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !unique_members.insert((member.node_id.as_str(), &member.panel_position)) {
|
||||
report.push(
|
||||
ValidationSeverity::Error,
|
||||
"duplicate_group_member",
|
||||
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||
format!(
|
||||
"group '{}' contains '{}' more than once",
|
||||
group.group_id,
|
||||
panel_ref_label(member)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_ref_label(panel: &PanelRef) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
panel.node_id,
|
||||
display_panel_position(&panel.panel_position)
|
||||
)
|
||||
}
|
||||
|
||||
fn display_panel_position(position: &PanelPosition) -> &'static str {
|
||||
match position {
|
||||
PanelPosition::Top => "top",
|
||||
PanelPosition::Middle => "middle",
|
||||
PanelPosition::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
ColorOrder, DriverChannelRef, LedDirection, NodeConfig, NodeNetworkConfig, PanelOutputConfig,
|
||||
PanelPosition, PresetConfig, ProjectMetadata, SceneConfig, TopologyConfig, TransportMode,
|
||||
TransportProfileConfig,
|
||||
};
|
||||
|
||||
fn build_output(position: PanelPosition, label: &str, driver_kind: DriverKind) -> PanelOutputConfig {
|
||||
PanelOutputConfig {
|
||||
panel_position: position,
|
||||
physical_output_name: label.to_string(),
|
||||
driver_channel: DriverChannelRef {
|
||||
kind: driver_kind,
|
||||
reference: label.to_string(),
|
||||
},
|
||||
led_count: 106,
|
||||
direction: LedDirection::Forward,
|
||||
color_order: ColorOrder::Grb,
|
||||
enabled: true,
|
||||
validation_state: ValidationState::Validated,
|
||||
logical_panel_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_project() -> ProjectConfig {
|
||||
let mut nodes = Vec::new();
|
||||
for index in 1..=6 {
|
||||
nodes.push(NodeConfig {
|
||||
node_id: format!("node-{index:02}"),
|
||||
display_name: format!("Node {index:02}"),
|
||||
network: NodeNetworkConfig {
|
||||
reserved_ip: Some(format!("192.168.40.1{index:02}")),
|
||||
telemetry_label: None,
|
||||
},
|
||||
outputs: vec![
|
||||
build_output(PanelPosition::Top, "GPIO_1", DriverKind::Gpio),
|
||||
build_output(PanelPosition::Middle, "GPIO_2", DriverKind::Gpio),
|
||||
build_output(PanelPosition::Bottom, "GPIO_3", DriverKind::Gpio),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
ProjectConfig {
|
||||
metadata: ProjectMetadata {
|
||||
project_name: "Test".to_string(),
|
||||
schema_version: 1,
|
||||
default_transport_profile: "default-scene".to_string(),
|
||||
default_safety_profile: "live-safe".to_string(),
|
||||
},
|
||||
topology: TopologyConfig {
|
||||
expected_node_count: 6,
|
||||
outputs_per_node: 3,
|
||||
leds_per_output: 106,
|
||||
nodes,
|
||||
layout_panels: Vec::new(),
|
||||
groups: vec![GroupConfig {
|
||||
group_id: "all".to_string(),
|
||||
tags: vec!["global".to_string()],
|
||||
members: vec![
|
||||
PanelRef {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Top,
|
||||
},
|
||||
PanelRef {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Middle,
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
transport_profiles: vec![TransportProfileConfig {
|
||||
profile_id: "default-scene".to_string(),
|
||||
mode: TransportMode::DistributedScene,
|
||||
logic_hz: 120,
|
||||
network_send_hz: 60,
|
||||
preview_hz: 15,
|
||||
heartbeat_hz: 5,
|
||||
ddp_compatibility: false,
|
||||
}],
|
||||
safety_profiles: vec![SafetyProfileConfig {
|
||||
profile_id: "live-safe".to_string(),
|
||||
master_brightness_limit: 0.3,
|
||||
default_start_brightness: 0.2,
|
||||
allow_strobe: false,
|
||||
max_strobe_hz: None,
|
||||
hold_last_frame_ms: 1200,
|
||||
fallback_preset_id: Some("safe-blue".to_string()),
|
||||
}],
|
||||
presets: vec![PresetConfig {
|
||||
preset_id: "safe-blue".to_string(),
|
||||
scene: SceneConfig {
|
||||
effect: "solid_color".to_string(),
|
||||
seed: 7,
|
||||
palette: vec!["#003bff".to_string()],
|
||||
speed: 0.0,
|
||||
intensity: 1.0,
|
||||
blackout: false,
|
||||
},
|
||||
transition_ms: 150,
|
||||
target_group: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_structurally_valid_project() {
|
||||
let project = build_project();
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(report.is_ok(), "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_led_count() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[0].led_count = 105;
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "invalid_led_count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_driver_refs() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[1].driver_channel.reference =
|
||||
project.topology.nodes[0].outputs[0].driver_channel.reference.clone();
|
||||
let report = project.validate(ValidationMode::Structural);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "duplicate_driver_reference"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_requires_hardware_validation() {
|
||||
let mut project = build_project();
|
||||
project.topology.nodes[0].outputs[0].driver_channel.kind = DriverKind::PendingValidation;
|
||||
project.topology.nodes[0].outputs[0].validation_state =
|
||||
ValidationState::PendingHardwareValidation;
|
||||
let report = project.validate(ValidationMode::Activation);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "pending_driver_validation"));
|
||||
assert!(report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == "pending_output_validation"));
|
||||
}
|
||||
}
|
||||
13
crates/infinity_host/Cargo.toml
Normal file
13
crates/infinity_host/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "infinity_host"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
infinity_config = { path = "../infinity_config" }
|
||||
infinity_protocol = { path = "../infinity_protocol" }
|
||||
130
crates/infinity_host/src/main.rs
Normal file
130
crates/infinity_host/src/main.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
mod runtime;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
|
||||
use runtime::RealtimeEngine;
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(author, version, about = "Infinity Vis host-side validation and planning CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
Validate {
|
||||
#[arg(long)]
|
||||
config: PathBuf,
|
||||
#[arg(long, value_enum, default_value_t = CliValidationMode::Structural)]
|
||||
mode: CliValidationMode,
|
||||
},
|
||||
PlanBootScene {
|
||||
#[arg(long)]
|
||||
config: PathBuf,
|
||||
#[arg(long)]
|
||||
preset_id: String,
|
||||
},
|
||||
OpenValidationPoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
enum CliValidationMode {
|
||||
Structural,
|
||||
Activation,
|
||||
}
|
||||
|
||||
impl From<CliValidationMode> for ValidationMode {
|
||||
fn from(value: CliValidationMode) -> Self {
|
||||
match value {
|
||||
CliValidationMode::Structural => ValidationMode::Structural,
|
||||
CliValidationMode::Activation => ValidationMode::Activation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Validate { config, mode } => validate_command(config, mode),
|
||||
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
|
||||
Command::OpenValidationPoints => {
|
||||
print_open_validation_points();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
|
||||
let project = match load_project_from_path(&config) {
|
||||
Ok(project) => project,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let engine = RealtimeEngine::default();
|
||||
let report = engine.validate_project(&project, mode.into());
|
||||
println!(
|
||||
"Validation finished: {} error(s), {} warning(s)",
|
||||
report.error_count(),
|
||||
report.warning_count()
|
||||
);
|
||||
for issue in &report.issues {
|
||||
let level = match issue.severity {
|
||||
ValidationSeverity::Warning => "WARN",
|
||||
ValidationSeverity::Error => "ERROR",
|
||||
};
|
||||
println!("[{level}] {} at {}: {}", issue.code, issue.path, issue.message);
|
||||
}
|
||||
|
||||
if report.is_ok() {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_boot_scene_command(config: PathBuf, preset_id: &str) -> ExitCode {
|
||||
let project = match load_project_from_path(&config) {
|
||||
Ok(project) => project,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let engine = RealtimeEngine::default();
|
||||
let plan = engine.plan_boot_scene(&project, preset_id);
|
||||
if plan.is_empty() {
|
||||
eprintln!("Preset '{preset_id}' was not found.");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
match serde_json::to_string_pretty(&plan) {
|
||||
Ok(output) => {
|
||||
println!("{output}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to serialize boot plan: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_open_validation_points() {
|
||||
for line in [
|
||||
"Pending hardware validation gates:",
|
||||
"1. Confirm whether 'UART 6 / 5 / 4' are GPIO labels, logical channels, or real UART peripherals.",
|
||||
"2. Confirm the exact LED chipset and timing backend required per output.",
|
||||
"3. Confirm color order, start pixel, and direction for all 18 outputs.",
|
||||
"4. Confirm whether all outputs truly have 106 active LEDs without dummy or reserve pixels.",
|
||||
"5. Confirm the final node-to-physical-panel mapping before enabling layout-specific scenes.",
|
||||
] {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
167
crates/infinity_host/src/runtime.rs
Normal file
167
crates/infinity_host/src/runtime.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use infinity_config::{
|
||||
PanelPosition, ProjectConfig, TransportMode, ValidationIssue, ValidationMode, ValidationReport,
|
||||
ValidationSeverity,
|
||||
};
|
||||
use infinity_protocol::{
|
||||
ControlEnvelope, ControlMessage, PanelAddress, PanelAssignment, PanelSlot, RealtimeEnvelope,
|
||||
RealtimeMessage, RealtimeMode, SceneParametersFrame, TransitionMode, TransitionSpec,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TickSchedule {
|
||||
pub logic_hz: u16,
|
||||
pub frame_synthesis_hz: u16,
|
||||
pub network_send_hz: u16,
|
||||
pub preview_hz: u16,
|
||||
}
|
||||
|
||||
impl Default for TickSchedule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
logic_hz: 120,
|
||||
frame_synthesis_hz: 60,
|
||||
network_send_hz: 60,
|
||||
preview_hz: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PlannedSend {
|
||||
pub node_id: String,
|
||||
pub control: Vec<ControlEnvelope>,
|
||||
pub realtime: Vec<RealtimeEnvelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealtimeEngine {
|
||||
pub schedule: TickSchedule,
|
||||
}
|
||||
|
||||
impl Default for RealtimeEngine {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schedule: TickSchedule::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealtimeEngine {
|
||||
pub fn validate_project(&self, project: &ProjectConfig, mode: ValidationMode) -> ValidationReport {
|
||||
let mut report = project.validate(mode);
|
||||
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
|
||||
report.issues.push(ValidationIssue {
|
||||
severity: ValidationSeverity::Warning,
|
||||
code: "preview_rate_too_high",
|
||||
path: "runtime.schedule.preview_hz".to_string(),
|
||||
message: "preview rate should stay below frame synthesis rate".to_string(),
|
||||
});
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
|
||||
let Some(preset) = project.presets.iter().find(|preset| preset.preset_id == preset_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let transport = project
|
||||
.transport_profiles
|
||||
.iter()
|
||||
.find(|profile| profile.profile_id == project.metadata.default_transport_profile);
|
||||
|
||||
let safety = project
|
||||
.safety_profiles
|
||||
.iter()
|
||||
.find(|profile| profile.profile_id == project.metadata.default_safety_profile);
|
||||
|
||||
project
|
||||
.topology
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| {
|
||||
let assignments = node
|
||||
.outputs
|
||||
.iter()
|
||||
.map(|output| PanelAssignment {
|
||||
panel: PanelAddress {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_slot: map_panel_slot(&output.panel_position),
|
||||
},
|
||||
physical_output_name: output.physical_output_name.clone(),
|
||||
driver_reference: output.driver_channel.reference.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let targets = assignments
|
||||
.iter()
|
||||
.map(|assignment| assignment.panel.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let transition = TransitionSpec {
|
||||
transition_ms: preset.transition_ms,
|
||||
mode: TransitionMode::Crossfade,
|
||||
};
|
||||
|
||||
let mode = match transport.map(|profile| &profile.mode) {
|
||||
Some(TransportMode::FrameStreaming) => RealtimeMode::FrameStreaming,
|
||||
_ => RealtimeMode::DistributedScene,
|
||||
};
|
||||
|
||||
PlannedSend {
|
||||
node_id: node.node_id.clone(),
|
||||
control: vec![
|
||||
ControlEnvelope::new(
|
||||
(index as u32) * 2 + 1,
|
||||
0,
|
||||
ControlMessage::ConfigSync {
|
||||
topology_revision: format!(
|
||||
"schema-{}",
|
||||
project.metadata.schema_version
|
||||
),
|
||||
outputs: assignments,
|
||||
},
|
||||
),
|
||||
ControlEnvelope::new(
|
||||
(index as u32) * 2 + 2,
|
||||
0,
|
||||
ControlMessage::PresetRecall {
|
||||
preset_id: preset.preset_id.clone(),
|
||||
transition,
|
||||
},
|
||||
),
|
||||
],
|
||||
realtime: vec![RealtimeEnvelope::new(
|
||||
(index as u32) + 1,
|
||||
0,
|
||||
RealtimeMessage::SceneParameters(SceneParametersFrame {
|
||||
node_id: node.node_id.clone(),
|
||||
mode,
|
||||
preset_id: Some(preset.preset_id.clone()),
|
||||
effect: preset.scene.effect.clone(),
|
||||
seed: preset.scene.seed,
|
||||
palette: preset.scene.palette.clone(),
|
||||
master_brightness: safety
|
||||
.map(|profile| profile.default_start_brightness)
|
||||
.unwrap_or(0.2),
|
||||
speed: preset.scene.speed,
|
||||
intensity: preset.scene.intensity,
|
||||
target_group: preset.target_group.clone(),
|
||||
target_outputs: targets,
|
||||
}),
|
||||
)],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_panel_slot(position: &PanelPosition) -> PanelSlot {
|
||||
match position {
|
||||
PanelPosition::Top => PanelSlot::Top,
|
||||
PanelPosition::Middle => PanelSlot::Middle,
|
||||
PanelPosition::Bottom => PanelSlot::Bottom,
|
||||
}
|
||||
}
|
||||
10
crates/infinity_protocol/Cargo.toml
Normal file
10
crates/infinity_protocol/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "infinity_protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
206
crates/infinity_protocol/src/lib.rs
Normal file
206
crates/infinity_protocol/src/lib.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CONTROL_PROTOCOL_VERSION: u16 = 1;
|
||||
pub const REALTIME_PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ControlEnvelope {
|
||||
pub protocol_version: u16,
|
||||
pub sequence: u32,
|
||||
pub sent_at_millis: u64,
|
||||
pub message: ControlMessage,
|
||||
}
|
||||
|
||||
impl ControlEnvelope {
|
||||
pub fn new(sequence: u32, sent_at_millis: u64, message: ControlMessage) -> Self {
|
||||
Self {
|
||||
protocol_version: CONTROL_PROTOCOL_VERSION,
|
||||
sequence,
|
||||
sent_at_millis,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RealtimeEnvelope {
|
||||
pub protocol_version: u16,
|
||||
pub sequence: u32,
|
||||
pub sent_at_millis: u64,
|
||||
pub message: RealtimeMessage,
|
||||
}
|
||||
|
||||
impl RealtimeEnvelope {
|
||||
pub fn new(sequence: u32, sent_at_millis: u64, message: RealtimeMessage) -> Self {
|
||||
Self {
|
||||
protocol_version: REALTIME_PROTOCOL_VERSION,
|
||||
sequence,
|
||||
sent_at_millis,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ControlMessage {
|
||||
DiscoveryHello {
|
||||
host_id: String,
|
||||
},
|
||||
DiscoveryAck {
|
||||
node_id: String,
|
||||
firmware_version: String,
|
||||
capabilities: NodeCapabilities,
|
||||
},
|
||||
ConfigSync {
|
||||
topology_revision: String,
|
||||
outputs: Vec<PanelAssignment>,
|
||||
},
|
||||
HeartbeatRequest {
|
||||
node_id: String,
|
||||
},
|
||||
Heartbeat(NodeHeartbeat),
|
||||
PresetRecall {
|
||||
preset_id: String,
|
||||
transition: TransitionSpec,
|
||||
},
|
||||
Blackout {
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RealtimeMessage {
|
||||
SceneParameters(SceneParametersFrame),
|
||||
PixelFrame(PixelFrame),
|
||||
ResyncRequest {
|
||||
node_id: String,
|
||||
last_sequence_seen: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeCapabilities {
|
||||
pub outputs_per_node: u8,
|
||||
pub leds_per_output: u16,
|
||||
pub supported_driver_backends: Vec<String>,
|
||||
pub supports_scene_mode: bool,
|
||||
pub supports_frame_streaming: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NodeHeartbeat {
|
||||
pub node_id: String,
|
||||
pub fps: f32,
|
||||
pub heartbeat_age_ms: u32,
|
||||
pub dropped_frames: u32,
|
||||
pub reconnect_count: u32,
|
||||
pub free_heap_bytes: Option<u32>,
|
||||
pub temperature_celsius: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PanelAssignment {
|
||||
pub panel: PanelAddress,
|
||||
pub physical_output_name: String,
|
||||
pub driver_reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TransitionSpec {
|
||||
pub transition_ms: u32,
|
||||
pub mode: TransitionMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransitionMode {
|
||||
Snap,
|
||||
Crossfade,
|
||||
Chase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SceneParametersFrame {
|
||||
pub node_id: String,
|
||||
pub mode: RealtimeMode,
|
||||
pub preset_id: Option<String>,
|
||||
pub effect: String,
|
||||
pub seed: u64,
|
||||
pub palette: Vec<String>,
|
||||
pub master_brightness: f32,
|
||||
pub speed: f32,
|
||||
pub intensity: f32,
|
||||
pub target_group: Option<String>,
|
||||
pub target_outputs: Vec<PanelAddress>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PixelFrame {
|
||||
pub node_id: String,
|
||||
pub frame_index: u64,
|
||||
pub target_outputs: Vec<OutputFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OutputFrame {
|
||||
pub panel: PanelAddress,
|
||||
pub pixels: Vec<Rgb8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Rgb8 {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PanelAddress {
|
||||
pub node_id: String,
|
||||
pub panel_slot: PanelSlot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PanelSlot {
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RealtimeMode {
|
||||
DistributedScene,
|
||||
FrameStreaming,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constructors_stamp_current_protocol_versions() {
|
||||
let control = ControlEnvelope::new(
|
||||
4,
|
||||
123,
|
||||
ControlMessage::HeartbeatRequest {
|
||||
node_id: "node-01".to_string(),
|
||||
},
|
||||
);
|
||||
let realtime = RealtimeEnvelope::new(
|
||||
5,
|
||||
456,
|
||||
RealtimeMessage::ResyncRequest {
|
||||
node_id: "node-01".to_string(),
|
||||
last_sequence_seen: 4,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(control.protocol_version, CONTROL_PROTOCOL_VERSION);
|
||||
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user