Die Host-Seite ist jetzt auf eine gemeinsame software-first API ausgerichtet. In [control.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/control.rs>) steckt jetzt das stabile gemeinsame Modell für Snapshots, Commands, Pattern-Katalog, Presets, Gruppen, Parameter, Preview und Übergänge. Darauf sitzen die neue Szenen-/Pattern-Schicht in [scene.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/scene.rs>) und der simulationsbasierte Host-Service in [simulation.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>).

Der neue Core kann jetzt softwareseitig schon:
- Pattern-Katalog mit `solid_color`, `gradient`, `chase`, `pulse`, `noise`, `walking_pixel`
- Preset-Recall, Gruppen-Targeting, Parameteränderungen und Übergänge
- simulierte Preview-Daten für alle 18 Outputs
- denselben API-Zugriff für CLI, Engineering-GUI und später Web-UI / grandMA-Adapter

Zusätzlich gibt es im Host-CLI jetzt `snapshot`, also eine direkte JSON-Sicht auf den gemeinsamen Host-State über [main.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/main.rs>).

**Oberflächen**
Die technische lokale GUI bleibt bestehen und hängt jetzt auf der neuen gemeinsamen API. In [app.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_ui/src/app.rs>) zeigt sie weiter Mapping/Status/Testmuster, ergänzt um Engine-/Szene-/Übergangsstatus. Sie bleibt bewusst Engineering-orientiert und ist nicht zur kreativen Hauptoberfläche aufgeblasen worden.

Die Beispielkonfiguration in [project.example.toml](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/config/project.example.toml>) ist jetzt auch als Software-Spielwiese brauchbarer: mehr Gruppen, mehr kreative Presets und bessere Basis für Look-Entwicklung ohne echte Node-Aktivierung. Die neue API-Ausrichtung ist in [host_api.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>) und [architecture.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>) dokumentiert.

**Verifikation**
`cargo check` und `cargo test -q` laufen erfolgreich. Zusätzlich läuft `cargo run -p infinity_host -- snapshot --config config/project.example.toml` und liefert den gemeinsamen Host-Snapshot mit Katalog, aktiver Szene, Preview, Node- und Panelstatus.

Der nächste sinnvolle Schritt ist jetzt ein echter API-Adapter fuer die kommende Web-UI, also HTTP/WebSocket auf genau diesem Host-Core statt einer frontend-spezifischen Parallelarchitektur.
This commit is contained in:
2026-04-17 11:39:56 +02:00
parent dde35551be
commit 9457666fd6
15 changed files with 6371 additions and 384 deletions

4509
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,21 @@ The repository is intentionally structured around hard separation of concerns:
- `crates/infinity_config`: versioned project configuration and validation
- `crates/infinity_protocol`: shared control and realtime protocol model
- `crates/infinity_host`: host-side core library, CLI, mock UI service, and runtime skeleton
- `crates/infinity_host_ui`: native Rust desktop GUI vertical slice
- `crates/infinity_host`: host-side core library, simulation engine, scene/preset API, and CLI
- `crates/infinity_host_ui`: native Rust desktop engineering GUI for mapping, diagnostics, and admin
- `firmware/esp32_node`: ESP-IDF firmware skeleton with explicit driver abstraction
- `docs/`: architecture, protocol, validation, deployment, testing, and acceptance artifacts
- `config/`: example configuration files
Current software priority:
- stable host-core first
- shared host API for every surface
- simulation and mock-first creative workflow
- engineering GUI for technical operation
- web UI to follow as the primary creative surface
- grandMA planned later as an external show-control adapter, not as the system core
The current baseline is intentionally strict about unresolved hardware facts. `UART 6`, `UART 5`, and `UART 4` are treated as unvalidated labels until the real electrical meaning is confirmed.
## Quick Start
@@ -24,12 +33,14 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
1. Install a current Rust toolchain.
2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md).
3. Start from [config/project.example.toml](config/project.example.toml).
4. Start the desktop GUI with `cargo run -p infinity_host_ui`.
5. Use the host CLI to validate the project config before attempting activation.
4. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`.
5. Start the engineering GUI with `cargo run -p infinity_host_ui`.
6. Use the host CLI to validate the project config before attempting activation.
## Docs
- [Architecture](docs/architecture.md)
- [Host API](docs/host_api.md)
- [Protocol](docs/protocol.md)
- [Config Schema](docs/config_schema.md)
- [Build and Deploy](docs/build_and_deploy.md)

View File

@@ -285,6 +285,30 @@ members = [
{ node_id = "node-06", panel_position = "top" },
]
[[topology.groups]]
group_id = "middle_panels"
tags = ["row_like", "hardware_safe"]
members = [
{ node_id = "node-01", panel_position = "middle" },
{ node_id = "node-02", panel_position = "middle" },
{ node_id = "node-03", panel_position = "middle" },
{ node_id = "node-04", panel_position = "middle" },
{ node_id = "node-05", panel_position = "middle" },
{ node_id = "node-06", panel_position = "middle" },
]
[[topology.groups]]
group_id = "bottom_panels"
tags = ["row_like", "hardware_safe"]
members = [
{ node_id = "node-01", panel_position = "bottom" },
{ node_id = "node-02", panel_position = "bottom" },
{ node_id = "node-03", panel_position = "bottom" },
{ node_id = "node-04", panel_position = "bottom" },
{ node_id = "node-05", panel_position = "bottom" },
{ node_id = "node-06", panel_position = "bottom" },
]
[[transport_profiles]]
profile_id = "scene_default"
mode = "distributed_scene"
@@ -335,3 +359,38 @@ speed = 1.0
intensity = 1.0
blackout = false
[[presets]]
preset_id = "ocean_gradient"
target_group = "all_panels"
transition_ms = 500
[presets.scene]
effect = "gradient"
seed = 1337
palette = ["#041F4A", "#0F8AA6", "#7FFFD4"]
speed = 0.35
intensity = 0.82
blackout = false
[[presets]]
preset_id = "amber_chase_top"
target_group = "top_panels"
transition_ms = 120
[presets.scene]
effect = "chase"
seed = 2026
palette = ["#FF7A00", "#FFD166"]
speed = 1.40
intensity = 1.0
blackout = false
[[presets]]
preset_id = "deep_pulse_bottom"
target_group = "bottom_panels"
transition_ms = 380
[presets.scene]
effect = "pulse"
seed = 404
palette = ["#240046", "#5A189A", "#9D4EDD"]
speed = 0.80
intensity = 0.88
blackout = false

View File

@@ -506,9 +506,9 @@ fn display_panel_position(position: &PanelPosition) -> &'static str {
mod tests {
use super::*;
use crate::{
ColorOrder, DriverChannelRef, LedDirection, NodeConfig, NodeNetworkConfig, PanelOutputConfig,
PanelPosition, PresetConfig, ProjectMetadata, SceneConfig, TopologyConfig, TransportMode,
TransportProfileConfig,
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 {

View File

@@ -1,24 +1,172 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
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, PartialEq)]
#[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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[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>,
}
#[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, 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,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
}
#[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,
@@ -29,7 +177,7 @@ pub struct NodeSnapshot {
pub panel_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelSnapshot {
pub target: PanelTarget,
pub physical_output_name: String,
@@ -44,46 +192,64 @@ pub struct PanelSnapshot {
pub error_status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusEvent {
pub at_millis: u64,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeConnectionState {
Online,
Degraded,
Offline,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelTarget {
pub node_id: String,
pub panel_position: PanelPosition,
}
#[derive(Debug, Clone, PartialEq)]
#[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),
TriggerPanelTest {
target: PanelTarget,
pattern: TestPatternKind,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TestPatternKind {
WalkingPixel106,
}
pub trait HostUiPort: Send + Sync {
pub trait HostApiPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn send_command(&self, command: HostCommand);
}
pub trait HostUiPort: HostApiPort {}
impl<T: HostApiPort + ?Sized> HostUiPort for T {}
impl NodeConnectionState {
pub fn label(self) -> &'static str {
match self {
@@ -102,3 +268,22 @@ impl TestPatternKind {
}
}
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())
}
}

View File

@@ -1,8 +1,9 @@
pub mod control;
pub mod mock;
pub mod runtime;
pub mod scene;
pub mod simulation;
pub use control::*;
pub use mock::*;
pub use runtime::*;
pub use scene::*;
pub use simulation::*;

View File

@@ -1,6 +1,6 @@
use clap::{Parser, Subcommand, ValueEnum};
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
use infinity_host::RealtimeEngine;
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
use std::{path::PathBuf, process::ExitCode};
#[derive(Debug, Parser)]
@@ -24,6 +24,10 @@ enum Command {
#[arg(long)]
preset_id: String,
},
Snapshot {
#[arg(long)]
config: PathBuf,
},
OpenValidationPoints,
}
@@ -47,6 +51,7 @@ fn main() -> ExitCode {
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
@@ -113,6 +118,28 @@ fn plan_boot_scene_command(config: PathBuf, preset_id: &str) -> ExitCode {
}
}
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:",

View File

@@ -1,351 +0,0 @@
use crate::control::{
GlobalControlSnapshot, HostCommand, HostSnapshot, HostUiPort, NodeConnectionState,
NodeSnapshot, PanelSnapshot, PanelTarget, StatusEvent, TestPatternKind,
};
use infinity_config::{PanelPosition, ProjectConfig};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
const MAX_RECENT_EVENTS: usize = 10;
#[derive(Debug)]
pub struct MockHostService {
inner: Arc<Mutex<MockHostState>>,
}
#[derive(Debug)]
struct MockHostState {
started_at: Instant,
tick_count: u64,
snapshot: HostSnapshot,
}
impl MockHostService {
pub fn spawn(project: ProjectConfig) -> Arc<dyn HostUiPort> {
let service = Arc::new(Self {
inner: Arc::new(Mutex::new(MockHostState::new(project))),
});
Self::spawn_simulation_loop(Arc::clone(&service));
service
}
fn spawn_simulation_loop(service: Arc<Self>) {
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(250));
if let Ok(mut state) = service.inner.lock() {
state.simulate_tick();
}
});
}
}
impl HostUiPort for MockHostService {
fn snapshot(&self) -> HostSnapshot {
self.inner
.lock()
.map(|state| state.snapshot.clone())
.unwrap_or_else(|_| HostSnapshot {
backend_label: "mock-simulation".to_string(),
generated_at_millis: 0,
global: GlobalControlSnapshot {
blackout: true,
master_brightness: 0.0,
selected_pattern: "unavailable".to_string(),
},
available_patterns: vec!["unavailable".to_string()],
nodes: Vec::new(),
panels: Vec::new(),
recent_events: vec![StatusEvent {
at_millis: 0,
message: "snapshot unavailable because the simulation lock was poisoned"
.to_string(),
}],
})
}
fn send_command(&self, command: HostCommand) {
if let Ok(mut state) = self.inner.lock() {
state.apply_command(command);
}
}
}
impl MockHostState {
fn new(project: ProjectConfig) -> Self {
let patterns = pattern_list_from_project(&project);
let selected_pattern = patterns
.first()
.cloned()
.unwrap_or_else(|| "solid_color".to_string());
let mut nodes = Vec::new();
let mut panels = Vec::new();
for node in &project.topology.nodes {
nodes.push(NodeSnapshot {
node_id: node.node_id.clone(),
display_name: node.display_name.clone(),
reserved_ip: node.network.reserved_ip.clone(),
connection: NodeConnectionState::Online,
last_contact_ms: 14,
error_status: None,
panel_count: node.outputs.len(),
});
for output in &node.outputs {
panels.push(PanelSnapshot {
target: PanelTarget {
node_id: node.node_id.clone(),
panel_position: output.panel_position.clone(),
},
physical_output_name: output.physical_output_name.clone(),
driver_reference: output.driver_channel.reference.clone(),
led_count: output.led_count,
direction: output.direction.clone(),
color_order: output.color_order.clone(),
enabled: output.enabled,
validation_state: output.validation_state.clone(),
connection: NodeConnectionState::Online,
last_test_trigger_ms: None,
error_status: None,
});
}
}
let mut state = Self {
started_at: Instant::now(),
tick_count: 0,
snapshot: HostSnapshot {
backend_label: "mock-simulation".to_string(),
generated_at_millis: 0,
global: GlobalControlSnapshot {
blackout: false,
master_brightness: 0.20,
selected_pattern,
},
available_patterns: patterns,
nodes,
panels,
recent_events: Vec::new(),
},
};
state.push_event("mock backend started".to_string());
state.simulate_tick();
state
}
fn simulate_tick(&mut self) {
self.tick_count += 1;
self.snapshot.generated_at_millis = self.elapsed_millis();
let previous_states: BTreeMap<_, _> = self
.snapshot
.nodes
.iter()
.map(|node| (node.node_id.clone(), node.connection))
.collect();
let mut transition_messages = Vec::new();
for (index, node) in self.snapshot.nodes.iter_mut().enumerate() {
let connection = simulated_connection_state(index, self.tick_count);
node.connection = connection;
node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection);
node.error_status = simulated_error_status(connection);
if previous_states
.get(&node.node_id)
.copied()
.unwrap_or(NodeConnectionState::Offline)
!= connection
{
transition_messages.push(format!(
"{} is now {}",
node.display_name,
connection.label()
));
}
}
for message in transition_messages {
self.push_event(message);
}
let node_states: BTreeMap<_, _> = self
.snapshot
.nodes
.iter()
.map(|node| (node.node_id.clone(), (node.connection, node.error_status.clone())))
.collect();
for panel in &mut self.snapshot.panels {
if let Some((connection, node_error)) = node_states.get(&panel.target.node_id) {
panel.connection = *connection;
panel.error_status = match (node_error, panel.enabled) {
(_, false) => Some("output disabled".to_string()),
(Some(error), _) => Some(error.clone()),
(None, true) => None,
};
}
}
}
fn apply_command(&mut self, command: HostCommand) {
match command {
HostCommand::SetBlackout(enabled) => {
self.snapshot.global.blackout = enabled;
self.push_event(if enabled {
"global blackout enabled".to_string()
} else {
"global blackout cleared".to_string()
});
}
HostCommand::SetMasterBrightness(value) => {
self.snapshot.global.master_brightness = value.clamp(0.0, 1.0);
self.push_event(format!(
"master brightness set to {:.0}%",
self.snapshot.global.master_brightness * 100.0
));
}
HostCommand::SelectPattern(pattern) => {
if self.snapshot.available_patterns.iter().any(|entry| entry == &pattern) {
self.snapshot.global.selected_pattern = pattern.clone();
self.push_event(format!("pattern selected: {pattern}"));
} else {
self.push_event(format!("ignored unknown pattern request: {pattern}"));
}
}
HostCommand::TriggerPanelTest { target, pattern } => {
let now = self.elapsed_millis();
let event_message = if let Some(panel) = self
.snapshot
.panels
.iter_mut()
.find(|panel| panel.target == target)
{
panel.last_test_trigger_ms = Some(now);
format!(
"test '{}' triggered for {}:{}",
pattern.label(),
panel.target.node_id,
panel_position_label(&panel.target.panel_position)
)
} else {
format!(
"test '{}' requested for unknown panel {}:{}",
pattern.label(),
target.node_id,
panel_position_label(&target.panel_position)
)
};
self.push_event(event_message);
}
}
}
fn push_event(&mut self, message: String) {
self.snapshot.recent_events.insert(
0,
StatusEvent {
at_millis: self.elapsed_millis(),
message,
},
);
self.snapshot.recent_events.truncate(MAX_RECENT_EVENTS);
}
fn elapsed_millis(&self) -> u64 {
self.started_at.elapsed().as_millis() as u64
}
}
fn pattern_list_from_project(project: &ProjectConfig) -> Vec<String> {
let mut patterns = BTreeSet::new();
for preset in &project.presets {
patterns.insert(preset.scene.effect.clone());
}
for fallback in ["solid_color", "gradient", "chase", "noise", "walking_pixel"] {
patterns.insert(fallback.to_string());
}
patterns.into_iter().collect()
}
fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState {
match index {
4 => {
if tick_count % 24 < 8 {
NodeConnectionState::Degraded
} else {
NodeConnectionState::Online
}
}
5 => {
if tick_count % 32 < 7 {
NodeConnectionState::Offline
} else {
NodeConnectionState::Online
}
}
_ => NodeConnectionState::Online,
}
}
fn simulated_last_contact_ms(
index: usize,
tick_count: u64,
connection: NodeConnectionState,
) -> u64 {
match connection {
NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6),
NodeConnectionState::Degraded => 180 + (tick_count % 90),
NodeConnectionState::Offline => 2_500 + (tick_count % 700),
}
}
fn simulated_error_status(connection: NodeConnectionState) -> Option<String> {
match connection {
NodeConnectionState::Online => None,
NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()),
NodeConnectionState::Offline => Some("awaiting reconnect".to_string()),
}
}
fn panel_position_label(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
#[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("embedded project config must parse")
}
#[test]
fn mock_service_starts_with_six_nodes_and_eighteen_panels() {
let service = MockHostService::spawn(sample_project());
let snapshot = service.snapshot();
assert_eq!(snapshot.nodes.len(), 6);
assert_eq!(snapshot.panels.len(), 18);
}
#[test]
fn commands_update_global_state() {
let service = MockHostService::spawn(sample_project());
service.send_command(HostCommand::SetBlackout(true));
service.send_command(HostCommand::SelectPattern("walking_pixel".to_string()));
let snapshot = service.snapshot();
assert!(snapshot.global.blackout);
assert_eq!(snapshot.global.selected_pattern, "walking_pixel");
}
}

View File

@@ -0,0 +1,717 @@
use crate::control::{
ActiveSceneSnapshot, CatalogSnapshot, GroupSummary, PatternDefinition, PresetSummary,
SceneParameterKind, SceneParameterSpec, SceneParameterState, SceneParameterValue,
SceneTransitionStyle, TransitionSnapshot,
};
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
use std::{
collections::{BTreeMap, BTreeSet},
hash::{Hash, Hasher},
time::Instant,
};
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
#[derive(Debug, Clone)]
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,
})
.collect(),
groups: project
.topology
.groups
.iter()
.map(|group| GroupSummary {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
})
.collect(),
}
}
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 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,
}
}
fn scene_from_preset(&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 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,691 @@
use crate::{
control::{
EngineSnapshot, GlobalControlSnapshot, HostApiPort, HostCommand, HostSnapshot,
HOST_API_VERSION, NodeConnectionState, NodeSnapshot, PanelSnapshot, PanelTarget,
PreviewPanelSnapshot, PreviewSource, StatusEvent, SystemSnapshot,
},
runtime::TickSchedule,
scene::{
apply_group_gate, blackout_preview, blend_previews, build_group_members,
panel_membership_key, panel_test_preview, transition_style_from_duration, PatternRegistry,
RenderedPreview, SceneRuntime, TransitionRuntime,
},
};
use infinity_config::{PanelPosition, ProjectConfig};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
const MAX_RECENT_EVENTS: usize = 16;
const PANEL_TEST_HOLD_MS: u64 = 1_400;
#[derive(Debug)]
pub struct SimulationHostService {
inner: Arc<Mutex<SimulationState>>,
}
#[derive(Debug)]
struct SimulationState {
project: ProjectConfig,
registry: PatternRegistry,
group_members: BTreeMap<String, std::collections::BTreeSet<String>>,
started_at: Instant,
next_seed: u64,
tick_count: u64,
frame_index: u64,
dropped_frames: u64,
schedule: TickSchedule,
current_scene: SceneRuntime,
active_transition: Option<TransitionRuntime>,
snapshot: HostSnapshot,
}
impl SimulationHostService {
pub fn new(project: ProjectConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(SimulationState::new(project))),
}
}
pub fn spawn_shared(project: ProjectConfig) -> Arc<Self> {
let service = Arc::new(Self::new(project));
Self::spawn_simulation_loop(Arc::clone(&service));
service
}
fn spawn_simulation_loop(service: Arc<Self>) {
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(80));
if let Ok(mut state) = service.inner.lock() {
state.simulate_tick();
}
});
}
}
impl HostApiPort for SimulationHostService {
fn snapshot(&self) -> HostSnapshot {
self.inner
.lock()
.map(|state| state.snapshot.clone())
.unwrap_or_else(|_| unavailable_snapshot())
}
fn send_command(&self, command: HostCommand) {
if let Ok(mut state) = self.inner.lock() {
state.apply_command(command);
}
}
}
impl SimulationState {
fn new(project: ProjectConfig) -> Self {
let registry = PatternRegistry::new();
let group_members = build_group_members(&project);
let schedule = TickSchedule::default();
let current_scene = registry.initial_scene(&project);
let catalog = registry.catalog(&project);
let available_patterns = catalog
.patterns
.iter()
.map(|pattern| pattern.pattern_id.clone())
.collect::<Vec<_>>();
let nodes = project
.topology
.nodes
.iter()
.map(|node| NodeSnapshot {
node_id: node.node_id.clone(),
display_name: node.display_name.clone(),
reserved_ip: node.network.reserved_ip.clone(),
connection: NodeConnectionState::Online,
last_contact_ms: 10,
error_status: None,
panel_count: node.outputs.len(),
})
.collect::<Vec<_>>();
let panels = project
.topology
.nodes
.iter()
.flat_map(|node| {
node.outputs.iter().map(move |output| PanelSnapshot {
target: PanelTarget {
node_id: node.node_id.clone(),
panel_position: output.panel_position.clone(),
},
physical_output_name: output.physical_output_name.clone(),
driver_reference: output.driver_channel.reference.clone(),
led_count: output.led_count,
direction: output.direction.clone(),
color_order: output.color_order.clone(),
enabled: output.enabled,
validation_state: output.validation_state.clone(),
connection: NodeConnectionState::Online,
last_test_trigger_ms: None,
error_status: None,
})
})
.collect::<Vec<_>>();
let mut state = Self {
project: project.clone(),
registry,
group_members,
started_at: Instant::now(),
next_seed: 100,
tick_count: 0,
frame_index: 0,
dropped_frames: 0,
schedule: schedule.clone(),
current_scene,
active_transition: None,
snapshot: HostSnapshot {
api_version: HOST_API_VERSION,
backend_label: "simulation-core".to_string(),
generated_at_millis: 0,
system: SystemSnapshot {
project_name: project.metadata.project_name.clone(),
schema_version: project.metadata.schema_version,
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
},
global: GlobalControlSnapshot {
blackout: false,
master_brightness: 0.20,
selected_pattern: "solid_color".to_string(),
selected_group: None,
transition_duration_ms: 150,
},
engine: EngineSnapshot {
logic_hz: schedule.logic_hz,
frame_hz: schedule.frame_synthesis_hz,
preview_hz: schedule.preview_hz,
uptime_ms: 0,
frame_index: 0,
dropped_frames: 0,
active_transition: None,
},
catalog,
active_scene: crate::control::ActiveSceneSnapshot {
preset_id: None,
pattern_id: "solid_color".to_string(),
seed: 0,
palette: vec!["#ffffff".to_string()],
parameters: Vec::new(),
target_group: None,
blackout: false,
},
preview: crate::control::PreviewSnapshot { panels: Vec::new() },
available_patterns,
nodes,
panels,
recent_events: Vec::new(),
},
};
state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone();
state.snapshot.global.selected_group = state.current_scene.target_group.clone();
state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene);
state.push_event("simulation host service started".to_string());
state.simulate_tick();
state
}
fn simulate_tick(&mut self) {
self.tick_count += 1;
let elapsed_ms = self.elapsed_millis();
self.frame_index = elapsed_ms * self.schedule.frame_synthesis_hz as u64 / 1_000;
self.snapshot.generated_at_millis = elapsed_ms;
self.snapshot.engine.uptime_ms = elapsed_ms;
self.snapshot.engine.frame_index = self.frame_index;
self.snapshot.engine.dropped_frames = self.dropped_frames;
self.snapshot.engine.logic_hz = self.schedule.logic_hz;
self.snapshot.engine.frame_hz = self.schedule.frame_synthesis_hz;
self.snapshot.engine.preview_hz = self.schedule.preview_hz;
self.update_node_states();
self.update_panel_states();
self.resolve_transition_if_complete();
self.snapshot.engine.active_transition = self
.active_transition
.as_ref()
.map(|transition| self.registry.transition_snapshot(&self.current_scene, transition));
self.snapshot.active_scene = self.registry.active_scene_snapshot(&self.current_scene);
self.snapshot.global.selected_pattern = self.current_scene.pattern_id.clone();
self.snapshot.global.selected_group = self.current_scene.target_group.clone();
self.snapshot.preview.panels = self.render_preview_panels(elapsed_ms);
}
fn apply_command(&mut self, command: HostCommand) {
match command {
HostCommand::SetBlackout(enabled) => {
self.snapshot.global.blackout = enabled;
self.push_event(if enabled {
"global blackout enabled".to_string()
} else {
"global blackout released".to_string()
});
}
HostCommand::SetMasterBrightness(value) => {
self.snapshot.global.master_brightness = value.clamp(0.0, 1.0);
self.push_event(format!(
"master brightness set to {:.0}%",
self.snapshot.global.master_brightness * 100.0
));
}
HostCommand::SelectPattern(pattern_id) => {
let mut new_scene = self.registry.scene_for_pattern(
&pattern_id,
None,
self.current_scene.target_group.clone(),
self.next_seed,
self.current_scene.palette.clone(),
false,
);
self.next_seed += 1;
if let Some(speed) = self.current_scene.parameters.get("speed").cloned() {
self.registry.set_scene_parameter(&mut new_scene, "speed", speed);
}
if let Some(intensity) = self.current_scene.parameters.get("intensity").cloned() {
self.registry
.set_scene_parameter(&mut new_scene, "intensity", intensity);
}
let duration_ms = self.snapshot.global.transition_duration_ms;
self.start_scene_transition(
new_scene,
duration_ms,
transition_style_from_duration(duration_ms),
format!("pattern selected: {pattern_id}"),
);
}
HostCommand::RecallPreset { preset_id } => {
if let Some(scene) = self.registry.scene_from_preset_id(&self.project, &preset_id) {
let duration_ms = self
.project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| preset.transition_ms)
.unwrap_or(self.snapshot.global.transition_duration_ms);
self.snapshot.global.transition_duration_ms = duration_ms;
self.start_scene_transition(
scene,
duration_ms,
self.registry
.transition_style_for_preset(&self.project, &preset_id),
format!("preset recalled: {preset_id}"),
);
} else {
self.push_event(format!("ignored unknown preset request: {preset_id}"));
}
}
HostCommand::SelectGroup { group_id } => {
self.current_scene.target_group = group_id.clone();
self.snapshot.global.selected_group = group_id.clone();
self.current_scene.preset_id = None;
self.push_event(format!(
"target group set to {}",
group_id.as_deref().unwrap_or("all_panels")
));
}
HostCommand::SetSceneParameter { key, value } => {
self.registry
.set_scene_parameter(&mut self.current_scene, &key, value.clone());
self.current_scene.preset_id = None;
self.push_event(format!("scene parameter updated: {key} = {value:?}"));
}
HostCommand::SetTransitionDurationMs(duration_ms) => {
self.snapshot.global.transition_duration_ms = duration_ms;
self.push_event(format!(
"default transition duration set to {} ms",
duration_ms
));
}
HostCommand::TriggerPanelTest { target, pattern } => {
let now = self.elapsed_millis();
let mut message = format!(
"test '{}' requested for unknown panel {}:{}",
pattern.label(),
target.node_id,
panel_position_label(&target.panel_position)
);
if let Some(panel) = self
.snapshot
.panels
.iter_mut()
.find(|panel| panel.target == target)
{
panel.last_test_trigger_ms = Some(now);
message = format!(
"test '{}' triggered for {}:{}",
pattern.label(),
target.node_id,
panel_position_label(&target.panel_position)
);
}
self.push_event(message);
}
}
self.simulate_tick();
}
fn start_scene_transition(
&mut self,
new_scene: SceneRuntime,
duration_ms: u32,
style: crate::control::SceneTransitionStyle,
event_message: String,
) {
let previous_scene = self.current_scene.clone();
self.current_scene = new_scene;
self.active_transition = if duration_ms == 0 {
None
} else {
Some(TransitionRuntime {
style,
duration_ms,
started_at: Instant::now(),
from_scene: previous_scene,
})
};
self.push_event(event_message);
}
fn resolve_transition_if_complete(&mut self) {
let finished = self
.active_transition
.as_ref()
.map(|transition| self.registry.transition_finished(transition))
.unwrap_or(false);
if finished {
self.active_transition = None;
self.push_event(format!(
"transition completed to {}",
self.current_scene.pattern_id
));
}
}
fn update_node_states(&mut self) {
let previous_states: BTreeMap<_, _> = self
.snapshot
.nodes
.iter()
.map(|node| (node.node_id.clone(), node.connection))
.collect();
let mut transition_messages = Vec::new();
for (index, node) in self.snapshot.nodes.iter_mut().enumerate() {
let connection = simulated_connection_state(index, self.tick_count);
node.connection = connection;
node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection);
node.error_status = simulated_error_status(connection);
if previous_states
.get(&node.node_id)
.copied()
.unwrap_or(NodeConnectionState::Offline)
!= connection
{
transition_messages.push(format!(
"{} is now {}",
node.display_name,
connection.label()
));
}
}
for message in transition_messages {
self.push_event(message);
}
}
fn update_panel_states(&mut self) {
let node_states: BTreeMap<_, _> = self
.snapshot
.nodes
.iter()
.map(|node| (node.node_id.clone(), (node.connection, node.error_status.clone())))
.collect();
for panel in &mut self.snapshot.panels {
if let Some((connection, node_error)) = node_states.get(&panel.target.node_id) {
panel.connection = *connection;
panel.error_status = match (node_error, panel.enabled) {
(_, false) => Some("output disabled".to_string()),
(Some(error), _) => Some(error.clone()),
(None, true) => None,
};
}
}
}
fn render_preview_panels(&self, elapsed_ms: u64) -> Vec<PreviewPanelSnapshot> {
self.snapshot
.panels
.iter()
.enumerate()
.map(|(panel_index, panel)| {
let preview = self.render_preview_for_panel(panel, panel_index, elapsed_ms);
PreviewPanelSnapshot {
target: panel.target.clone(),
representative_color_hex: preview.0.representative_color_hex,
sample_led_hex: preview.0.sample_led_hex,
energy_percent: preview.0.energy_percent,
preview_source: preview.1,
}
})
.collect()
}
fn render_preview_for_panel(
&self,
panel: &PanelSnapshot,
panel_index: usize,
elapsed_ms: u64,
) -> (RenderedPreview, PreviewSource) {
if self.snapshot.global.blackout || self.current_scene.blackout || !panel.enabled {
return (blackout_preview(), PreviewSource::Blackout);
}
if let Some(last_trigger_ms) = panel.last_test_trigger_ms {
let age = elapsed_ms.saturating_sub(last_trigger_ms);
if age <= PANEL_TEST_HOLD_MS {
let preview = scale_preview(
panel_test_preview(age),
self.snapshot.global.master_brightness,
);
return (preview, PreviewSource::PanelTest);
}
}
let panel_count = self.snapshot.panels.len();
let current = self
.registry
.render_preview(&self.current_scene, panel_index, panel_count, elapsed_ms);
let mut source = PreviewSource::Scene;
let mut preview = if let Some(transition) = &self.active_transition {
let from = self
.registry
.render_preview(&transition.from_scene, panel_index, panel_count, elapsed_ms);
let progress = self
.registry
.transition_snapshot(&self.current_scene, transition)
.progress;
source = PreviewSource::Transition;
blend_previews(&from, &current, progress)
} else {
current
};
if let Some(group_id) = &self.current_scene.target_group {
let membership_key = panel_membership_key(
&panel.target.node_id,
panel_position_label(&panel.target.panel_position),
);
let active_in_group = self
.group_members
.get(group_id)
.map(|members| members.contains(&membership_key))
.unwrap_or(false);
preview = apply_group_gate(&preview, active_in_group);
}
(scale_preview(preview, self.snapshot.global.master_brightness), source)
}
fn push_event(&mut self, message: String) {
self.snapshot.recent_events.insert(
0,
StatusEvent {
at_millis: self.elapsed_millis(),
message,
},
);
self.snapshot.recent_events.truncate(MAX_RECENT_EVENTS);
}
fn elapsed_millis(&self) -> u64 {
self.started_at.elapsed().as_millis() as u64
}
}
fn unavailable_snapshot() -> HostSnapshot {
HostSnapshot {
api_version: HOST_API_VERSION,
backend_label: "simulation-unavailable".to_string(),
generated_at_millis: 0,
system: SystemSnapshot {
project_name: "Unavailable".to_string(),
schema_version: 0,
topology_label: "unknown".to_string(),
},
global: GlobalControlSnapshot {
blackout: true,
master_brightness: 0.0,
selected_pattern: "unavailable".to_string(),
selected_group: None,
transition_duration_ms: 0,
},
engine: EngineSnapshot {
logic_hz: 0,
frame_hz: 0,
preview_hz: 0,
uptime_ms: 0,
frame_index: 0,
dropped_frames: 0,
active_transition: None,
},
catalog: crate::control::CatalogSnapshot {
patterns: Vec::new(),
presets: Vec::new(),
groups: Vec::new(),
},
active_scene: crate::control::ActiveSceneSnapshot {
preset_id: None,
pattern_id: "unavailable".to_string(),
seed: 0,
palette: Vec::new(),
parameters: Vec::new(),
target_group: None,
blackout: true,
},
preview: crate::control::PreviewSnapshot { panels: Vec::new() },
available_patterns: Vec::new(),
nodes: Vec::new(),
panels: Vec::new(),
recent_events: vec![StatusEvent {
at_millis: 0,
message: "simulation service lock was unavailable".to_string(),
}],
}
}
fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState {
match index {
4 => {
if tick_count % 24 < 8 {
NodeConnectionState::Degraded
} else {
NodeConnectionState::Online
}
}
5 => {
if tick_count % 32 < 7 {
NodeConnectionState::Offline
} else {
NodeConnectionState::Online
}
}
_ => NodeConnectionState::Online,
}
}
fn simulated_last_contact_ms(
index: usize,
tick_count: u64,
connection: NodeConnectionState,
) -> u64 {
match connection {
NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6),
NodeConnectionState::Degraded => 180 + (tick_count % 90),
NodeConnectionState::Offline => 2_500 + (tick_count % 700),
}
}
fn simulated_error_status(connection: NodeConnectionState) -> Option<String> {
match connection {
NodeConnectionState::Online => None,
NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()),
NodeConnectionState::Offline => Some("awaiting reconnect".to_string()),
}
}
fn panel_position_label(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn scale_preview(mut preview: RenderedPreview, factor: f32) -> RenderedPreview {
let factor = factor.clamp(0.0, 1.0);
preview.representative_color_hex = scale_hex_color(&preview.representative_color_hex, factor);
preview.sample_led_hex = preview
.sample_led_hex
.into_iter()
.map(|hex| scale_hex_color(&hex, factor))
.collect();
preview.energy_percent = ((preview.energy_percent as f32) * factor).round() as u8;
preview
}
fn scale_hex_color(hex: &str, factor: f32) -> String {
let raw = hex.trim().trim_start_matches('#');
if raw.len() == 6 {
if let Ok(value) = u32::from_str_radix(raw, 16) {
let r = (((value >> 16) & 0xff) as f32 * factor).round() as u8;
let g = (((value >> 8) & 0xff) as f32 * factor).round() as u8;
let b = ((value & 0xff) as f32 * factor).round() as u8;
return format!("#{:02X}{:02X}{:02X}", r, g, b);
}
}
"#000000".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SceneParameterValue;
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 simulation_service_exposes_catalog_and_preview() {
let service = SimulationHostService::new(sample_project());
let snapshot = service.snapshot();
assert!(snapshot
.catalog
.presets
.iter()
.any(|preset| preset.preset_id == "amber_chase_top"));
assert_eq!(snapshot.preview.panels.len(), 18);
assert_eq!(snapshot.nodes.len(), 6);
}
#[test]
fn commands_update_scene_and_group() {
let service = SimulationHostService::new(sample_project());
service.send_command(HostCommand::SelectGroup {
group_id: Some("top_panels".to_string()),
});
service.send_command(HostCommand::RecallPreset {
preset_id: "mapping_walk_test".to_string(),
});
service.send_command(HostCommand::SetSceneParameter {
key: "speed".to_string(),
value: SceneParameterValue::Scalar(2.0),
});
let snapshot = service.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "walking_pixel");
assert_eq!(snapshot.global.selected_group.as_deref(), Some("all_panels"));
assert!(snapshot
.active_scene
.parameters
.iter()
.any(|parameter| parameter.key == "speed"));
}
}

View File

@@ -84,7 +84,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
}
let selected_pattern = snapshot.global.selected_pattern.clone();
egui::ComboBox::from_id_source("pattern_selector")
egui::ComboBox::from_id_salt("pattern_selector")
.width(180.0)
.selected_text(selected_pattern.clone())
.show_ui(ui, |ui| {
@@ -202,6 +202,41 @@ fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
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| {

View File

@@ -4,7 +4,7 @@ use app::HostUiApp;
use clap::Parser;
use eframe::egui;
use infinity_config::{load_project_from_path, ProjectConfig};
use infinity_host::MockHostService;
use infinity_host::SimulationHostService;
use std::path::PathBuf;
#[derive(Debug, Parser)]
@@ -17,7 +17,7 @@ struct Cli {
fn main() -> eframe::Result<()> {
let cli = Cli::parse();
let project = load_project(cli.config.as_deref());
let service = MockHostService::spawn(project);
let service = SimulationHostService::spawn_shared(project);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()

View File

@@ -4,14 +4,25 @@
Build a live-capable LED control platform that keeps realtime output deterministic while letting operators change scenes, brightness, tests, and presets without UI jitter leaking into the hot path.
## Current Priority
The current delivery order is intentionally software-first:
1. host-core and shared API
2. scene, preset, group, parameter, transition, and simulation model
3. web UI as the primary creative surface
4. engineering GUI as the technical surface
5. external show-control adapters such as grandMA
6. hardware validation and real node activation later
## Layer Split
1. Control layer
- Operator workflow
- Presets and topology editing
- Monitoring and diagnostics
- Shared host API first
- Creative web UI later
- Engineering GUI already implemented in `crates/infinity_host_ui`
- Monitoring, mapping, diagnostics, and admin
- Never the timing master for LED output
- First vertical slice is implemented as `crates/infinity_host_ui`
2. Realtime engine
- Owns the monotonic clock
- Computes scene state, transitions, and dirty regions
@@ -35,6 +46,17 @@ Build a live-capable LED control platform that keeps realtime output determinist
Preview and telemetry are explicitly degradable. Realtime output is not.
## Shared Surface Model
Every surface must talk to the same host API:
- engineering GUI
- future creative web UI
- CLI inspection
- future grandMA adapter
The current software-first implementation uses a simulation-backed host API so looks, presets, parameters, and grouping can be developed before real node activation.
## Modes
### Distributed Scene Mode
@@ -79,7 +101,7 @@ The codebase deliberately blocks activation when these remain unresolved:
## Planned Next Steps
1. Expand the new UI slice from mock service to real host transport adapters
2. Implement UDP transport with separate control and realtime sockets
3. Connect firmware driver backends after hardware validation
4. Add deterministic effect registry shared between host planning and firmware capability negotiation
1. Add a network-facing adapter for the shared host API and start the web UI
2. Expand scene authoring and preset editing on top of the existing simulation core
3. Implement transport adapters without coupling them to any single frontend
4. Keep hardware activation behind explicit later validation gates

View File

@@ -11,12 +11,13 @@ Suggested commands:
```powershell
cargo test
cargo run -p infinity_host -- snapshot --config config/project.example.toml
cargo run -p infinity_host_ui
cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural
cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue
```
The native UI currently runs against the host-core mock service so the operator workflow can be exercised before transport and firmware integration are complete.
The native engineering UI and the CLI snapshot currently run against the simulation-backed host API so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete.
Before any live activation, run:

80
docs/host_api.md Normal file
View File

@@ -0,0 +1,80 @@
# Host API
## Purpose
The host API is the stable boundary that all operator surfaces and later external show-control adapters must use.
Current rule:
- no UI is allowed to become the realtime clock
- no frontend-specific assumptions are allowed to leak into scene simulation or transport planning
- future grandMA support must land as an adapter on this API, not as a special-case core path
## Current Implementation
The API lives in:
- `crates/infinity_host/src/control.rs`
- `crates/infinity_host/src/scene.rs`
- `crates/infinity_host/src/simulation.rs`
The engineering GUI already consumes this API through the `HostApiPort` / `HostUiPort` trait boundary.
## Snapshot Model
`HostSnapshot` currently exposes:
- system metadata
- global controls
- engine timing and transition state
- catalog of patterns, presets, and groups
- active scene with parameter values
- simulated preview panels
- node and panel status
- recent event log
This makes it suitable as the shared read model for:
- engineering GUI
- upcoming web UI
- CLI inspection
- later external control bridges
## Command Model
`HostCommand` currently supports:
- blackout
- master brightness
- pattern selection
- preset recall
- group selection
- scene parameter changes
- transition duration changes
- per-panel test triggers
## Simulation Layer
The current `SimulationHostService` is not a throwaway mock. It is the software-first runtime for:
- look exploration
- preset development
- parameter tuning
- future web UI integration
- API contract testing before hardware activation
It simulates:
- active scene state
- pattern rendering previews
- group gating
- transitions
- node connectivity status
- per-panel mapping tests
## Near-Term Direction
1. Keep extending this API instead of adding surface-specific data paths.
2. Add a network-facing adapter for the same API when the web UI starts.
3. Keep engineering GUI focused on topology, mapping, diagnostics, and admin.
4. Add grandMA later as an external show-control adapter against this API.