Die gemeinsame Plattform ist jetzt softwareseitig deutlich vollständiger. Der Host-Core hat mit [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) eine echte Runtime-Bibliothek und Persistenz für aktive Szene, Runtime-Presets, Runtime-Gruppen und kreative Varianten bekommen; die Simulation in [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>) liefert jetzt typisierte Command-Ergebnisse, saubere Fehlercodes und persistiert nach data/runtime_state.json. Dazu kommt das generische External-Show-Control-Interface in [external_control.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/external_control.rs>), damit spätere Adapter nur auf definierte Commands und Snapshot-/Preset-/Parameter-Flächen zugreifen.

Die API v1 ist als Produktgrenze geschärft in [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>): getrennte Modelle für `state`, `preview`, `snapshot`, `command response`, `event stream` und stabile Fehlerobjekte mit echten Codes statt generischem Fallback. Dazu kamen `GET /api/v1/state` und `GET /api/v1/preview`, neue persistenzbezogene Commands wie `save_preset`, `save_creative_snapshot`, `recall_creative_snapshot`, `set_transition_style` und `upsert_group`, plus serverseitige Durchreichung der echten Fehlercodes. Die kreative Web-UI in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>) nutzt jetzt genau diese API für Preset-Speichern/Überschreiben, Varianten, Transition-Style, filterbaren Eventfeed und klarere Preview-Darstellung, ohne Parallelarchitektur.

Die Doku ist auf den neuen Stand gezogen in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>), [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>) sowie neue Core-Tests in [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) und [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Nicht separat verifiziert habe ich einen echten Browserlauf der Web-UI; die JS-Datei wurde hier nicht mit `node` geprüft, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
2026-04-17 12:34:03 +02:00
parent a37a3c5cbe
commit 8e19f535ae
20 changed files with 2399 additions and 459 deletions

View File

@@ -9,5 +9,6 @@ authors.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
infinity_config = { path = "../infinity_config" }
infinity_protocol = { path = "../infinity_protocol" }

View File

@@ -34,6 +34,7 @@ pub struct GlobalControlSnapshot {
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -52,6 +53,7 @@ pub struct CatalogSnapshot {
pub patterns: Vec<PatternDefinition>,
pub presets: Vec<PresetSummary>,
pub groups: Vec<GroupSummary>,
pub creative_snapshots: Vec<CreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -62,12 +64,21 @@ pub struct PatternDefinition {
pub parameters: Vec<SceneParameterSpec>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CatalogSource {
BuiltIn,
RuntimeUser,
}
#[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,
pub transition_style: SceneTransitionStyle,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -75,6 +86,18 @@ pub struct GroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -195,9 +218,19 @@ pub struct PanelSnapshot {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusEvent {
pub at_millis: u64,
pub kind: StatusEventKind,
pub code: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StatusEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeConnectionState {
@@ -229,10 +262,29 @@ pub enum HostCommand {
value: SceneParameterValue,
},
SetTransitionDurationMs(u32),
SetTransitionStyle(SceneTransitionStyle),
TriggerPanelTest {
target: PanelTarget,
pattern: TestPatternKind,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -241,9 +293,21 @@ pub enum TestPatternKind {
WalkingPixel106,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandOutcome {
pub generated_at_millis: u64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostCommandError {
pub code: String,
pub message: String,
}
pub trait HostApiPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn send_command(&self, command: HostCommand);
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError>;
}
pub trait HostUiPort: HostApiPort {}
@@ -260,6 +324,35 @@ impl NodeConnectionState {
}
}
impl SceneTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl CatalogSource {
pub fn label(self) -> &'static str {
match self {
Self::BuiltIn => "built_in",
Self::RuntimeUser => "runtime_user",
}
}
}
impl StatusEventKind {
pub fn label(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
impl TestPatternKind {
pub fn label(self) -> &'static str {
match self {
@@ -287,3 +380,12 @@ impl SceneParameterValue {
Self::Text(value.into())
}
}
impl HostCommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}

View File

@@ -0,0 +1,124 @@
use crate::{
CommandOutcome, HostApiPort, HostCommand, HostCommandError, HostSnapshot, PanelTarget,
SceneParameterValue, SceneTransitionStyle,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "action", content = "payload")]
pub enum ExternalControlAction {
SetBlackout {
enabled: bool,
},
SetMasterBrightness {
value: f32,
},
SelectPattern {
pattern_id: String,
},
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: SceneParameterValue,
},
SetTransitionConfig {
duration_ms: u32,
style: SceneTransitionStyle,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
TriggerPanelTest {
target: PanelTarget,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalAdapterCapabilities {
pub supports_group_targeting: bool,
pub supports_preset_recall: bool,
pub supports_parameter_updates: bool,
pub supports_transition_config: bool,
pub supports_panel_tests: bool,
}
impl Default for ExternalAdapterCapabilities {
fn default() -> Self {
Self {
supports_group_targeting: true,
supports_preset_recall: true,
supports_parameter_updates: true,
supports_transition_config: true,
supports_panel_tests: false,
}
}
}
pub trait ExternalShowControlPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn execute_action(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError>;
}
pub trait ExternalShowControlAdapter: Send {
fn adapter_id(&self) -> &str;
fn capabilities(&self) -> ExternalAdapterCapabilities {
ExternalAdapterCapabilities::default()
}
fn translate(
&mut self,
action: ExternalControlAction,
) -> Result<Vec<HostCommand>, HostCommandError>;
}
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(self)
}
fn execute_action(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError> {
match action {
ExternalControlAction::SetBlackout { enabled } => {
self.send_command(HostCommand::SetBlackout(enabled))
}
ExternalControlAction::SetMasterBrightness { value } => {
self.send_command(HostCommand::SetMasterBrightness(value))
}
ExternalControlAction::SelectPattern { pattern_id } => {
self.send_command(HostCommand::SelectPattern(pattern_id))
}
ExternalControlAction::RecallPreset { preset_id } => {
self.send_command(HostCommand::RecallPreset { preset_id })
}
ExternalControlAction::SelectGroup { group_id } => {
self.send_command(HostCommand::SelectGroup { group_id })
}
ExternalControlAction::SetSceneParameter { key, value } => {
self.send_command(HostCommand::SetSceneParameter { key, value })
}
ExternalControlAction::SetTransitionConfig { duration_ms, style } => {
self.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
self.send_command(HostCommand::SetTransitionStyle(style))
}
ExternalControlAction::RecallCreativeSnapshot { snapshot_id } => {
self.send_command(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ExternalControlAction::TriggerPanelTest { target } => {
self.send_command(HostCommand::TriggerPanelTest {
target,
pattern: crate::TestPatternKind::WalkingPixel106,
})
}
}
}
}

View File

@@ -1,9 +1,13 @@
pub mod control;
pub mod runtime;
pub mod scene;
pub mod show_store;
pub mod simulation;
pub mod external_control;
pub use control::*;
pub use external_control::*;
pub use runtime::*;
pub use scene::*;
pub use show_store::*;
pub use simulation::*;

View File

@@ -1,9 +1,10 @@
use crate::control::{
ActiveSceneSnapshot, CatalogSnapshot, GroupSummary, PatternDefinition, PresetSummary,
SceneParameterKind, SceneParameterSpec, SceneParameterState, SceneParameterValue,
SceneTransitionStyle, TransitionSnapshot,
ActiveSceneSnapshot, CatalogSnapshot, CatalogSource, GroupSummary, PatternDefinition,
PresetSummary, SceneParameterKind, SceneParameterSpec, SceneParameterState,
SceneParameterValue, SceneTransitionStyle, TransitionSnapshot,
};
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
hash::{Hash, Hasher},
@@ -12,7 +13,7 @@ use std::{
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneRuntime {
pub preset_id: Option<String>,
pub pattern_id: String,
@@ -70,6 +71,8 @@ impl PatternRegistry {
pattern_id: preset.scene.effect.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_ms,
transition_style: transition_style_from_duration(preset.transition_ms),
source: CatalogSource::BuiltIn,
})
.collect(),
groups: project
@@ -80,8 +83,10 @@ impl PatternRegistry {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: CatalogSource::BuiltIn,
})
.collect(),
creative_snapshots: Vec::new(),
}
}
@@ -102,6 +107,10 @@ impl PatternRegistry {
})
}
pub fn pattern_definitions(&self) -> Vec<PatternDefinition> {
self.definitions.values().cloned().collect()
}
pub fn scene_from_preset_id(&self, project: &ProjectConfig, preset_id: &str) -> Option<SceneRuntime> {
project
.presets
@@ -246,7 +255,7 @@ impl PatternRegistry {
}
}
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
pub fn scene_from_preset_config(&self, preset: &PresetConfig) -> SceneRuntime {
let mut scene = self.scene_for_pattern(
&preset.scene.effect,
Some(preset.preset_id.clone()),
@@ -264,6 +273,10 @@ impl PatternRegistry {
scene
}
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
self.scene_from_preset_config(preset)
}
fn definition_or_default(&self, pattern_id: &str) -> &PatternDefinition {
self.definitions
.get(pattern_id)

View File

@@ -0,0 +1,605 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{SceneRuntime, PatternRegistry},
};
use infinity_config::{PanelPosition, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
pub const RUNTIME_STATE_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredPreset {
pub preset_id: String,
pub scene: SceneRuntime,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub source: CatalogSource,
pub updated_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoredGroup {
pub group_id: String,
pub tags: Vec<String>,
pub members: Vec<PanelTarget>,
pub source: CatalogSource,
pub updated_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredCreativeSnapshot {
pub snapshot_id: String,
pub label: Option<String>,
pub scene: SceneRuntime,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
pub source_preset_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PersistedGlobalState {
pub blackout: bool,
pub master_brightness: f32,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
impl Default for PersistedGlobalState {
fn default() -> Self {
Self {
blackout: false,
master_brightness: 0.20,
transition_duration_ms: 150,
transition_style: SceneTransitionStyle::Crossfade,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PersistedRuntimeState {
pub active_scene: Option<SceneRuntime>,
pub global: PersistedGlobalState,
pub user_presets: Vec<StoredPreset>,
pub user_groups: Vec<StoredGroup>,
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct RuntimeStateEnvelope {
schema_version: u16,
saved_at_unix_ms: u64,
runtime: PersistedRuntimeState,
}
#[derive(Debug, Clone)]
pub struct RuntimeStateStorage {
path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum ShowStoreError {
#[error("runtime state I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("runtime state parse failed: {0}")]
Parse(#[from] serde_json::Error),
#[error("{0}")]
Validation(String),
}
#[derive(Debug, Clone, Default)]
pub struct ShowStore {
presets: Vec<StoredPreset>,
groups: Vec<StoredGroup>,
creative_snapshots: Vec<StoredCreativeSnapshot>,
}
impl RuntimeStateStorage {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<PersistedRuntimeState, ShowStoreError> {
if !self.path.exists() {
return Ok(PersistedRuntimeState::default());
}
let raw = fs::read_to_string(&self.path)?;
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(&raw)?;
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
return Err(ShowStoreError::Validation(format!(
"unsupported runtime state schema version {} at {}",
envelope.schema_version,
self.path.display()
)));
}
Ok(envelope.runtime)
}
pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let envelope = RuntimeStateEnvelope {
schema_version: RUNTIME_STATE_SCHEMA_VERSION,
saved_at_unix_ms: now_unix_ms(),
runtime: runtime.clone(),
};
let payload = serde_json::to_string_pretty(&envelope)?;
fs::write(&self.path, payload)?;
Ok(())
}
}
impl ShowStore {
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
let presets = project
.presets
.iter()
.map(|preset| StoredPreset {
preset_id: preset.preset_id.clone(),
scene: registry.scene_from_preset_config(preset),
transition_duration_ms: preset.transition_ms,
transition_style: crate::scene::transition_style_from_duration(preset.transition_ms),
source: CatalogSource::BuiltIn,
updated_at_unix_ms: None,
})
.collect::<Vec<_>>();
let groups = project
.topology
.groups
.iter()
.map(|group| StoredGroup {
group_id: group.group_id.clone(),
tags: group.tags.clone(),
members: group
.members
.iter()
.map(|member| PanelTarget {
node_id: member.node_id.clone(),
panel_position: member.panel_position.clone(),
})
.collect(),
source: CatalogSource::BuiltIn,
updated_at_unix_ms: None,
})
.collect::<Vec<_>>();
Self {
presets,
groups,
creative_snapshots: Vec::new(),
}
}
pub fn apply_persisted(&mut self, runtime: PersistedRuntimeState) {
for preset in runtime.user_presets {
replace_or_append_by(&mut self.presets, preset, |left, right| {
left.preset_id == right.preset_id
});
}
for group in runtime.user_groups {
replace_or_append_by(&mut self.groups, group, |left, right| {
left.group_id == right.group_id
});
}
self.creative_snapshots = runtime.creative_snapshots;
}
pub fn catalog(&self, registry: &PatternRegistry) -> CatalogSnapshot {
CatalogSnapshot {
patterns: registry.pattern_definitions(),
presets: self
.presets
.iter()
.map(|preset| PresetSummary {
preset_id: preset.preset_id.clone(),
pattern_id: preset.scene.pattern_id.clone(),
target_group: preset.scene.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: preset.transition_style,
source: preset.source,
})
.collect(),
groups: self
.groups
.iter()
.map(|group| GroupSummary {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: group.source,
})
.collect(),
creative_snapshots: self
.creative_snapshots
.iter()
.map(|snapshot| CreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.scene.pattern_id.clone(),
target_group: snapshot.scene.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: snapshot.transition_style,
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
}
pub fn initial_scene(&self, registry: &PatternRegistry) -> SceneRuntime {
self.presets
.first()
.map(|preset| preset.scene.clone())
.unwrap_or_else(|| {
registry.scene_for_pattern(
"solid_color",
Some("bootstrap-solid-color".to_string()),
None,
1,
vec!["#ffffff".to_string()],
false,
)
})
}
pub fn available_patterns(&self, registry: &PatternRegistry) -> Vec<String> {
registry
.pattern_definitions()
.into_iter()
.map(|pattern| pattern.pattern_id)
.collect()
}
pub fn scene_from_preset_id(&self, preset_id: &str) -> Option<SceneRuntime> {
self.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| preset.scene.clone())
}
pub fn transition_for_preset(&self, preset_id: &str) -> Option<(u32, SceneTransitionStyle)> {
self.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
}
pub fn recall_creative_snapshot(
&self,
snapshot_id: &str,
) -> Option<StoredCreativeSnapshot> {
self.creative_snapshots
.iter()
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
.cloned()
}
pub fn has_group(&self, group_id: &str) -> bool {
self.groups.iter().any(|group| group.group_id == group_id)
}
pub fn group_members_map(&self) -> BTreeMap<String, BTreeSet<String>> {
self.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 save_preset_from_scene(
&mut self,
preset_id: &str,
scene: &SceneRuntime,
transition_duration_ms: u32,
transition_style: SceneTransitionStyle,
overwrite: bool,
) -> Result<(), HostCommandError> {
if preset_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_preset_id",
"preset_id must not be empty",
));
}
if let Some(existing) = self.presets.iter().find(|preset| preset.preset_id == preset_id) {
if !overwrite {
return Err(HostCommandError::new(
"preset_exists",
format!("preset '{preset_id}' already exists"),
));
}
if existing.source == CatalogSource::BuiltIn {
// Overwriting a built-in preset becomes a runtime overlay with the same id.
}
}
let preset = StoredPreset {
preset_id: preset_id.to_string(),
scene: scene.clone(),
transition_duration_ms,
transition_style,
source: CatalogSource::RuntimeUser,
updated_at_unix_ms: Some(now_unix_ms()),
};
replace_or_append_by(&mut self.presets, preset, |left, right| {
left.preset_id == right.preset_id
});
Ok(())
}
pub fn save_creative_snapshot(
&mut self,
snapshot_id: &str,
label: Option<String>,
scene: &SceneRuntime,
transition_duration_ms: u32,
transition_style: SceneTransitionStyle,
overwrite: bool,
) -> Result<(), HostCommandError> {
if snapshot_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_snapshot_id",
"snapshot_id must not be empty",
));
}
if self
.creative_snapshots
.iter()
.any(|snapshot| snapshot.snapshot_id == snapshot_id)
&& !overwrite
{
return Err(HostCommandError::new(
"snapshot_exists",
format!("creative snapshot '{snapshot_id}' already exists"),
));
}
let snapshot = StoredCreativeSnapshot {
snapshot_id: snapshot_id.to_string(),
label,
scene: scene.clone(),
transition_duration_ms,
transition_style,
saved_at_unix_ms: now_unix_ms(),
source_preset_id: scene.preset_id.clone(),
};
replace_or_append_by(&mut self.creative_snapshots, snapshot, |left, right| {
left.snapshot_id == right.snapshot_id
});
Ok(())
}
pub fn upsert_group(
&mut self,
group_id: &str,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
) -> Result<(), HostCommandError> {
if group_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_group_id",
"group_id must not be empty",
));
}
if members.is_empty() {
return Err(HostCommandError::new(
"invalid_group_members",
"group must contain at least one panel target",
));
}
if self.groups.iter().any(|group| group.group_id == group_id) && !overwrite {
return Err(HostCommandError::new(
"group_exists",
format!("group '{group_id}' already exists"),
));
}
let group = StoredGroup {
group_id: group_id.to_string(),
tags,
members,
source: CatalogSource::RuntimeUser,
updated_at_unix_ms: Some(now_unix_ms()),
};
replace_or_append_by(&mut self.groups, group, |left, right| {
left.group_id == right.group_id
});
Ok(())
}
pub fn persisted_runtime(
&self,
active_scene: &SceneRuntime,
global: PersistedGlobalState,
) -> PersistedRuntimeState {
PersistedRuntimeState {
active_scene: Some(active_scene.clone()),
global,
user_presets: self
.presets
.iter()
.filter(|preset| preset.source == CatalogSource::RuntimeUser)
.cloned()
.collect(),
user_groups: self
.groups
.iter()
.filter(|group| group.source == CatalogSource::RuntimeUser)
.cloned()
.collect(),
creative_snapshots: self.creative_snapshots.clone(),
}
}
}
fn panel_position_key(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn replace_or_append_by<T, F>(items: &mut Vec<T>, item: T, predicate: F)
where
F: Fn(&T, &T) -> bool,
{
if let Some(index) = items.iter().position(|existing| predicate(existing, &item)) {
items[index] = item;
} else {
items.push(item);
}
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn show_store_builds_runtime_catalog() {
let registry = PatternRegistry::new();
let store = ShowStore::from_project(&sample_project(), &registry);
let catalog = store.catalog(&registry);
assert!(catalog
.presets
.iter()
.any(|preset| preset.preset_id == "ocean_gradient"));
assert!(catalog.groups.iter().any(|group| group.group_id == "top_panels"));
}
#[test]
fn runtime_presets_and_snapshots_can_be_saved() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"gradient",
None,
Some("top_panels".to_string()),
77,
vec!["#112233".to_string(), "#445566".to_string()],
false,
);
store
.save_preset_from_scene(
"user_gradient",
&scene,
420,
SceneTransitionStyle::Crossfade,
false,
)
.expect("preset save should succeed");
store
.save_creative_snapshot(
"variant_a",
Some("Variant A".to_string()),
&scene,
240,
SceneTransitionStyle::Chase,
false,
)
.expect("snapshot save should succeed");
assert!(store.scene_from_preset_id("user_gradient").is_some());
assert!(store.recall_creative_snapshot("variant_a").is_some());
}
#[test]
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"noise",
None,
Some("bottom_panels".to_string()),
99,
vec!["#AA8844".to_string()],
false,
);
store
.save_preset_from_scene(
"roundtrip_noise",
&scene,
220,
SceneTransitionStyle::Chase,
false,
)
.expect("preset save should succeed");
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
let runtime = store.persisted_runtime(
&scene,
PersistedGlobalState {
blackout: false,
master_brightness: 0.42,
transition_duration_ms: 220,
transition_style: SceneTransitionStyle::Chase,
},
);
storage.save(&runtime).expect("save should work");
let loaded = storage.load().expect("load should work");
assert_eq!(loaded.active_scene, Some(scene));
assert!(loaded
.user_presets
.iter()
.any(|preset| preset.preset_id == "roundtrip_noise"));
let _ = std::fs::remove_file(path);
}
}

View File

@@ -1,19 +1,23 @@
use crate::{
control::{
EngineSnapshot, GlobalControlSnapshot, HostApiPort, HostCommand, HostSnapshot,
HOST_API_VERSION, NodeConnectionState, NodeSnapshot, PanelSnapshot, PanelTarget,
PreviewPanelSnapshot, PreviewSource, StatusEvent, SystemSnapshot,
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
HostCommand, HostCommandError, HostSnapshot, HOST_API_VERSION, NodeConnectionState,
NodeSnapshot, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
SceneTransitionStyle, StatusEvent, StatusEventKind, 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,
apply_group_gate, blackout_preview, blend_previews, panel_membership_key,
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
},
show_store::{
PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError,
},
};
use infinity_config::{PanelPosition, ProjectConfig};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
@@ -29,9 +33,10 @@ pub struct SimulationHostService {
#[derive(Debug)]
struct SimulationState {
project: ProjectConfig,
registry: PatternRegistry,
group_members: BTreeMap<String, std::collections::BTreeSet<String>>,
show_store: ShowStore,
runtime_storage: Option<RuntimeStateStorage>,
started_at: Instant,
next_seed: u64,
tick_count: u64,
@@ -45,9 +50,7 @@ struct SimulationState {
impl SimulationHostService {
pub fn new(project: ProjectConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(SimulationState::new(project))),
}
Self::try_new(project).expect("memory-only simulation host service must initialize")
}
pub fn spawn_shared(project: ProjectConfig) -> Arc<Self> {
@@ -56,6 +59,33 @@ impl SimulationHostService {
service
}
pub fn try_new(project: ProjectConfig) -> Result<Self, ShowStoreError> {
Ok(Self {
inner: Arc::new(Mutex::new(SimulationState::try_new(project, None)?)),
})
}
pub fn try_new_with_persistence(
project: ProjectConfig,
runtime_state_path: impl Into<PathBuf>,
) -> Result<Self, ShowStoreError> {
Ok(Self {
inner: Arc::new(Mutex::new(SimulationState::try_new(
project,
Some(RuntimeStateStorage::new(runtime_state_path)),
)?)),
})
}
pub fn try_spawn_shared_with_persistence(
project: ProjectConfig,
runtime_state_path: impl Into<PathBuf>,
) -> Result<Arc<Self>, ShowStoreError> {
let service = Arc::new(Self::try_new_with_persistence(project, runtime_state_path)?);
Self::spawn_simulation_loop(Arc::clone(&service));
Ok(service)
}
fn spawn_simulation_loop(service: Arc<Self>) {
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(80));
@@ -74,25 +104,38 @@ impl HostApiPort for SimulationHostService {
.unwrap_or_else(|_| unavailable_snapshot())
}
fn send_command(&self, command: HostCommand) {
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
if let Ok(mut state) = self.inner.lock() {
state.apply_command(command);
state.apply_command(command)
} else {
Err(HostCommandError::new(
"service_unavailable",
"simulation state lock was unavailable",
))
}
}
}
impl SimulationState {
fn new(project: ProjectConfig) -> Self {
fn try_new(
project: ProjectConfig,
runtime_storage: Option<RuntimeStateStorage>,
) -> Result<Self, ShowStoreError> {
let registry = PatternRegistry::new();
let group_members = build_group_members(&project);
let mut show_store = ShowStore::from_project(&project, &registry);
let persisted_runtime = if let Some(storage) = &runtime_storage {
storage.load()?
} else {
Default::default()
};
let restored_scene = persisted_runtime.active_scene.clone();
let restored_global = persisted_runtime.global.clone();
show_store.apply_persisted(persisted_runtime);
let group_members = show_store.group_members_map();
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 current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(&registry));
let catalog = show_store.catalog(&registry);
let available_patterns = show_store.available_patterns(&registry);
let nodes = project
.topology
.nodes
@@ -131,10 +174,12 @@ impl SimulationState {
})
.collect::<Vec<_>>();
let selected_pattern = current_scene.pattern_id.clone();
let mut state = Self {
project: project.clone(),
registry,
group_members,
show_store,
runtime_storage,
started_at: Instant::now(),
next_seed: 100,
tick_count: 0,
@@ -153,11 +198,12 @@ impl SimulationState {
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
},
global: GlobalControlSnapshot {
blackout: false,
master_brightness: 0.20,
selected_pattern: "solid_color".to_string(),
blackout: restored_global.blackout,
master_brightness: restored_global.master_brightness,
selected_pattern,
selected_group: None,
transition_duration_ms: 150,
transition_duration_ms: restored_global.transition_duration_ms,
transition_style: restored_global.transition_style,
},
engine: EngineSnapshot {
logic_hz: schedule.logic_hz,
@@ -188,9 +234,16 @@ impl SimulationState {
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.push_event(StatusEventKind::Info, None, "simulation host service started".to_string());
if state.runtime_storage.is_some() {
state.push_event(
StatusEventKind::Info,
Some("runtime_state_restored".to_string()),
"runtime state persistence enabled".to_string(),
);
}
state.simulate_tick();
state
Ok(state)
}
fn simulate_tick(&mut self) {
@@ -218,22 +271,29 @@ impl SimulationState {
self.snapshot.preview.panels = self.render_preview_panels(elapsed_ms);
}
fn apply_command(&mut self, command: HostCommand) {
match command {
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
let mut should_persist = false;
let summary = match command {
HostCommand::SetBlackout(enabled) => {
self.snapshot.global.blackout = enabled;
self.push_event(if enabled {
should_persist = true;
let summary = if enabled {
"global blackout enabled".to_string()
} else {
"global blackout released".to_string()
});
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetMasterBrightness(value) => {
self.snapshot.global.master_brightness = value.clamp(0.0, 1.0);
self.push_event(format!(
should_persist = true;
let summary = format!(
"master brightness set to {:.0}%",
self.snapshot.global.master_brightness * 100.0
));
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SelectPattern(pattern_id) => {
let mut new_scene = self.registry.scene_for_pattern(
@@ -255,55 +315,95 @@ impl SimulationState {
}
let duration_ms = self.snapshot.global.transition_duration_ms;
let style = self.snapshot.global.transition_style;
should_persist = true;
self.start_scene_transition(
new_scene,
duration_ms,
transition_style_from_duration(duration_ms),
style,
format!("pattern selected: {pattern_id}"),
);
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}"),
let Some(scene) = self.show_store.scene_from_preset_id(&preset_id) else {
let error = HostCommandError::new(
"unknown_preset",
format!("preset '{preset_id}' does not exist"),
);
} else {
self.push_event(format!("ignored unknown preset request: {preset_id}"));
}
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
let (duration_ms, style) = self
.show_store
.transition_for_preset(&preset_id)
.unwrap_or((
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
));
self.snapshot.global.transition_duration_ms = duration_ms;
self.snapshot.global.transition_style = style;
should_persist = true;
self.start_scene_transition(
scene,
duration_ms,
style,
format!("preset recalled: {preset_id}"),
);
format!("preset recalled: {preset_id}")
}
HostCommand::SelectGroup { group_id } => {
if let Some(group_id_ref) = &group_id {
if !self.show_store.has_group(group_id_ref) {
let error = HostCommandError::new(
"unknown_group",
format!("group '{group_id_ref}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
}
}
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!(
should_persist = true;
let summary = format!(
"target group set to {}",
group_id.as_deref().unwrap_or("all_panels")
));
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
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:?}"));
should_persist = true;
let summary = format!("scene parameter updated: {key} = {value:?}");
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetTransitionDurationMs(duration_ms) => {
self.snapshot.global.transition_duration_ms = duration_ms;
self.push_event(format!(
"default transition duration set to {} ms",
duration_ms
));
should_persist = true;
let summary = format!("default transition duration set to {} ms", duration_ms);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetTransitionStyle(style) => {
self.snapshot.global.transition_style = style;
should_persist = true;
let summary = format!("default transition style set to {}", style.label());
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::TriggerPanelTest { target, pattern } => {
let now = self.elapsed_millis();
@@ -329,18 +429,114 @@ impl SimulationState {
);
}
self.push_event(message);
self.push_event(StatusEventKind::Info, None, message.clone());
message
}
}
HostCommand::SavePreset {
preset_id,
overwrite,
} => {
self.show_store.save_preset_from_scene(
&preset_id,
&self.current_scene,
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
overwrite,
)?;
self.rebuild_catalog();
self.group_members = self.show_store.group_members_map();
should_persist = true;
let summary = if overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => {
self.show_store.save_creative_snapshot(
&snapshot_id,
label.clone(),
&self.current_scene,
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
overwrite,
)?;
self.rebuild_catalog();
should_persist = true;
let summary = if overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::RecallCreativeSnapshot { snapshot_id } => {
let Some(snapshot) = self.show_store.recall_creative_snapshot(&snapshot_id) else {
let error = HostCommandError::new(
"unknown_creative_snapshot",
format!("creative snapshot '{snapshot_id}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
self.snapshot.global.transition_duration_ms = snapshot.transition_duration_ms;
self.snapshot.global.transition_style = snapshot.transition_style;
should_persist = true;
self.start_scene_transition(
snapshot.scene,
snapshot.transition_duration_ms,
snapshot.transition_style,
format!("creative snapshot recalled: {snapshot_id}"),
);
format!("creative snapshot recalled: {snapshot_id}")
}
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => {
self.show_store
.upsert_group(&group_id, tags, members, overwrite)?;
self.group_members = self.show_store.group_members_map();
self.rebuild_catalog();
should_persist = true;
let summary = if overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
};
self.simulate_tick();
if should_persist {
self.persist_runtime_state()?;
}
Ok(CommandOutcome {
generated_at_millis: self.snapshot.generated_at_millis,
summary,
})
}
fn start_scene_transition(
&mut self,
new_scene: SceneRuntime,
duration_ms: u32,
style: crate::control::SceneTransitionStyle,
style: SceneTransitionStyle,
event_message: String,
) {
let previous_scene = self.current_scene.clone();
@@ -355,7 +551,7 @@ impl SimulationState {
from_scene: previous_scene,
})
};
self.push_event(event_message);
self.push_event(StatusEventKind::Info, None, event_message);
}
fn resolve_transition_if_complete(&mut self) {
@@ -367,7 +563,7 @@ impl SimulationState {
if finished {
self.active_transition = None;
self.push_event(format!(
self.push_event(StatusEventKind::Info, None, format!(
"transition completed to {}",
self.current_scene.pattern_id
));
@@ -403,7 +599,11 @@ impl SimulationState {
}
for message in transition_messages {
self.push_event(message);
self.push_event(
StatusEventKind::Warning,
Some("node_connection_state".to_string()),
message,
);
}
}
@@ -501,11 +701,58 @@ impl SimulationState {
(scale_preview(preview, self.snapshot.global.master_brightness), source)
}
fn push_event(&mut self, message: String) {
fn rebuild_catalog(&mut self) {
self.snapshot.catalog = self.show_store.catalog(&self.registry);
}
fn persisted_global_state(&self) -> PersistedGlobalState {
PersistedGlobalState {
blackout: self.snapshot.global.blackout,
master_brightness: self.snapshot.global.master_brightness,
transition_duration_ms: self.snapshot.global.transition_duration_ms,
transition_style: self.snapshot.global.transition_style,
}
}
fn persist_runtime_state(&mut self) -> Result<(), HostCommandError> {
let Some(storage) = &self.runtime_storage else {
return Ok(());
};
let storage_path = storage.path().to_path_buf();
let runtime_state = self
.show_store
.persisted_runtime(&self.current_scene, self.persisted_global_state());
if let Err(error) = storage.save(&runtime_state) {
let command_error = HostCommandError::new(
"persist_failed",
format!(
"runtime state could not be saved to {}: {error}",
storage_path.display()
),
);
self.push_event(
StatusEventKind::Error,
Some(command_error.code.clone()),
command_error.message.clone(),
);
return Err(command_error);
}
Ok(())
}
fn push_event(
&mut self,
kind: StatusEventKind,
code: Option<String>,
message: String,
) {
self.snapshot.recent_events.insert(
0,
StatusEvent {
at_millis: self.elapsed_millis(),
kind,
code,
message,
},
);
@@ -533,6 +780,7 @@ fn unavailable_snapshot() -> HostSnapshot {
selected_pattern: "unavailable".to_string(),
selected_group: None,
transition_duration_ms: 0,
transition_style: SceneTransitionStyle::Snap,
},
engine: EngineSnapshot {
logic_hz: 0,
@@ -543,10 +791,11 @@ fn unavailable_snapshot() -> HostSnapshot {
dropped_frames: 0,
active_transition: None,
},
catalog: crate::control::CatalogSnapshot {
catalog: CatalogSnapshot {
patterns: Vec::new(),
presets: Vec::new(),
groups: Vec::new(),
creative_snapshots: Vec::new(),
},
active_scene: crate::control::ActiveSceneSnapshot {
preset_id: None,
@@ -563,6 +812,8 @@ fn unavailable_snapshot() -> HostSnapshot {
panels: Vec::new(),
recent_events: vec![StatusEvent {
at_millis: 0,
kind: StatusEventKind::Error,
code: Some("service_unavailable".to_string()),
message: "simulation service lock was unavailable".to_string(),
}],
}
@@ -668,13 +919,13 @@ mod tests {
#[test]
fn commands_update_scene_and_group() {
let service = SimulationHostService::new(sample_project());
service.send_command(HostCommand::SelectGroup {
let _ = service.send_command(HostCommand::SelectGroup {
group_id: Some("top_panels".to_string()),
});
service.send_command(HostCommand::RecallPreset {
let _ = service.send_command(HostCommand::RecallPreset {
preset_id: "mapping_walk_test".to_string(),
});
service.send_command(HostCommand::SetSceneParameter {
let _ = service.send_command(HostCommand::SetSceneParameter {
key: "speed".to_string(),
value: SceneParameterValue::Scalar(2.0),
});
@@ -688,4 +939,22 @@ mod tests {
.iter()
.any(|parameter| parameter.key == "speed"));
}
#[test]
fn unknown_group_returns_typed_error_and_warning_event() {
let service = SimulationHostService::new(sample_project());
let error = service
.send_command(HostCommand::SelectGroup {
group_id: Some("does_not_exist".to_string()),
})
.expect_err("unknown group should fail");
let snapshot = service.snapshot();
assert_eq!(error.code, "unknown_group");
assert!(snapshot
.recent_events
.iter()
.any(|event| event.kind == StatusEventKind::Warning
&& event.code.as_deref() == Some("unknown_group")));
}
}

View File

@@ -1,7 +1,8 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, SceneParameterKind,
SceneParameterValue, SceneTransitionStyle, TestPatternKind,
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
TestPatternKind,
};
use serde::{Deserialize, Serialize};
@@ -15,12 +16,27 @@ pub struct ApiSnapshotResponse {
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse {
pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>,
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -48,6 +64,7 @@ pub struct ApiCommandResponse {
pub accepted: bool,
pub request_id: Option<String>,
pub generated_at_millis: u64,
pub command_type: String,
pub summary: String,
}
@@ -98,6 +115,7 @@ pub enum ApiStreamMessage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice {
pub kind: ApiEventKind,
pub code: Option<String>,
pub message: String,
}
@@ -105,6 +123,8 @@ pub struct ApiEventNotice {
#[serde(rename_all = "snake_case")]
pub enum ApiEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -121,6 +141,7 @@ pub struct ApiGlobalState {
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -198,6 +219,8 @@ pub struct ApiPresetSummary {
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -205,6 +228,18 @@ pub struct ApiGroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -277,6 +312,13 @@ pub enum ApiParameterKind {
Text,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiCatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiLedDirection {
@@ -336,11 +378,38 @@ pub enum ApiCommand {
SetTransitionDurationMs {
duration_ms: u32,
},
SetTransitionStyle {
style: ApiTransitionStyle,
},
TriggerPanelTest {
node_id: String,
panel_position: ApiPanelPosition,
pattern: ApiTestPattern,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<ApiPanelRef>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiPanelRef {
pub node_id: String,
pub panel_position: ApiPanelPosition,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -362,6 +431,26 @@ impl ApiSnapshotResponse {
}
}
impl ApiStateResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state: ApiStateSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiPreviewResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
preview: ApiPreviewSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
@@ -398,6 +487,8 @@ impl ApiCatalogResponse {
pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: map_transition_style(preset.transition_style),
source: map_catalog_source(preset.source),
})
.collect(),
groups: snapshot
@@ -408,6 +499,21 @@ impl ApiCatalogResponse {
group_id: group.group_id.clone(),
member_count: group.member_count,
tags: group.tags.clone(),
source: map_catalog_source(group.source),
})
.collect(),
creative_snapshots: snapshot
.catalog
.creative_snapshots
.iter()
.map(|snapshot| ApiCreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.pattern_id.clone(),
target_group: snapshot.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: map_transition_style(snapshot.transition_style),
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
@@ -446,6 +552,7 @@ impl ApiStateSnapshot {
selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms,
transition_style: map_transition_style(snapshot.global.transition_style),
},
engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz,
@@ -565,6 +672,9 @@ impl ApiCommandRequest {
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::SetTransitionStyle { style } => {
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
}
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
@@ -578,6 +688,42 @@ impl ApiCommandRequest {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106,
},
}),
ApiCommand::SavePreset {
preset_id,
overwrite,
} => Ok(HostCommand::SavePreset {
preset_id,
overwrite,
}),
ApiCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => Ok(HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
}),
ApiCommand::RecallCreativeSnapshot { snapshot_id } => {
Ok(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ApiCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(HostCommand::UpsertGroup {
group_id,
tags,
members: members
.into_iter()
.map(|member| infinity_host::PanelTarget {
node_id: member.node_id,
panel_position: map_command_panel_position(member.panel_position),
})
.collect(),
overwrite,
}),
}
}
@@ -653,6 +799,29 @@ fn map_transition_style(style: SceneTransitionStyle) -> ApiTransitionStyle {
}
}
fn map_command_transition_style(style: ApiTransitionStyle) -> SceneTransitionStyle {
match style {
ApiTransitionStyle::Snap => SceneTransitionStyle::Snap,
ApiTransitionStyle::Crossfade => SceneTransitionStyle::Crossfade,
ApiTransitionStyle::Chase => SceneTransitionStyle::Chase,
}
}
fn map_catalog_source(source: CatalogSource) -> ApiCatalogSource {
match source {
CatalogSource::BuiltIn => ApiCatalogSource::BuiltIn,
CatalogSource::RuntimeUser => ApiCatalogSource::RuntimeUser,
}
}
fn map_event_kind(kind: StatusEventKind) -> ApiEventKind {
match kind {
StatusEventKind::Info => ApiEventKind::Info,
StatusEventKind::Warning => ApiEventKind::Warning,
StatusEventKind::Error => ApiEventKind::Error,
}
}
fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar,
@@ -678,6 +847,24 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
}
impl ApiCommand {
pub fn kind_label(&self) -> &'static str {
match self {
Self::SetBlackout { .. } => "set_blackout",
Self::SetMasterBrightness { .. } => "set_master_brightness",
Self::SelectPattern { .. } => "select_pattern",
Self::RecallPreset { .. } => "recall_preset",
Self::SelectGroup { .. } => "select_group",
Self::SetSceneParameter { .. } => "set_scene_parameter",
Self::SetTransitionDurationMs { .. } => "set_transition_duration_ms",
Self::SetTransitionStyle { .. } => "set_transition_style",
Self::TriggerPanelTest { .. } => "trigger_panel_test",
Self::SavePreset { .. } => "save_preset",
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
Self::UpsertGroup { .. } => "upsert_group",
}
}
pub fn summary(&self) -> String {
match self {
Self::SetBlackout { enabled } => {
@@ -700,6 +887,9 @@ impl ApiCommand {
Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms")
}
Self::SetTransitionStyle { style } => {
format!("transition style set to {}", style.label())
}
Self::TriggerPanelTest {
node_id,
panel_position,
@@ -710,6 +900,38 @@ impl ApiCommand {
node_id,
panel_position.label()
),
Self::SavePreset { preset_id, overwrite } => {
if *overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
}
}
Self::SaveCreativeSnapshot {
snapshot_id,
overwrite,
..
} => {
if *overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
}
}
Self::RecallCreativeSnapshot { snapshot_id } => {
format!("creative snapshot recalled: {snapshot_id}")
}
Self::UpsertGroup {
group_id,
overwrite,
..
} => {
if *overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
}
}
}
}
}
@@ -724,6 +946,16 @@ impl ApiPanelPosition {
}
}
impl ApiTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl ApiTestPattern {
pub fn label(self) -> &'static str {
match self {
@@ -743,3 +975,13 @@ impl ApiErrorResponse {
}
}
}
impl From<infinity_host::StatusEvent> for ApiEventNotice {
fn from(event: infinity_host::StatusEvent) -> Self {
Self {
kind: map_event_kind(event.kind),
code: event.code,
message: event.message,
}
}
}

View File

@@ -11,16 +11,23 @@ struct Cli {
config: PathBuf,
#[arg(long, default_value = "127.0.0.1:9001")]
bind: String,
#[arg(long, default_value = "data/runtime_state.json")]
runtime_state: PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let project = load_project(&cli.config)?;
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(project);
let service: Arc<dyn HostApiPort> =
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
let server = HostApiServer::bind(&cli.bind, service)?;
println!("Infinity Vis host API listening on http://{}", server.local_addr());
println!("Web UI available at http://{}/", server.local_addr());
println!(
"Runtime state persistence: {}",
cli.runtime_state.display()
);
loop {
thread::sleep(Duration::from_secs(60));

View File

@@ -1,7 +1,7 @@
use crate::dto::{
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse, ApiEventKind,
ApiEventNotice, ApiGroupListResponse, ApiPresetListResponse, ApiSnapshotResponse,
ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
};
use crate::websocket::{websocket_accept_value, write_text_frame};
use infinity_host::HostApiPort;
@@ -21,6 +21,13 @@ pub struct HostApiServer {
accept_thread: Option<JoinHandle<()>>,
}
#[derive(Debug)]
struct ApiRequestError {
status: u16,
code: String,
message: String,
}
impl HostApiServer {
pub fn bind(bind: &str, service: Arc<dyn HostApiPort>) -> io::Result<Self> {
let listener = TcpListener::bind(bind)?;
@@ -86,6 +93,14 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiSnapshotResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/state") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiStateResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/preview") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPreviewResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiCatalogResponse::from_snapshot(&snapshot))
@@ -102,9 +117,9 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
Ok(()) => Ok(()),
Err(error) => respond_error(
&mut stream,
400,
"invalid_command",
format!("command request was rejected: {error}"),
error.status,
error.code,
error.message,
),
},
("GET", "/") => respond_text(
@@ -148,16 +163,29 @@ fn handle_command_post(
stream: &mut TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
) -> Result<(), ApiRequestError> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_request_json".to_string(),
message: format!("command request body could not be parsed: {error}"),
})?;
let request_id = parsed.request_id.clone();
let summary = parsed.summary();
let command_type = parsed.command.kind_label().to_string();
let command = parsed
.into_host_command()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
service.send_command(command);
let snapshot = service.snapshot();
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_command".to_string(),
message: error,
})?;
let outcome = service
.send_command(command)
.map_err(|error| ApiRequestError {
status: 400,
code: error.code,
message: error.message,
})?;
respond_json(
stream,
200,
@@ -165,10 +193,16 @@ fn handle_command_post(
api_version: API_VERSION,
accepted: true,
request_id,
generated_at_millis: snapshot.generated_at_millis,
summary,
generated_at_millis: outcome.generated_at_millis,
command_type,
summary: outcome.summary,
},
)
.map_err(|error| ApiRequestError {
status: 500,
code: "response_write_failed".to_string(),
message: error.to_string(),
})
}
fn handle_websocket(
@@ -223,10 +257,7 @@ fn handle_websocket(
&mut stream,
sequence,
event.at_millis,
ApiStreamMessage::Event(ApiEventNotice {
kind: ApiEventKind::Info,
message: event.message,
}),
ApiStreamMessage::Event(event.into()),
)?;
sequence += 1;
}
@@ -280,6 +311,7 @@ fn respond_text(
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK",
};
let response = format!(

View File

@@ -5,8 +5,9 @@ use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
@@ -14,7 +15,15 @@ fn sample_project() -> ProjectConfig {
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
let service: Arc<dyn HostApiPort> = Arc::new(SimulationHostService::new(sample_project()));
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(
SimulationHostService::try_new_with_persistence(sample_project(), path)
.expect("persistent service must initialize"),
);
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
@@ -25,118 +34,183 @@ struct HttpResponse {
}
#[test]
fn root_serves_creative_console_shell() {
fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/", None);
let html = send_http_request(server.local_addr(), "GET", "/", None);
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
assert_eq!(response.status_code, 200);
assert!(response
assert_eq!(html.status_code, 200);
assert!(html
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(response.body.contains("Infinity Vis / Creative Surface"));
assert!(response.body.contains("/app.js"));
assert!(html.body.contains("Preset Capture"));
assert!(html.body.contains("Creative Snapshots"));
assert!(html.body.contains("Event Stream"));
assert_eq!(app_js.status_code, 200);
assert!(app_js.body.contains("/api/v1/state"));
assert!(app_js.body.contains("/api/v1/preview"));
assert!(app_js.body.contains("save_preset"));
assert!(app_js.body.contains("save_creative_snapshot"));
server.shutdown();
}
#[test]
fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() {
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse");
assert_eq!(response.status_code, 200);
assert_eq!(body["api_version"], "v1");
assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis");
assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6));
assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(body["state"]["active_scene"]["pattern_id"].is_string());
server.shutdown();
}
#[test]
fn catalog_presets_and_groups_endpoints_return_expected_lists() {
let server = start_server();
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None);
let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json");
let group_body: Value = serde_json::from_str(&groups.body).expect("group json");
assert_eq!(catalog.status_code, 200);
assert!(catalog_body["patterns"]
.as_array()
.expect("patterns array")
.iter()
.any(|pattern| pattern["pattern_id"] == "walking_pixel"));
assert!(preset_body["presets"]
.as_array()
.expect("presets array")
.iter()
.any(|preset| preset["preset_id"] == "ocean_gradient"));
assert!(group_body["groups"]
.as_array()
.expect("groups array")
.iter()
.any(|group| group["group_id"] == "top_panels"));
server.shutdown();
}
#[test]
fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() {
let server = start_server();
let response = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-blackout",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
);
let response_body: Value =
serde_json::from_str(&response.body).expect("command response must parse");
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(response.status_code, 200);
assert_eq!(response_body["accepted"], true);
assert_eq!(response_body["request_id"], "contract-blackout");
assert_eq!(snapshot_body["state"]["global"]["blackout"], true);
assert_eq!(state.status_code, 200);
assert_eq!(state_body["api_version"], "v1");
assert!(state_body.get("state").is_some());
assert!(state_body.get("preview").is_none());
assert_eq!(state_body["state"]["nodes"].as_array().map(Vec::len), Some(6));
let invalid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#),
);
let invalid_body: Value =
serde_json::from_str(&invalid.body).expect("invalid response must parse");
assert_eq!(preview.status_code, 200);
assert_eq!(preview_body["api_version"], "v1");
assert!(preview_body.get("preview").is_some());
assert!(preview_body.get("state").is_none());
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["api_version"], "v1");
assert_eq!(invalid_body["error"]["code"], "invalid_command");
assert_eq!(snapshot.status_code, 200);
assert_eq!(snapshot_body["api_version"], "v1");
assert!(snapshot_body.get("state").is_some());
assert!(snapshot_body.get("preview").is_some());
server.shutdown();
}
#[test]
fn websocket_stream_emits_snapshot_preview_and_event_messages() {
fn command_flow_updates_group_parameters_transition_and_blackout() {
let server = start_server();
let responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"top_panels"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"speed","value":{"kind":"scalar","value":2.25}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":320}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"gradient"}}}"#,
),
];
for response in responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(state_body["state"]["global"]["selected_group"], "top_panels");
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "gradient");
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "speed" && parameter["value"]["value"] == 2.25));
let blackout = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
);
let blackout_body: Value = serde_json::from_str(&blackout.body).expect("blackout json");
assert_eq!(blackout.status_code, 200);
assert_eq!(blackout_body["command_type"], "set_blackout");
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
server.shutdown();
}
#[test]
fn presets_and_creative_snapshots_persist_across_restart() {
let runtime_state_path = unique_runtime_state_path("persistence");
let server = start_server_with_runtime_state(&runtime_state_path);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"bottom_panels"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.93}}}}"#,
);
let save_preset = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"user_noise_floor","overwrite":false}}}"#,
);
let save_snapshot = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"variant_floor","label":"Variant Floor","overwrite":false}}}"#,
);
assert_eq!(save_preset.status_code, 200);
assert_eq!(save_snapshot.status_code, 200);
server.shutdown();
let restarted = start_server_with_runtime_state(&runtime_state_path);
let catalog = send_http_request(restarted.local_addr(), "GET", "/api/v1/catalog", None);
let state = send_http_request(restarted.local_addr(), "GET", "/api/v1/state", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "user_noise_floor" && preset["source"] == "runtime_user"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(state_body["state"]["active_scene"]["target_group"], "bottom_panels");
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.93));
restarted.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
@@ -148,43 +222,97 @@ fn websocket_stream_emits_snapshot_preview_and_event_messages() {
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = send_http_request(
let invalid = send_command_json(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-event",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
);
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid json");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
let mut saw_event = false;
let mut saw_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws event frame");
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
saw_event = true;
saw_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
assert_eq!(payload["message"]["payload"]["code"], "unknown_creative_snapshot");
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
.contains("blackout"));
.contains("does_not_exist"));
break;
}
}
assert!(saw_event, "expected websocket event after command");
assert!(saw_warning, "expected warning event after failed command");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
#[ignore = "longer load-oriented sequence for platform hardening"]
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let server = start_server();
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
let groups = [None, Some("top_panels"), Some("middle_panels"), Some("bottom_panels")];
for index in 0..80 {
let pattern = patterns[index % patterns.len()];
let group = groups[index % groups.len()];
let brightness = ((index % 10) as f32) / 10.0;
let speed = 0.5 + (index % 6) as f32 * 0.25;
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_pattern","payload":{{"pattern_id":"{pattern}"}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_master_brightness","payload":{{"value":{brightness}}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_scene_parameter","payload":{{"key":"speed","value":{{"kind":"scalar","value":{speed}}}}}}}}}"#
),
);
let group_json = match group {
Some(group_id) => format!(r#""{group_id}""#),
None => "null".to_string(),
};
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_group","payload":{{"group_id":{group_json}}}}}}}"#
),
);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(state_body["state"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(catalog_body["patterns"].as_array().map(Vec::len).unwrap_or_default() >= 5);
}
server.shutdown();
}
fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
send_http_request(addr, "POST", "/api/v1/command", Some(body))
}
fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
@@ -293,3 +421,11 @@ fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8")
}
fn unique_runtime_state_path(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis();
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
}

View File

@@ -69,7 +69,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
});
if ui.add(blackout_button).clicked() {
service.send_command(HostCommand::SetBlackout(!blackout));
let _ = service.send_command(HostCommand::SetBlackout(!blackout));
}
let mut brightness = snapshot.global.master_brightness;
@@ -80,7 +80,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
)
.changed()
{
service.send_command(HostCommand::SetMasterBrightness(brightness));
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
}
let selected_pattern = snapshot.global.selected_pattern.clone();
@@ -93,7 +93,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
.selectable_label(selected_pattern == *pattern, pattern)
.clicked()
{
service.send_command(HostCommand::SelectPattern(pattern.clone()));
let _ = service.send_command(HostCommand::SelectPattern(pattern.clone()));
}
}
});
@@ -183,7 +183,7 @@ fn draw_panel_mapping(
}
});
if ui.button("Walk 106").clicked() {
service.send_command(HostCommand::TriggerPanelTest {
let _ = service.send_command(HostCommand::TriggerPanelTest {
target: PanelTarget {
node_id: panel.target.node_id.clone(),
panel_position: panel.target.panel_position.clone(),