Initial commit

This commit is contained in:
2026-04-17 01:33:23 +02:00
commit 1f6543bd7a
28 changed files with 2556 additions and 0 deletions

View 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" }

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

View 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,
}
}