From 8e19f535ae9b65e13a2a48fe7682e432adf5bfc2 Mon Sep 17 00:00:00 2001 From: JFly02 Date: Fri, 17 Apr 2026 12:34:03 +0200 Subject: [PATCH] =?UTF-8?q?Die=20gemeinsame=20Plattform=20ist=20jetzt=20so?= =?UTF-8?q?ftwareseitig=20deutlich=20vollst=C3=A4ndiger.=20Der=20Host-Core?= =?UTF-8?q?=20hat=20mit=20[show=5Fstore.rs]()=20eine=20echte=20Runtime-Bibliothek=20und=20Persistenz?= =?UTF-8?q?=20f=C3=BCr=20aktive=20Szene,=20Runtime-Presets,=20Runtime-Grup?= =?UTF-8?q?pen=20und=20kreative=20Varianten=20bekommen;=20die=20Simulation?= =?UTF-8?q?=20in=20[simulation.rs]()=20l?= =?UTF-8?q?iefert=20jetzt=20typisierte=20Command-Ergebnisse,=20saubere=20F?= =?UTF-8?q?ehlercodes=20und=20persistiert=20nach=20`data/runtime=5Fstate.j?= =?UTF-8?q?son`.=20Dazu=20kommt=20das=20generische=20External-Show-Control?= =?UTF-8?q?-Interface=20in=20[external=5Fcontrol.rs](),=20damit=20sp=C3=A4tere=20Adapter=20nur?= =?UTF-8?q?=20auf=20definierte=20Commands=20und=20Snapshot-/Preset-/Parame?= =?UTF-8?q?ter-Fl=C3=A4chen=20zugreifen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die API v1 ist als Produktgrenze geschärft in [dto.rs]() und [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](), [app.js]() und [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](), [README.md](), [docs/build_and_deploy.md]() und [docs/architecture.md](). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs]() sowie neue Core-Tests in [show_store.rs]() und [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. --- Cargo.lock | 1 + README.md | 2 +- crates/infinity_host/Cargo.toml | 1 + crates/infinity_host/src/control.rs | 104 +++- crates/infinity_host/src/external_control.rs | 124 ++++ crates/infinity_host/src/lib.rs | 4 + crates/infinity_host/src/scene.rs | 23 +- crates/infinity_host/src/show_store.rs | 605 +++++++++++++++++++ crates/infinity_host/src/simulation.rs | 411 ++++++++++--- crates/infinity_host_api/src/dto.rs | 246 +++++++- crates/infinity_host_api/src/main.rs | 9 +- crates/infinity_host_api/src/server.rs | 68 ++- crates/infinity_host_api/tests/contract.rs | 360 +++++++---- crates/infinity_host_ui/src/app.rs | 8 +- docs/architecture.md | 9 +- docs/build_and_deploy.md | 4 +- docs/host_api.md | 423 +++++++------ web/v1/app.js | 268 ++++++-- web/v1/index.html | 76 ++- web/v1/styles.css | 112 +++- 20 files changed, 2399 insertions(+), 459 deletions(-) create mode 100644 crates/infinity_host/src/external_control.rs create mode 100644 crates/infinity_host/src/show_store.rs diff --git a/Cargo.lock b/Cargo.lock index ac3a8c7..24d9bd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,6 +1706,7 @@ dependencies = [ "infinity_protocol", "serde", "serde_json", + "thiserror 1.0.69", ] [[package]] diff --git a/README.md b/README.md index 4fa46b8..1af0d80 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The current baseline is intentionally strict about unresolved hardware facts. `U 2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md). 3. Start from [config/project.example.toml](config/project.example.toml). 4. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`. -5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001`. +5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json`. 6. Open `http://127.0.0.1:9001/` for the creative surface. 7. Start the engineering GUI with `cargo run -p infinity_host_ui`. 8. Use the host CLI to validate the project config before attempting activation. diff --git a/crates/infinity_host/Cargo.toml b/crates/infinity_host/Cargo.toml index 39f614e..4c8ac85 100644 --- a/crates/infinity_host/Cargo.toml +++ b/crates/infinity_host/Cargo.toml @@ -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" } diff --git a/crates/infinity_host/src/control.rs b/crates/infinity_host/src/control.rs index 13249d5..94cced2 100644 --- a/crates/infinity_host/src/control.rs +++ b/crates/infinity_host/src/control.rs @@ -34,6 +34,7 @@ pub struct GlobalControlSnapshot { pub selected_pattern: String, pub selected_group: Option, 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, pub presets: Vec, pub groups: Vec, + pub creative_snapshots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -62,12 +64,21 @@ pub struct PatternDefinition { pub parameters: Vec, } +#[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, 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, + pub source: CatalogSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CreativeSnapshotSummary { + pub snapshot_id: String, + pub label: Option, + pub pattern_id: String, + pub target_group: Option, + 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, 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, + overwrite: bool, + }, + RecallCreativeSnapshot { + snapshot_id: String, + }, + UpsertGroup { + group_id: String, + tags: Vec, + members: Vec, + 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; } 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, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} diff --git a/crates/infinity_host/src/external_control.rs b/crates/infinity_host/src/external_control.rs new file mode 100644 index 0000000..55382c3 --- /dev/null +++ b/crates/infinity_host/src/external_control.rs @@ -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, + }, + 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; +} + +pub trait ExternalShowControlAdapter: Send { + fn adapter_id(&self) -> &str; + fn capabilities(&self) -> ExternalAdapterCapabilities { + ExternalAdapterCapabilities::default() + } + fn translate( + &mut self, + action: ExternalControlAction, + ) -> Result, HostCommandError>; +} + +impl ExternalShowControlPort for T { + fn snapshot(&self) -> HostSnapshot { + HostApiPort::snapshot(self) + } + + fn execute_action( + &self, + action: ExternalControlAction, + ) -> Result { + 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, + }) + } + } + } +} diff --git a/crates/infinity_host/src/lib.rs b/crates/infinity_host/src/lib.rs index 4d18d1d..ba45cb1 100644 --- a/crates/infinity_host/src/lib.rs +++ b/crates/infinity_host/src/lib.rs @@ -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::*; diff --git a/crates/infinity_host/src/scene.rs b/crates/infinity_host/src/scene.rs index 6c61300..71c6c48 100644 --- a/crates/infinity_host/src/scene.rs +++ b/crates/infinity_host/src/scene.rs @@ -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, 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 { + self.definitions.values().cloned().collect() + } + pub fn scene_from_preset_id(&self, project: &ProjectConfig, preset_id: &str) -> Option { 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) diff --git a/crates/infinity_host/src/show_store.rs b/crates/infinity_host/src/show_store.rs new file mode 100644 index 0000000..2f5545b --- /dev/null +++ b/crates/infinity_host/src/show_store.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredGroup { + pub group_id: String, + pub tags: Vec, + pub members: Vec, + pub source: CatalogSource, + pub updated_at_unix_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredCreativeSnapshot { + pub snapshot_id: String, + pub label: Option, + pub scene: SceneRuntime, + pub transition_duration_ms: u32, + pub transition_style: SceneTransitionStyle, + pub saved_at_unix_ms: u64, + pub source_preset_id: Option, +} + +#[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, + pub global: PersistedGlobalState, + pub user_presets: Vec, + pub user_groups: Vec, + pub creative_snapshots: Vec, +} + +#[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, + groups: Vec, + creative_snapshots: Vec, +} + +impl RuntimeStateStorage { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result { + if !self.path.exists() { + return Ok(PersistedRuntimeState::default()); + } + + let raw = fs::read_to_string(&self.path)?; + let envelope = serde_json::from_str::(&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::>(); + + 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::>(); + + 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 { + registry + .pattern_definitions() + .into_iter() + .map(|pattern| pattern.pattern_id) + .collect() + } + + pub fn scene_from_preset_id(&self, preset_id: &str) -> Option { + 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 { + 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> { + 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, + 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, + members: Vec, + 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(items: &mut Vec, 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(), ®istry); + let catalog = store.catalog(®istry); + 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(), ®istry); + 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(), ®istry); + 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); + } +} diff --git a/crates/infinity_host/src/simulation.rs b/crates/infinity_host/src/simulation.rs index ae19b1e..c007010 100644 --- a/crates/infinity_host/src/simulation.rs +++ b/crates/infinity_host/src/simulation.rs @@ -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>, + show_store: ShowStore, + runtime_storage: Option, 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 { @@ -56,6 +59,33 @@ impl SimulationHostService { service } + pub fn try_new(project: ProjectConfig) -> Result { + 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, + ) -> Result { + 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, + ) -> Result, 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) { 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 { 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, + ) -> Result { let registry = PatternRegistry::new(); - let group_members = build_group_members(&project); + let mut show_store = ShowStore::from_project(&project, ®istry); + 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::>(); + let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry)); + let catalog = show_store.catalog(®istry); + let available_patterns = show_store.available_patterns(®istry); let nodes = project .topology .nodes @@ -131,10 +174,12 @@ impl SimulationState { }) .collect::>(); + 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 { + 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, + 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"))); + } } diff --git a/crates/infinity_host_api/src/dto.rs b/crates/infinity_host_api/src/dto.rs index 802ac4f..2f98eaa 100644 --- a/crates/infinity_host_api/src/dto.rs +++ b/crates/infinity_host_api/src/dto.rs @@ -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, pub presets: Vec, pub groups: Vec, + pub creative_snapshots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -48,6 +64,7 @@ pub struct ApiCommandResponse { pub accepted: bool, pub request_id: Option, 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, 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, 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, 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, + pub source: ApiCatalogSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCreativeSnapshotSummary { + pub snapshot_id: String, + pub label: Option, + pub pattern_id: String, + pub target_group: Option, + 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, + overwrite: bool, + }, + RecallCreativeSnapshot { + snapshot_id: String, + }, + UpsertGroup { + group_id: String, + tags: Vec, + members: Vec, + 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 for ApiEventNotice { + fn from(event: infinity_host::StatusEvent) -> Self { + Self { + kind: map_event_kind(event.kind), + code: event.code, + message: event.message, + } + } +} diff --git a/crates/infinity_host_api/src/main.rs b/crates/infinity_host_api/src/main.rs index 77154f1..4a2577f 100644 --- a/crates/infinity_host_api/src/main.rs +++ b/crates/infinity_host_api/src/main.rs @@ -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> { let cli = Cli::parse(); let project = load_project(&cli.config)?; - let service: Arc = SimulationHostService::spawn_shared(project); + let service: Arc = + 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)); diff --git a/crates/infinity_host_api/src/server.rs b/crates/infinity_host_api/src/server.rs index 9f9ce51..c2e9fc5 100644 --- a/crates/infinity_host_api/src/server.rs +++ b/crates/infinity_host_api/src/server.rs @@ -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>, } +#[derive(Debug)] +struct ApiRequestError { + status: u16, + code: String, + message: String, +} + impl HostApiServer { pub fn bind(bind: &str, service: Arc) -> io::Result { let listener = TcpListener::bind(bind)?; @@ -86,6 +93,14 @@ fn handle_connection(mut stream: TcpStream, service: Arc) -> 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) -> 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, -) -> io::Result<()> { +) -> Result<(), ApiRequestError> { let parsed = serde_json::from_slice::(&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!( diff --git a/crates/infinity_host_api/tests/contract.rs b/crates/infinity_host_api/tests/contract.rs index 1cd7eff..88ab584 100644 --- a/crates/infinity_host_api/tests/contract.rs +++ b/crates/infinity_host_api/tests/contract.rs @@ -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 = SimulationHostService::spawn_shared(sample_project()); + let service: Arc = 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 = 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")) +} diff --git a/crates/infinity_host_ui/src/app.rs b/crates/infinity_host_ui/src/app.rs index c1af658..66f85f9 100644 --- a/crates/infinity_host_ui/src/app.rs +++ b/crates/infinity_host_ui/src/app.rs @@ -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(), diff --git a/docs/architecture.md b/docs/architecture.md index 00e7377..fe62b76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,6 +55,13 @@ Every surface must talk to the same host API: - CLI inspection - future grandMA adapter +The host core now also carries a runtime show store and persistence layer for: + +- saved presets +- runtime user groups +- active scene state +- creative snapshots and variants + The current software-first implementation uses a simulation-backed host API so looks, presets, parameters, and grouping can be developed before real node activation. ## Modes @@ -104,5 +111,5 @@ The codebase deliberately blocks activation when these remain unresolved: 1. Expand creative authoring on top of the now-versioned host API and web UI 2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin 3. Implement transport adapters without coupling them to any single frontend -4. Add future external show-control bridges such as grandMA on the same API boundary +4. Add future external show-control bridges such as grandMA on the same API boundary and generic adapter interface 5. Keep hardware activation behind explicit later validation gates diff --git a/docs/build_and_deploy.md b/docs/build_and_deploy.md index 481abb3..6d57c24 100644 --- a/docs/build_and_deploy.md +++ b/docs/build_and_deploy.md @@ -12,13 +12,13 @@ Suggested commands: ```powershell cargo test cargo run -p infinity_host -- snapshot --config config/project.example.toml -cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 +cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json cargo run -p infinity_host_ui cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue ``` -The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`. +The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`. Runtime creative data such as saved presets, groups, active scene state, and creative snapshots are persisted to `data/runtime_state.json` by default. The native engineering UI and the CLI snapshot continue to run against the same simulation-backed host core so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete. diff --git a/docs/host_api.md b/docs/host_api.md index 8af29c2..bc16d5a 100644 --- a/docs/host_api.md +++ b/docs/host_api.md @@ -2,58 +2,107 @@ ## Purpose -The host API is the stable external boundary for: +The host API is the stable external product boundary for: - the creative web UI -- the existing engineering GUI -- future external show-control adapters such as grandMA +- the native engineering GUI +- future remote operator clients +- later external show-control adapters such as a grandMA bridge -The core rule stays unchanged: +The realtime rule remains strict: - the API is a control and observation layer -- the realtime engine remains the timing authority -- no surface is allowed to become the LED clock +- the host core remains the timing authority +- no frontend or external adapter is allowed to become the LED clock -## Current Implementation +## Runtime Components -Runtime pieces: +Core and API implementation: - `crates/infinity_host/src/control.rs` - `crates/infinity_host/src/scene.rs` +- `crates/infinity_host/src/show_store.rs` - `crates/infinity_host/src/simulation.rs` +- `crates/infinity_host/src/external_control.rs` - `crates/infinity_host_api/src/dto.rs` - `crates/infinity_host_api/src/server.rs` -The network-facing server is started with: +Server startup: ```powershell -cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 +cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json ``` -Creative web UI V1 is served by the same process at: +Creative web UI V1 is served by the same process: ```text http://127.0.0.1:9001/ ``` -## Versioning +## Versioning Policy -- HTTP and WebSocket routes are versioned under `/api/v1` -- responses include `api_version: "v1"` -- the external DTOs are intentionally not a direct 1:1 dump of internal core structs +The current public contract is `v1`. -## Endpoint Contract +Rules: -### GET `/api/v1/snapshot` +- all public HTTP and WebSocket routes are namespaced under `/api/v1` +- every response body carries `api_version: "v1"` +- additive fields are allowed inside `v1` +- semantic breaking changes require a new version namespace +- external consumers must treat undocumented internal-only fields as unstable and ignore them -Returns the current state and preview in one response. +## Stable External Models + +Stable external response families: + +- command response +- state snapshot +- preview snapshot +- combined snapshot +- catalog +- event stream +- typed error object + +Stable external command families: + +- global control +- pattern and preset selection +- group targeting +- scene parameter updates +- transition configuration +- preset persistence +- creative snapshot persistence and recall +- panel test trigger + +## Internal Versus External Fields + +External and stable in `v1`: + +- every field defined in `crates/infinity_host_api/src/dto.rs` +- route names and payload shapes documented below +- error object shape `{ api_version, error: { code, message } }` +- event stream envelope shape `{ api_version, sequence, generated_at_millis, message }` + +Internal and not part of the API contract: + +- the exact shape of `HostSnapshot` in `crates/infinity_host/src/control.rs` +- simulation-only storage layout in `data/runtime_state.json` +- internal event history buffering size +- internal scene library structures in `show_store.rs` +- engineering-GUI-specific rendering or polling behavior + +## HTTP Endpoints + +### GET `/api/v1/state` + +Returns only the stable state snapshot. Example: ```json { "api_version": "v1", - "generated_at_millis": 241, + "generated_at_millis": 512, "state": { "system": { "project_name": "Infinity Vis", @@ -63,104 +112,113 @@ Example: "global": { "blackout": false, "master_brightness": 0.2, - "selected_pattern": "solid_color", - "selected_group": null, - "transition_duration_ms": 150 + "selected_pattern": "gradient", + "selected_group": "top_panels", + "transition_duration_ms": 320, + "transition_style": "chase" }, "engine": { "logic_hz": 120, "frame_hz": 60, "preview_hz": 15, - "uptime_ms": 241, - "frame_index": 14, + "uptime_ms": 512, + "frame_index": 30, "dropped_frames": 0, - "active_transition": null - }, - "active_scene": { - "preset_id": null, - "pattern_id": "solid_color", - "seed": 100, - "palette": [ - "#ffffff" - ], - "parameters": [], - "target_group": null, - "blackout": false + "active_transition": { + "style": "chase", + "from_pattern_id": "solid_color", + "to_pattern_id": "gradient", + "duration_ms": 320, + "progress": 0.28 + } } - }, + } +} +``` + +### GET `/api/v1/preview` + +Returns only the stable preview snapshot. + +Example: + +```json +{ + "api_version": "v1", + "generated_at_millis": 512, "preview": { - "generated_at_millis": 241, + "generated_at_millis": 512, "panels": [ { "node_id": "node-01", "panel_position": "top", - "representative_color_hex": "#33CCFF", + "representative_color_hex": "#FF8A5B", "sample_led_hex": [ - "#33CCFF", - "#28A3CC", - "#1E7A99" + "#FF8A5B", + "#F36E43", + "#D85A2F" ], - "energy_percent": 28, - "source": "scene" + "energy_percent": 47, + "source": "transition" } ] } } ``` +### GET `/api/v1/snapshot` + +Returns the convenience composition of `state` plus `preview`. + +This route exists for lightweight clients and debugging. Consumers that want strict separation should prefer `GET /api/v1/state` and `GET /api/v1/preview`. + ### GET `/api/v1/catalog` -Returns the creative catalog: +Returns the stable creative library: - patterns - presets - groups +- creative snapshots + +Example preset summary: + +```json +{ + "preset_id": "ocean_gradient", + "pattern_id": "gradient", + "target_group": null, + "transition_duration_ms": 320, + "transition_style": "crossfade", + "source": "built_in" +} +``` + +Example creative snapshot summary: + +```json +{ + "snapshot_id": "variant_floor", + "label": "Variant Floor", + "pattern_id": "noise", + "target_group": "bottom_panels", + "transition_duration_ms": 220, + "transition_style": "chase", + "saved_at_unix_ms": 1760000000000 +} +``` ### GET `/api/v1/presets` Returns only preset summaries. -Example: - -```json -{ - "api_version": "v1", - "presets": [ - { - "preset_id": "ocean_gradient", - "pattern_id": "gradient", - "target_group": null, - "transition_duration_ms": 320 - } - ] -} -``` - ### GET `/api/v1/groups` Returns only group summaries. -Example: - -```json -{ - "api_version": "v1", - "groups": [ - { - "group_id": "top_panels", - "member_count": 6, - "tags": [ - "row", - "top" - ] - } - ] -} -``` - ### POST `/api/v1/command` -Accepts a versioned command envelope. +Accepts one versioned command envelope. Example request: @@ -168,9 +226,11 @@ Example request: { "request_id": "web-1713352662000", "command": { - "type": "set_master_brightness", + "type": "save_creative_snapshot", "payload": { - "value": 0.42 + "snapshot_id": "variant_floor", + "label": "Variant Floor", + "overwrite": false } } } @@ -184,85 +244,44 @@ Example response: "accepted": true, "request_id": "web-1713352662000", "generated_at_millis": 522, - "summary": "master brightness set to 42%" + "command_type": "save_creative_snapshot", + "summary": "creative snapshot saved: variant_floor" } ``` -Errors use a stable error object: +## Stable Error Object + +All API failures return: ```json { "api_version": "v1", "error": { - "code": "invalid_command", - "message": "command request was rejected: missing field `enabled`" + "code": "unknown_creative_snapshot", + "message": "creative snapshot 'does_not_exist' does not exist" } } ``` +Stable `v1` error families currently include: + +- `invalid_request_json` +- `invalid_command` +- `unknown_group` +- `unknown_preset` +- `unknown_creative_snapshot` +- `preset_exists` +- `snapshot_exists` +- `group_exists` +- `persist_failed` +- `missing_websocket_key` +- `not_found` + +## WebSocket Event Stream + ### WS `/api/v1/stream` -The WebSocket stream emits envelopes with a monotonic sequence and a typed payload. - -Stream message types: - -- `snapshot` -- `preview` -- `event` - -Example snapshot envelope: - -```json -{ - "api_version": "v1", - "sequence": 17, - "generated_at_millis": 875, - "message": { - "type": "snapshot", - "payload": { - "global": { - "blackout": false, - "master_brightness": 0.35, - "selected_pattern": "gradient", - "selected_group": "top_panels", - "transition_duration_ms": 320 - } - } - } -} -``` - -Example preview envelope: - -```json -{ - "api_version": "v1", - "sequence": 18, - "generated_at_millis": 875, - "message": { - "type": "preview", - "payload": { - "generated_at_millis": 875, - "panels": [ - { - "node_id": "node-01", - "panel_position": "top", - "representative_color_hex": "#FF8A5B", - "sample_led_hex": [ - "#FF8A5B", - "#F36E43", - "#D85A2F" - ], - "energy_percent": 47, - "source": "transition" - } - ] - } - } -} -``` - -Example event envelope: +The stream emits a typed envelope with a monotonic sequence counter: ```json { @@ -272,16 +291,29 @@ Example event envelope: "message": { "type": "event", "payload": { - "kind": "info", - "message": "preset recalled: ocean_gradient" + "kind": "warning", + "code": "unknown_creative_snapshot", + "message": "creative snapshot 'does_not_exist' does not exist" } } } ``` -## Supported Commands +Stable message types: -The current API command set covers: +- `snapshot` +- `preview` +- `event` + +Stable event kinds: + +- `info` +- `warning` +- `error` + +## Guaranteed Commands In `v1` + +Guaranteed control commands: - `set_blackout` - `set_master_brightness` @@ -290,53 +322,76 @@ The current API command set covers: - `select_group` - `set_scene_parameter` - `set_transition_duration_ms` +- `set_transition_style` - `trigger_panel_test` -This is intentionally enough for: +Guaranteed persistence and creative-library commands: -- creative look development in the web UI -- engineering test triggers in the native GUI -- future external show-control translation layers +- `save_preset` +- `save_creative_snapshot` +- `recall_creative_snapshot` +- `upsert_group` -## Web UI V1 +## Persistence Behavior -The first creative web UI is intentionally limited to: +The simulation-backed host service now persists runtime-facing creative data to `data/runtime_state.json` by default. -- pattern selection -- preset recall -- group selection -- global brightness -- blackout -- transition duration -- scene parameter controls driven from the API schema -- panel preview -- snapshot display -- event feed +Persisted data includes: -It does not absorb mapping, topology, or hardware-diagnostic workflows. Those stay in the native engineering UI. +- active scene +- global blackout and brightness state +- transition duration and style +- runtime user presets +- runtime user groups +- creative snapshots and variants -## Contract Tests +This persistence file is an internal runtime artifact, not the public API contract. -The API contract is currently verified in: +## External Show Control Adapter Boundary + +The generic internal adapter surface lives in: + +- `crates/infinity_host/src/external_control.rs` + +Key rule: + +- future adapters may only translate external intent into the defined host command surface +- they must not reach into simulation internals, UI state, or hardware driver details directly + +## Contract And Integration Coverage + +Current software-side hardening lives in: - `crates/infinity_host_api/tests/contract.rs` +- `crates/infinity_host/src/show_store.rs` tests +- `crates/infinity_host/src/simulation.rs` tests -Covered paths: +Covered flows include: -- root web shell -- `GET /api/v1/snapshot` -- `GET /api/v1/catalog` -- `GET /api/v1/presets` -- `GET /api/v1/groups` -- `POST /api/v1/command` -- `WS /api/v1/stream` +- state, preview, snapshot, catalog, presets, and groups endpoints +- command success and typed command failure +- WebSocket snapshot, preview, and event messages +- group targeting +- parameter updates +- transition configuration +- blackout +- preset save +- creative snapshot save and recall +- persistence across restart +- a longer ignored load-oriented sequence for platform hardening -## Future Direction +## Web UI Scope -Next adapters should be built on this boundary instead of reaching into the host core directly. +The current web UI intentionally focuses on creative use: -That includes: +- pattern and preset selection +- group targeting +- transition configuration +- scene parameters +- preset save and overwrite +- creative snapshot save and recall +- preview +- raw snapshot display +- filterable event feed -- a richer web authoring surface -- remote operator clients -- a grandMA bridge that translates external show control into host API commands +Mapping, topology diagnostics, panel-test administration, and low-level node status remain primarily in the native engineering GUI. diff --git a/web/v1/app.js b/web/v1/app.js index 272cb90..33569cb 100644 --- a/web/v1/app.js +++ b/web/v1/app.js @@ -1,9 +1,8 @@ (function () { const apiState = { - snapshot: null, + stateResponse: null, + previewResponse: null, catalog: null, - presets: [], - groups: [], events: [], ws: null, commandTimers: new Map(), @@ -18,15 +17,27 @@ patternSelect: document.getElementById("pattern-select"), transitionSlider: document.getElementById("transition-slider"), transitionValue: document.getElementById("transition-value"), + transitionStyleSelect: document.getElementById("transition-style-select"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), presetList: document.getElementById("preset-list"), + presetIdInput: document.getElementById("preset-id-input"), + presetOverwriteInput: document.getElementById("preset-overwrite-input"), + savePresetButton: document.getElementById("save-preset-button"), + groupFilterInput: document.getElementById("group-filter-input"), groupList: document.getElementById("group-list"), + snapshotIdInput: document.getElementById("snapshot-id-input"), + snapshotLabelInput: document.getElementById("snapshot-label-input"), + snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"), + saveSnapshotButton: document.getElementById("save-snapshot-button"), + snapshotList: document.getElementById("snapshot-list"), sceneParams: document.getElementById("scene-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), snapshotJson: document.getElementById("snapshot-json"), + eventKindFilter: document.getElementById("event-kind-filter"), + eventSearchFilter: document.getElementById("event-search-filter"), eventList: document.getElementById("event-list"), }; @@ -49,12 +60,19 @@ dom.transitionSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.transitionValue.textContent = `${value} ms`; - debounceCommand("transition", { + debounceCommand("transition-duration", { type: "set_transition_duration_ms", payload: { duration_ms: value }, }); }); + dom.transitionStyleSelect.addEventListener("change", (event) => { + sendCommand({ + type: "set_transition_style", + payload: { style: event.target.value }, + }); + }); + dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; @@ -65,36 +83,83 @@ }); dom.blackoutButton.addEventListener("click", () => { - const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false); + const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false); sendCommand({ type: "set_blackout", payload: { enabled }, }); }); + + dom.savePresetButton.addEventListener("click", async () => { + const presetId = dom.presetIdInput.value.trim(); + if (!presetId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "preset_id_required", + message: "Preset ID is required before saving.", + }); + return; + } + + await sendCommand({ + type: "save_preset", + payload: { + preset_id: presetId, + overwrite: dom.presetOverwriteInput.checked, + }, + }); + }); + + dom.saveSnapshotButton.addEventListener("click", async () => { + const snapshotId = dom.snapshotIdInput.value.trim(); + if (!snapshotId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "snapshot_id_required", + message: "Snapshot ID is required before saving a creative variant.", + }); + return; + } + + await sendCommand({ + type: "save_creative_snapshot", + payload: { + snapshot_id: snapshotId, + label: dom.snapshotLabelInput.value.trim() || null, + overwrite: dom.snapshotOverwriteInput.checked, + }, + }); + }); + + dom.groupFilterInput.addEventListener("input", () => renderGroups()); + dom.eventKindFilter.addEventListener("change", () => renderEvents()); + dom.eventSearchFilter.addEventListener("input", () => renderEvents()); } async function refreshAll() { setConnectionState("connecting", "loading"); try { - const [snapshot, catalog, presets, groups] = await Promise.all([ - fetchJson("/api/v1/snapshot"), + const [stateResponse, previewResponse, catalog] = await Promise.all([ + fetchJson("/api/v1/state"), + fetchJson("/api/v1/preview"), fetchJson("/api/v1/catalog"), - fetchJson("/api/v1/presets"), - fetchJson("/api/v1/groups"), ]); - apiState.snapshot = snapshot; + apiState.stateResponse = stateResponse; + apiState.previewResponse = previewResponse; apiState.catalog = catalog; - apiState.presets = presets.presets || catalog.presets || []; - apiState.groups = groups.groups || catalog.groups || []; renderAll(); - setConnectionState("online", "HTTP snapshot synced"); + setConnectionState("online", "HTTP sync"); } catch (error) { console.error(error); setConnectionState("offline", "snapshot fetch failed"); pushEvent({ at: new Date().toLocaleTimeString(), + kind: "error", + code: "http_refresh_failed", message: `HTTP refresh failed: ${error.message}`, }); } @@ -110,6 +175,8 @@ setConnectionState("online", "stream connected"); pushEvent({ at: new Date().toLocaleTimeString(), + kind: "info", + code: "stream_connected", message: "WebSocket stream connected", }); }); @@ -123,6 +190,8 @@ setConnectionState("offline", "stream disconnected"); pushEvent({ at: new Date().toLocaleTimeString(), + kind: "warning", + code: "stream_reconnect", message: "WebSocket stream closed, retrying", }); window.setTimeout(connectStream, 1500); @@ -139,26 +208,22 @@ return; } - if (!apiState.snapshot) { - apiState.snapshot = { + if (message.type === "snapshot") { + apiState.stateResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, - state: null, - preview: null, + state: message.payload, }; - } - - if (message.type === "snapshot") { - apiState.snapshot.api_version = envelope.api_version; - apiState.snapshot.generated_at_millis = envelope.generated_at_millis; - apiState.snapshot.state = message.payload; renderState(); return; } if (message.type === "preview") { - apiState.snapshot.preview = message.payload; - apiState.snapshot.generated_at_millis = envelope.generated_at_millis; + apiState.previewResponse = { + api_version: envelope.api_version, + generated_at_millis: envelope.generated_at_millis, + preview: message.payload, + }; renderPreview(); renderSnapshotJson(); return; @@ -167,6 +232,8 @@ if (message.type === "event") { pushEvent({ at: `${envelope.generated_at_millis} ms`, + kind: message.payload.kind || "info", + code: message.payload.code || null, message: message.payload.message, }); } @@ -184,15 +251,21 @@ }); pushEvent({ at: new Date().toLocaleTimeString(), + kind: "info", + code: response.command_type, message: response.summary, }); await refreshAll(); + return response; } catch (error) { console.error(error); pushEvent({ at: new Date().toLocaleTimeString(), + kind: "error", + code: "command_failed", message: `Command failed: ${error.message}`, }); + return null; } } @@ -232,17 +305,16 @@ } function renderState() { - if (!apiState.snapshot?.state) { + const state = apiState.stateResponse?.state; + if (!state) { return; } - const snapshot = apiState.snapshot; - const state = snapshot.state; const global = state.global; const scene = state.active_scene; dom.projectName.textContent = state.system.project_name; - dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`; + dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; dom.patternSelect.innerHTML = ""; (apiState.catalog?.patterns || []).forEach((pattern) => { @@ -255,22 +327,23 @@ dom.transitionSlider.value = String(global.transition_duration_ms); dom.transitionValue.textContent = `${global.transition_duration_ms} ms`; + dom.transitionStyleSelect.value = global.transition_style; dom.brightnessSlider.value = String(global.master_brightness); dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; dom.blackoutButton.classList.toggle("is-active", global.blackout); - renderPresetButtons(scene, global); - renderGroupButtons(global); + renderPresets(scene); + renderGroups(global); + renderCreativeSnapshots(); renderSceneParameters(scene); - renderSummaryCards(state, snapshot.generated_at_millis); + renderSummaryCards(state); renderSnapshotJson(); - dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`; } - function renderPresetButtons(scene) { + function renderPresets(scene) { dom.presetList.innerHTML = ""; - const presets = apiState.presets || []; + const presets = apiState.catalog?.presets || []; if (!presets.length) { dom.presetList.innerHTML = '
No presets available.
'; return; @@ -283,7 +356,7 @@ button.classList.toggle("active", scene.preset_id === preset.preset_id); button.innerHTML = ` ${preset.preset_id} -
${preset.pattern_id} / ${preset.transition_duration_ms} ms
+
${preset.pattern_id} / ${preset.transition_style} / ${preset.source}
`; button.addEventListener("click", () => sendCommand({ @@ -295,8 +368,18 @@ }); } - function renderGroupButtons(global) { + function renderGroups(global) { dom.groupList.innerHTML = ""; + const filterValue = dom.groupFilterInput.value.trim().toLowerCase(); + const groups = (apiState.catalog?.groups || []).filter((group) => { + if (!filterValue) { + return true; + } + return ( + group.group_id.toLowerCase().includes(filterValue) || + (group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue)) + ); + }); const allButton = document.createElement("button"); allButton.type = "button"; @@ -311,14 +394,22 @@ ); dom.groupList.appendChild(allButton); - (apiState.groups || []).forEach((group) => { + if (!groups.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "No groups match the current filter."; + dom.groupList.appendChild(empty); + return; + } + + groups.forEach((group) => { const button = document.createElement("button"); button.type = "button"; button.className = "group-button"; button.classList.toggle("active", group.group_id === global.selected_group); button.innerHTML = ` ${group.group_id} -
${group.member_count} members
+
${group.member_count} members / ${group.source}
`; button.addEventListener("click", () => sendCommand({ @@ -330,6 +421,43 @@ }); } + function renderCreativeSnapshots() { + dom.snapshotList.innerHTML = ""; + const snapshots = apiState.catalog?.creative_snapshots || []; + if (!snapshots.length) { + dom.snapshotList.innerHTML = + '
No creative snapshots saved yet.
'; + return; + } + + snapshots.forEach((snapshot) => { + const card = document.createElement("article"); + card.className = "snapshot-card"; + card.innerHTML = ` +
+
+ ${snapshot.label || snapshot.snapshot_id} +
${snapshot.snapshot_id}
+
+ +
+
+ ${snapshot.pattern_id} + ${snapshot.transition_style} + ${snapshot.transition_duration_ms} ms + ${snapshot.target_group || "all_panels"} +
+ `; + card.querySelector("button").addEventListener("click", () => + sendCommand({ + type: "recall_creative_snapshot", + payload: { snapshot_id: snapshot.snapshot_id }, + }) + ); + dom.snapshotList.appendChild(card); + }); + } + function renderSceneParameters(scene) { dom.sceneParams.innerHTML = ""; const parameters = scene.parameters || []; @@ -417,7 +545,7 @@ } function renderPreview() { - const preview = apiState.snapshot?.preview; + const preview = apiState.previewResponse?.preview; dom.previewGrid.innerHTML = ""; if (!preview?.panels?.length) { dom.previewGrid.innerHTML = @@ -425,6 +553,7 @@ return; } + dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`; const panels = [...preview.panels].sort(comparePreviewPanels); panels.forEach((panel) => { const card = document.createElement("article"); @@ -439,6 +568,7 @@ ${panel.energy_percent}%
+
${panel.sample_led_hex .map( @@ -452,11 +582,12 @@ }); } - function renderSummaryCards(state, generatedAtMillis) { + function renderSummaryCards(state) { const scene = state.active_scene; const global = state.global; const engine = state.engine; const nodeStats = summarizeNodes(state.nodes || []); + const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length; const cards = [ { @@ -467,11 +598,11 @@ { label: "Group Target", value: scene.target_group || "all_panels", - detail: `${(apiState.groups || []).length} groups available`, + detail: `${(apiState.catalog?.groups || []).length} groups available`, }, { label: "Transition", - value: `${global.transition_duration_ms} ms`, + value: `${global.transition_style} / ${global.transition_duration_ms} ms`, detail: engine.active_transition ? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%` : "idle", @@ -492,9 +623,9 @@ detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, }, { - label: "Preview Timestamp", - value: `${generatedAtMillis} ms`, - detail: `${state.system.schema_version} schema`, + label: "Creative Snapshots", + value: `${creativeSnapshotCount}`, + detail: `${(apiState.catalog?.presets || []).length} presets in library`, }, ]; @@ -512,28 +643,55 @@ } function renderSnapshotJson() { - dom.snapshotJson.textContent = apiState.snapshot - ? JSON.stringify(apiState.snapshot, null, 2) - : "No snapshot loaded."; + dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2); + } + + function buildComposedSnapshot() { + return { + api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1", + generated_at_millis: + apiState.previewResponse?.generated_at_millis || + apiState.stateResponse?.generated_at_millis || + 0, + state: apiState.stateResponse?.state || null, + preview: apiState.previewResponse?.preview || null, + catalog: apiState.catalog || null, + }; } function pushEvent(entry) { - apiState.events.unshift(entry); - apiState.events = apiState.events.slice(0, 12); + apiState.events.unshift({ + kind: entry.kind || "info", + code: entry.code || null, + ...entry, + }); + apiState.events = apiState.events.slice(0, 50); renderEvents(); } function renderEvents() { - if (!apiState.events.length) { - dom.eventList.innerHTML = '
No websocket notices yet.
'; + const kindFilter = dom.eventKindFilter.value; + const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase(); + const filtered = apiState.events.filter((entry) => { + const kindMatches = kindFilter === "all" || entry.kind === kindFilter; + const searchMatches = + !searchFilter || + (entry.message || "").toLowerCase().includes(searchFilter) || + (entry.code || "").toLowerCase().includes(searchFilter); + return kindMatches && searchMatches; + }); + + if (!filtered.length) { + dom.eventList.innerHTML = '
No events match the current filter.
'; return; } - dom.eventList.innerHTML = apiState.events + dom.eventList.innerHTML = filtered .map( (entry) => ` -
+
${entry.at}
+ ${entry.code ? `${entry.code}` : ""} ${entry.message}
` diff --git a/web/v1/index.html b/web/v1/index.html index c60a733..12392c3 100644 --- a/web/v1/index.html +++ b/web/v1/index.html @@ -45,11 +45,20 @@ + +
+
+
+

Preset Capture

+

Store or overwrite the current scene as a reusable preset through the same API.

+
+
+ + + +
+
+

Groups

Focus looks on a subset while keeping the core scene model shared.

+
+
+
+

Creative Snapshots

+

Capture exploratory variants without replacing curated presets.

+
+
+ + + + +
+
+
+

Scene Parameters

@@ -111,6 +171,20 @@

Event Stream

Recent notices from the websocket feed.

+
+ + +
diff --git a/web/v1/styles.css b/web/v1/styles.css index d7d99ea..5afc2b4 100644 --- a/web/v1/styles.css +++ b/web/v1/styles.css @@ -202,6 +202,7 @@ body::after { } .control-grid, +.capture-grid, .parameter-grid, .summary-cards { display: grid; @@ -212,6 +213,11 @@ body::after { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.capture-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + align-items: end; +} + .parameter-grid, .summary-cards { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); @@ -295,11 +301,26 @@ input[type="text"] { color: var(--text); } +.filter-input { + margin-bottom: 12px; +} + input[type="range"] { width: 100%; accent-color: var(--accent); } +.inline-checkbox { + align-content: start; +} + +.inline-checkbox input[type="checkbox"] { + width: 20px; + height: 20px; + margin: 4px 0 0; + accent-color: var(--accent-cool); +} + .ghost-button, .preset-button, .group-button { @@ -374,6 +395,21 @@ input[type="range"] { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); } +.energy-bar { + height: 8px; + margin-top: 12px; + border-radius: 999px; + background: rgba(31, 36, 36, 0.08); + overflow: hidden; +} + +.energy-bar > span { + display: block; + height: 100%; + width: var(--energy-width, 0%); + background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84)); +} + .sample-row { display: flex; flex-wrap: wrap; @@ -429,11 +465,84 @@ input[type="range"] { gap: 12px; } +.event-filter-bar { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 12px; + margin-bottom: 14px; +} + .event-item { display: grid; gap: 8px; } +.event-item.event-info strong { + color: var(--accent-strong); +} + +.event-item.event-warning strong { + color: #a7631c; +} + +.event-item.event-error strong { + color: var(--danger); +} + +.event-code { + display: inline-flex; + width: fit-content; + padding: 4px 8px; + border-radius: 999px; + background: rgba(31, 36, 36, 0.08); + color: var(--muted); + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.snapshot-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.snapshot-card { + display: grid; + gap: 8px; + padding: 14px; + border-radius: var(--radius-md); + background: var(--surface-strong); + border: 1px solid rgba(56, 63, 61, 0.08); +} + +.snapshot-card-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.snapshot-card-header strong { + display: block; + margin-bottom: 2px; +} + +.snapshot-meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meta-chip { + display: inline-flex; + padding: 5px 8px; + border-radius: 999px; + background: rgba(31, 36, 36, 0.08); + color: var(--muted); + font-size: 0.82rem; +} + .event-item strong { color: var(--accent-strong); } @@ -460,7 +569,8 @@ input[type="range"] { @media (max-width: 1080px) { .layout, .hero, - .control-grid { + .control-grid, + .event-filter-bar { grid-template-columns: 1fr; }