**Core**
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:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
717
crates/infinity_host/src/scene.rs
Normal file
717
crates/infinity_host/src/scene.rs
Normal 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('#'));
|
||||
}
|
||||
}
|
||||
691
crates/infinity_host/src/simulation.rs
Normal file
691
crates/infinity_host/src/simulation.rs
Normal 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, ¤t, 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"));
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user