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

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()