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

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

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

1
Cargo.lock generated
View File

@@ -1706,6 +1706,7 @@ dependencies = [
"infinity_protocol", "infinity_protocol",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 1.0.69",
] ]
[[package]] [[package]]

View File

@@ -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). 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). 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`. 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. 6. Open `http://127.0.0.1:9001/` for the creative surface.
7. Start the engineering GUI with `cargo run -p infinity_host_ui`. 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. 8. Use the host CLI to validate the project config before attempting activation.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState}; use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{ use infinity_host::{
HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, SceneParameterKind, CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
SceneParameterValue, SceneTransitionStyle, TestPatternKind, SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
TestPatternKind,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -15,12 +16,27 @@ pub struct ApiSnapshotResponse {
pub preview: ApiPreviewSnapshot, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse { pub struct ApiCatalogResponse {
pub api_version: &'static str, pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>, pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>, pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>, pub groups: Vec<ApiGroupSummary>,
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -48,6 +64,7 @@ pub struct ApiCommandResponse {
pub accepted: bool, pub accepted: bool,
pub request_id: Option<String>, pub request_id: Option<String>,
pub generated_at_millis: u64, pub generated_at_millis: u64,
pub command_type: String,
pub summary: String, pub summary: String,
} }
@@ -98,6 +115,7 @@ pub enum ApiStreamMessage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice { pub struct ApiEventNotice {
pub kind: ApiEventKind, pub kind: ApiEventKind,
pub code: Option<String>,
pub message: String, pub message: String,
} }
@@ -105,6 +123,8 @@ pub struct ApiEventNotice {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ApiEventKind { pub enum ApiEventKind {
Info, Info,
Warning,
Error,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -121,6 +141,7 @@ pub struct ApiGlobalState {
pub selected_pattern: String, pub selected_pattern: String,
pub selected_group: Option<String>, pub selected_group: Option<String>,
pub transition_duration_ms: u32, pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -198,6 +219,8 @@ pub struct ApiPresetSummary {
pub pattern_id: String, pub pattern_id: String,
pub target_group: Option<String>, pub target_group: Option<String>,
pub transition_duration_ms: u32, pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub source: ApiCatalogSource,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -205,6 +228,18 @@ pub struct ApiGroupSummary {
pub group_id: String, pub group_id: String,
pub member_count: usize, pub member_count: usize,
pub tags: Vec<String>, pub tags: Vec<String>,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub saved_at_unix_ms: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -277,6 +312,13 @@ pub enum ApiParameterKind {
Text, 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)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ApiLedDirection { pub enum ApiLedDirection {
@@ -336,11 +378,38 @@ pub enum ApiCommand {
SetTransitionDurationMs { SetTransitionDurationMs {
duration_ms: u32, duration_ms: u32,
}, },
SetTransitionStyle {
style: ApiTransitionStyle,
},
TriggerPanelTest { TriggerPanelTest {
node_id: String, node_id: String,
panel_position: ApiPanelPosition, panel_position: ApiPanelPosition,
pattern: ApiTestPattern, pattern: ApiTestPattern,
}, },
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<ApiPanelRef>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiPanelRef {
pub node_id: String,
pub panel_position: ApiPanelPosition,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[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 { impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self { pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self { Self {
@@ -398,6 +487,8 @@ impl ApiCatalogResponse {
pattern_id: preset.pattern_id.clone(), pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(), target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms, transition_duration_ms: preset.transition_duration_ms,
transition_style: map_transition_style(preset.transition_style),
source: map_catalog_source(preset.source),
}) })
.collect(), .collect(),
groups: snapshot groups: snapshot
@@ -408,6 +499,21 @@ impl ApiCatalogResponse {
group_id: group.group_id.clone(), group_id: group.group_id.clone(),
member_count: group.member_count, member_count: group.member_count,
tags: group.tags.clone(), 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(), .collect(),
} }
@@ -446,6 +552,7 @@ impl ApiStateSnapshot {
selected_pattern: snapshot.global.selected_pattern.clone(), selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(), selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms, transition_duration_ms: snapshot.global.transition_duration_ms,
transition_style: map_transition_style(snapshot.global.transition_style),
}, },
engine: ApiEngineState { engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz, logic_hz: snapshot.engine.logic_hz,
@@ -565,6 +672,9 @@ impl ApiCommandRequest {
ApiCommand::SetTransitionDurationMs { duration_ms } => { ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms)) Ok(HostCommand::SetTransitionDurationMs(duration_ms))
} }
ApiCommand::SetTransitionStyle { style } => {
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
}
ApiCommand::TriggerPanelTest { ApiCommand::TriggerPanelTest {
node_id, node_id,
panel_position, panel_position,
@@ -578,6 +688,42 @@ impl ApiCommandRequest {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106, 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 { fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind { match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar, SceneParameterKind::Scalar => ApiParameterKind::Scalar,
@@ -678,6 +847,24 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
} }
impl ApiCommand { 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 { pub fn summary(&self) -> String {
match self { match self {
Self::SetBlackout { enabled } => { Self::SetBlackout { enabled } => {
@@ -700,6 +887,9 @@ impl ApiCommand {
Self::SetTransitionDurationMs { duration_ms } => { Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms") format!("transition duration set to {duration_ms} ms")
} }
Self::SetTransitionStyle { style } => {
format!("transition style set to {}", style.label())
}
Self::TriggerPanelTest { Self::TriggerPanelTest {
node_id, node_id,
panel_position, panel_position,
@@ -710,6 +900,38 @@ impl ApiCommand {
node_id, node_id,
panel_position.label() 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 { impl ApiTestPattern {
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
@@ -743,3 +975,13 @@ impl ApiErrorResponse {
} }
} }
} }
impl From<infinity_host::StatusEvent> for ApiEventNotice {
fn from(event: infinity_host::StatusEvent) -> Self {
Self {
kind: map_event_kind(event.kind),
code: event.code,
message: event.message,
}
}
}

View File

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

View File

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

View File

@@ -5,8 +5,9 @@ use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream}; use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig { fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml")) ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
@@ -14,7 +15,15 @@ fn sample_project() -> ProjectConfig {
} }
fn start_server() -> HostApiServer { fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project()); let service: Arc<dyn HostApiPort> = Arc::new(SimulationHostService::new(sample_project()));
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(
SimulationHostService::try_new_with_persistence(sample_project(), path)
.expect("persistent service must initialize"),
);
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind") HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
} }
@@ -25,118 +34,183 @@ struct HttpResponse {
} }
#[test] #[test]
fn root_serves_creative_console_shell() { fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server(); 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_eq!(html.status_code, 200);
assert!(response assert!(html
.headers .headers
.get("content-type") .get("content-type")
.expect("content-type header") .expect("content-type header")
.starts_with("text/html")); .starts_with("text/html"));
assert!(response.body.contains("Infinity Vis / Creative Surface")); assert!(html.body.contains("Preset Capture"));
assert!(response.body.contains("/app.js")); 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(); server.shutdown();
} }
#[test] #[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 server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None); let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse"); let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
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 snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", 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"); let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(response.status_code, 200); assert_eq!(state.status_code, 200);
assert_eq!(response_body["accepted"], true); assert_eq!(state_body["api_version"], "v1");
assert_eq!(response_body["request_id"], "contract-blackout"); assert!(state_body.get("state").is_some());
assert_eq!(snapshot_body["state"]["global"]["blackout"], true); 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( assert_eq!(preview.status_code, 200);
server.local_addr(), assert_eq!(preview_body["api_version"], "v1");
"POST", assert!(preview_body.get("preview").is_some());
"/api/v1/command", assert!(preview_body.get("state").is_none());
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#), assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
);
let invalid_body: Value =
serde_json::from_str(&invalid.body).expect("invalid response must parse");
assert_eq!(invalid.status_code, 400); assert_eq!(snapshot.status_code, 200);
assert_eq!(invalid_body["api_version"], "v1"); assert_eq!(snapshot_body["api_version"], "v1");
assert_eq!(invalid_body["error"]["code"], "invalid_command"); assert!(snapshot_body.get("state").is_some());
assert!(snapshot_body.get("preview").is_some());
server.shutdown(); server.shutdown();
} }
#[test] #[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 server = start_server();
let mut stream = open_websocket(server.local_addr()); 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"); let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview"); assert_eq!(second_payload["message"]["type"], "preview");
let _ = send_http_request( let invalid = send_command_json(
server.local_addr(), server.local_addr(),
"POST", r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
"/api/v1/command",
Some(
r#"{
"request_id": "contract-event",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
); );
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 { for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream); 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" { 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"] assert!(payload["message"]["payload"]["message"]
.as_str() .as_str()
.expect("event message") .expect("event message")
.contains("blackout")); .contains("does_not_exist"));
break; break;
} }
} }
assert!(saw_warning, "expected warning event after failed command");
assert!(saw_event, "expected websocket event after command");
let _ = stream.shutdown(Shutdown::Both); let _ = stream.shutdown(Shutdown::Both);
server.shutdown(); 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 { fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
let body = body.unwrap_or(""); let body = body.unwrap_or("");
let request = format!( let request = format!(
@@ -293,3 +421,11 @@ fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
stream.read_exact(&mut payload).expect("frame payload"); stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8") String::from_utf8(payload).expect("frame utf8")
} }
fn unique_runtime_state_path(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis();
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
}

View File

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

View File

@@ -55,6 +55,13 @@ Every surface must talk to the same host API:
- CLI inspection - CLI inspection
- future grandMA adapter - 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. 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 ## 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 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 2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin
3. Implement transport adapters without coupling them to any single frontend 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 5. Keep hardware activation behind explicit later validation gates

View File

@@ -12,13 +12,13 @@ Suggested commands:
```powershell ```powershell
cargo test cargo test
cargo run -p infinity_host -- snapshot --config config/project.example.toml 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_ui
cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural 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 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. 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.

View File

@@ -2,58 +2,107 @@
## Purpose ## 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 creative web UI
- the existing engineering GUI - the native engineering GUI
- future external show-control adapters such as grandMA - 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 API is a control and observation layer
- the realtime engine remains the timing authority - the host core remains the timing authority
- no surface is allowed to become the LED clock - 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/control.rs`
- `crates/infinity_host/src/scene.rs` - `crates/infinity_host/src/scene.rs`
- `crates/infinity_host/src/show_store.rs`
- `crates/infinity_host/src/simulation.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/dto.rs`
- `crates/infinity_host_api/src/server.rs` - `crates/infinity_host_api/src/server.rs`
The network-facing server is started with: Server startup:
```powershell ```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 ```text
http://127.0.0.1:9001/ http://127.0.0.1:9001/
``` ```
## Versioning ## Versioning Policy
- HTTP and WebSocket routes are versioned under `/api/v1` The current public contract is `v1`.
- responses include `api_version: "v1"`
- the external DTOs are intentionally not a direct 1:1 dump of internal core structs
## 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: Example:
```json ```json
{ {
"api_version": "v1", "api_version": "v1",
"generated_at_millis": 241, "generated_at_millis": 512,
"state": { "state": {
"system": { "system": {
"project_name": "Infinity Vis", "project_name": "Infinity Vis",
@@ -63,104 +112,113 @@ Example:
"global": { "global": {
"blackout": false, "blackout": false,
"master_brightness": 0.2, "master_brightness": 0.2,
"selected_pattern": "solid_color", "selected_pattern": "gradient",
"selected_group": null, "selected_group": "top_panels",
"transition_duration_ms": 150 "transition_duration_ms": 320,
"transition_style": "chase"
}, },
"engine": { "engine": {
"logic_hz": 120, "logic_hz": 120,
"frame_hz": 60, "frame_hz": 60,
"preview_hz": 15, "preview_hz": 15,
"uptime_ms": 241, "uptime_ms": 512,
"frame_index": 14, "frame_index": 30,
"dropped_frames": 0, "dropped_frames": 0,
"active_transition": null "active_transition": {
}, "style": "chase",
"active_scene": { "from_pattern_id": "solid_color",
"preset_id": null, "to_pattern_id": "gradient",
"pattern_id": "solid_color", "duration_ms": 320,
"seed": 100, "progress": 0.28
"palette": [ }
"#ffffff"
],
"parameters": [],
"target_group": null,
"blackout": false
} }
}, }
}
```
### GET `/api/v1/preview`
Returns only the stable preview snapshot.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 512,
"preview": { "preview": {
"generated_at_millis": 241, "generated_at_millis": 512,
"panels": [ "panels": [
{ {
"node_id": "node-01", "node_id": "node-01",
"panel_position": "top", "panel_position": "top",
"representative_color_hex": "#33CCFF", "representative_color_hex": "#FF8A5B",
"sample_led_hex": [ "sample_led_hex": [
"#33CCFF", "#FF8A5B",
"#28A3CC", "#F36E43",
"#1E7A99" "#D85A2F"
], ],
"energy_percent": 28, "energy_percent": 47,
"source": "scene" "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` ### GET `/api/v1/catalog`
Returns the creative catalog: Returns the stable creative library:
- patterns - patterns
- presets - presets
- groups - 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` ### GET `/api/v1/presets`
Returns only preset summaries. 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` ### GET `/api/v1/groups`
Returns only group summaries. Returns only group summaries.
Example:
```json
{
"api_version": "v1",
"groups": [
{
"group_id": "top_panels",
"member_count": 6,
"tags": [
"row",
"top"
]
}
]
}
```
### POST `/api/v1/command` ### POST `/api/v1/command`
Accepts a versioned command envelope. Accepts one versioned command envelope.
Example request: Example request:
@@ -168,9 +226,11 @@ Example request:
{ {
"request_id": "web-1713352662000", "request_id": "web-1713352662000",
"command": { "command": {
"type": "set_master_brightness", "type": "save_creative_snapshot",
"payload": { "payload": {
"value": 0.42 "snapshot_id": "variant_floor",
"label": "Variant Floor",
"overwrite": false
} }
} }
} }
@@ -184,85 +244,44 @@ Example response:
"accepted": true, "accepted": true,
"request_id": "web-1713352662000", "request_id": "web-1713352662000",
"generated_at_millis": 522, "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 ```json
{ {
"api_version": "v1", "api_version": "v1",
"error": { "error": {
"code": "invalid_command", "code": "unknown_creative_snapshot",
"message": "command request was rejected: missing field `enabled`" "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` ### WS `/api/v1/stream`
The WebSocket stream emits envelopes with a monotonic sequence and a typed payload. The stream emits a typed envelope with a monotonic sequence counter:
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:
```json ```json
{ {
@@ -272,16 +291,29 @@ Example event envelope:
"message": { "message": {
"type": "event", "type": "event",
"payload": { "payload": {
"kind": "info", "kind": "warning",
"message": "preset recalled: ocean_gradient" "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_blackout`
- `set_master_brightness` - `set_master_brightness`
@@ -290,53 +322,76 @@ The current API command set covers:
- `select_group` - `select_group`
- `set_scene_parameter` - `set_scene_parameter`
- `set_transition_duration_ms` - `set_transition_duration_ms`
- `set_transition_style`
- `trigger_panel_test` - `trigger_panel_test`
This is intentionally enough for: Guaranteed persistence and creative-library commands:
- creative look development in the web UI - `save_preset`
- engineering test triggers in the native GUI - `save_creative_snapshot`
- future external show-control translation layers - `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 Persisted data includes:
- preset recall
- group selection
- global brightness
- blackout
- transition duration
- scene parameter controls driven from the API schema
- panel preview
- snapshot display
- event feed
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_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 - state, preview, snapshot, catalog, presets, and groups endpoints
- `GET /api/v1/snapshot` - command success and typed command failure
- `GET /api/v1/catalog` - WebSocket snapshot, preview, and event messages
- `GET /api/v1/presets` - group targeting
- `GET /api/v1/groups` - parameter updates
- `POST /api/v1/command` - transition configuration
- `WS /api/v1/stream` - 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 Mapping, topology diagnostics, panel-test administration, and low-level node status remain primarily in the native engineering GUI.
- remote operator clients
- a grandMA bridge that translates external show control into host API commands

View File

@@ -1,9 +1,8 @@
(function () { (function () {
const apiState = { const apiState = {
snapshot: null, stateResponse: null,
previewResponse: null,
catalog: null, catalog: null,
presets: [],
groups: [],
events: [], events: [],
ws: null, ws: null,
commandTimers: new Map(), commandTimers: new Map(),
@@ -18,15 +17,27 @@
patternSelect: document.getElementById("pattern-select"), patternSelect: document.getElementById("pattern-select"),
transitionSlider: document.getElementById("transition-slider"), transitionSlider: document.getElementById("transition-slider"),
transitionValue: document.getElementById("transition-value"), transitionValue: document.getElementById("transition-value"),
transitionStyleSelect: document.getElementById("transition-style-select"),
brightnessSlider: document.getElementById("brightness-slider"), brightnessSlider: document.getElementById("brightness-slider"),
brightnessValue: document.getElementById("brightness-value"), brightnessValue: document.getElementById("brightness-value"),
blackoutButton: document.getElementById("blackout-button"), blackoutButton: document.getElementById("blackout-button"),
presetList: document.getElementById("preset-list"), 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"), 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"), sceneParams: document.getElementById("scene-params"),
previewGrid: document.getElementById("preview-grid"), previewGrid: document.getElementById("preview-grid"),
summaryCards: document.getElementById("summary-cards"), summaryCards: document.getElementById("summary-cards"),
snapshotJson: document.getElementById("snapshot-json"), snapshotJson: document.getElementById("snapshot-json"),
eventKindFilter: document.getElementById("event-kind-filter"),
eventSearchFilter: document.getElementById("event-search-filter"),
eventList: document.getElementById("event-list"), eventList: document.getElementById("event-list"),
}; };
@@ -49,12 +60,19 @@
dom.transitionSlider.addEventListener("input", (event) => { dom.transitionSlider.addEventListener("input", (event) => {
const value = Number(event.target.value); const value = Number(event.target.value);
dom.transitionValue.textContent = `${value} ms`; dom.transitionValue.textContent = `${value} ms`;
debounceCommand("transition", { debounceCommand("transition-duration", {
type: "set_transition_duration_ms", type: "set_transition_duration_ms",
payload: { duration_ms: value }, payload: { duration_ms: value },
}); });
}); });
dom.transitionStyleSelect.addEventListener("change", (event) => {
sendCommand({
type: "set_transition_style",
payload: { style: event.target.value },
});
});
dom.brightnessSlider.addEventListener("input", (event) => { dom.brightnessSlider.addEventListener("input", (event) => {
const value = Number(event.target.value); const value = Number(event.target.value);
dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; dom.brightnessValue.textContent = `${Math.round(value * 100)}%`;
@@ -65,36 +83,83 @@
}); });
dom.blackoutButton.addEventListener("click", () => { dom.blackoutButton.addEventListener("click", () => {
const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false); const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false);
sendCommand({ sendCommand({
type: "set_blackout", type: "set_blackout",
payload: { enabled }, 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() { async function refreshAll() {
setConnectionState("connecting", "loading"); setConnectionState("connecting", "loading");
try { try {
const [snapshot, catalog, presets, groups] = await Promise.all([ const [stateResponse, previewResponse, catalog] = await Promise.all([
fetchJson("/api/v1/snapshot"), fetchJson("/api/v1/state"),
fetchJson("/api/v1/preview"),
fetchJson("/api/v1/catalog"), 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.catalog = catalog;
apiState.presets = presets.presets || catalog.presets || [];
apiState.groups = groups.groups || catalog.groups || [];
renderAll(); renderAll();
setConnectionState("online", "HTTP snapshot synced"); setConnectionState("online", "HTTP sync");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setConnectionState("offline", "snapshot fetch failed"); setConnectionState("offline", "snapshot fetch failed");
pushEvent({ pushEvent({
at: new Date().toLocaleTimeString(), at: new Date().toLocaleTimeString(),
kind: "error",
code: "http_refresh_failed",
message: `HTTP refresh failed: ${error.message}`, message: `HTTP refresh failed: ${error.message}`,
}); });
} }
@@ -110,6 +175,8 @@
setConnectionState("online", "stream connected"); setConnectionState("online", "stream connected");
pushEvent({ pushEvent({
at: new Date().toLocaleTimeString(), at: new Date().toLocaleTimeString(),
kind: "info",
code: "stream_connected",
message: "WebSocket stream connected", message: "WebSocket stream connected",
}); });
}); });
@@ -123,6 +190,8 @@
setConnectionState("offline", "stream disconnected"); setConnectionState("offline", "stream disconnected");
pushEvent({ pushEvent({
at: new Date().toLocaleTimeString(), at: new Date().toLocaleTimeString(),
kind: "warning",
code: "stream_reconnect",
message: "WebSocket stream closed, retrying", message: "WebSocket stream closed, retrying",
}); });
window.setTimeout(connectStream, 1500); window.setTimeout(connectStream, 1500);
@@ -139,26 +208,22 @@
return; return;
} }
if (!apiState.snapshot) { if (message.type === "snapshot") {
apiState.snapshot = { apiState.stateResponse = {
api_version: envelope.api_version, api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis, generated_at_millis: envelope.generated_at_millis,
state: null, state: message.payload,
preview: null,
}; };
}
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(); renderState();
return; return;
} }
if (message.type === "preview") { if (message.type === "preview") {
apiState.snapshot.preview = message.payload; apiState.previewResponse = {
apiState.snapshot.generated_at_millis = envelope.generated_at_millis; api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
preview: message.payload,
};
renderPreview(); renderPreview();
renderSnapshotJson(); renderSnapshotJson();
return; return;
@@ -167,6 +232,8 @@
if (message.type === "event") { if (message.type === "event") {
pushEvent({ pushEvent({
at: `${envelope.generated_at_millis} ms`, at: `${envelope.generated_at_millis} ms`,
kind: message.payload.kind || "info",
code: message.payload.code || null,
message: message.payload.message, message: message.payload.message,
}); });
} }
@@ -184,15 +251,21 @@
}); });
pushEvent({ pushEvent({
at: new Date().toLocaleTimeString(), at: new Date().toLocaleTimeString(),
kind: "info",
code: response.command_type,
message: response.summary, message: response.summary,
}); });
await refreshAll(); await refreshAll();
return response;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
pushEvent({ pushEvent({
at: new Date().toLocaleTimeString(), at: new Date().toLocaleTimeString(),
kind: "error",
code: "command_failed",
message: `Command failed: ${error.message}`, message: `Command failed: ${error.message}`,
}); });
return null;
} }
} }
@@ -232,17 +305,16 @@
} }
function renderState() { function renderState() {
if (!apiState.snapshot?.state) { const state = apiState.stateResponse?.state;
if (!state) {
return; return;
} }
const snapshot = apiState.snapshot;
const state = snapshot.state;
const global = state.global; const global = state.global;
const scene = state.active_scene; const scene = state.active_scene;
dom.projectName.textContent = state.system.project_name; 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 = ""; dom.patternSelect.innerHTML = "";
(apiState.catalog?.patterns || []).forEach((pattern) => { (apiState.catalog?.patterns || []).forEach((pattern) => {
@@ -255,22 +327,23 @@
dom.transitionSlider.value = String(global.transition_duration_ms); dom.transitionSlider.value = String(global.transition_duration_ms);
dom.transitionValue.textContent = `${global.transition_duration_ms} ms`; dom.transitionValue.textContent = `${global.transition_duration_ms} ms`;
dom.transitionStyleSelect.value = global.transition_style;
dom.brightnessSlider.value = String(global.master_brightness); dom.brightnessSlider.value = String(global.master_brightness);
dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`;
dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout";
dom.blackoutButton.classList.toggle("is-active", global.blackout); dom.blackoutButton.classList.toggle("is-active", global.blackout);
renderPresetButtons(scene, global); renderPresets(scene);
renderGroupButtons(global); renderGroups(global);
renderCreativeSnapshots();
renderSceneParameters(scene); renderSceneParameters(scene);
renderSummaryCards(state, snapshot.generated_at_millis); renderSummaryCards(state);
renderSnapshotJson(); renderSnapshotJson();
dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`;
} }
function renderPresetButtons(scene) { function renderPresets(scene) {
dom.presetList.innerHTML = ""; dom.presetList.innerHTML = "";
const presets = apiState.presets || []; const presets = apiState.catalog?.presets || [];
if (!presets.length) { if (!presets.length) {
dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>'; dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>';
return; return;
@@ -283,7 +356,7 @@
button.classList.toggle("active", scene.preset_id === preset.preset_id); button.classList.toggle("active", scene.preset_id === preset.preset_id);
button.innerHTML = ` button.innerHTML = `
<strong>${preset.preset_id}</strong> <strong>${preset.preset_id}</strong>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_duration_ms} ms</div> <div class="pill-subtext">${preset.pattern_id} / ${preset.transition_style} / ${preset.source}</div>
`; `;
button.addEventListener("click", () => button.addEventListener("click", () =>
sendCommand({ sendCommand({
@@ -295,8 +368,18 @@
}); });
} }
function renderGroupButtons(global) { function renderGroups(global) {
dom.groupList.innerHTML = ""; 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"); const allButton = document.createElement("button");
allButton.type = "button"; allButton.type = "button";
@@ -311,14 +394,22 @@
); );
dom.groupList.appendChild(allButton); 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"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "group-button"; button.className = "group-button";
button.classList.toggle("active", group.group_id === global.selected_group); button.classList.toggle("active", group.group_id === global.selected_group);
button.innerHTML = ` button.innerHTML = `
<strong>${group.group_id}</strong> <strong>${group.group_id}</strong>
<div class="pill-subtext">${group.member_count} members</div> <div class="pill-subtext">${group.member_count} members / ${group.source}</div>
`; `;
button.addEventListener("click", () => button.addEventListener("click", () =>
sendCommand({ sendCommand({
@@ -330,6 +421,43 @@
}); });
} }
function renderCreativeSnapshots() {
dom.snapshotList.innerHTML = "";
const snapshots = apiState.catalog?.creative_snapshots || [];
if (!snapshots.length) {
dom.snapshotList.innerHTML =
'<div class="empty-state">No creative snapshots saved yet.</div>';
return;
}
snapshots.forEach((snapshot) => {
const card = document.createElement("article");
card.className = "snapshot-card";
card.innerHTML = `
<div class="snapshot-card-header">
<div>
<strong>${snapshot.label || snapshot.snapshot_id}</strong>
<div class="preview-meta">${snapshot.snapshot_id}</div>
</div>
<button type="button" class="ghost-button">Recall</button>
</div>
<div class="snapshot-meta-row">
<span class="meta-chip">${snapshot.pattern_id}</span>
<span class="meta-chip">${snapshot.transition_style}</span>
<span class="meta-chip">${snapshot.transition_duration_ms} ms</span>
<span class="meta-chip">${snapshot.target_group || "all_panels"}</span>
</div>
`;
card.querySelector("button").addEventListener("click", () =>
sendCommand({
type: "recall_creative_snapshot",
payload: { snapshot_id: snapshot.snapshot_id },
})
);
dom.snapshotList.appendChild(card);
});
}
function renderSceneParameters(scene) { function renderSceneParameters(scene) {
dom.sceneParams.innerHTML = ""; dom.sceneParams.innerHTML = "";
const parameters = scene.parameters || []; const parameters = scene.parameters || [];
@@ -417,7 +545,7 @@
} }
function renderPreview() { function renderPreview() {
const preview = apiState.snapshot?.preview; const preview = apiState.previewResponse?.preview;
dom.previewGrid.innerHTML = ""; dom.previewGrid.innerHTML = "";
if (!preview?.panels?.length) { if (!preview?.panels?.length) {
dom.previewGrid.innerHTML = dom.previewGrid.innerHTML =
@@ -425,6 +553,7 @@
return; return;
} }
dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`;
const panels = [...preview.panels].sort(comparePreviewPanels); const panels = [...preview.panels].sort(comparePreviewPanels);
panels.forEach((panel) => { panels.forEach((panel) => {
const card = document.createElement("article"); const card = document.createElement("article");
@@ -439,6 +568,7 @@
<strong>${panel.energy_percent}%</strong> <strong>${panel.energy_percent}%</strong>
</div> </div>
<div class="preview-swatch"></div> <div class="preview-swatch"></div>
<div class="energy-bar"><span style="--energy-width: ${panel.energy_percent}%"></span></div>
<div class="sample-row"> <div class="sample-row">
${panel.sample_led_hex ${panel.sample_led_hex
.map( .map(
@@ -452,11 +582,12 @@
}); });
} }
function renderSummaryCards(state, generatedAtMillis) { function renderSummaryCards(state) {
const scene = state.active_scene; const scene = state.active_scene;
const global = state.global; const global = state.global;
const engine = state.engine; const engine = state.engine;
const nodeStats = summarizeNodes(state.nodes || []); const nodeStats = summarizeNodes(state.nodes || []);
const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length;
const cards = [ const cards = [
{ {
@@ -467,11 +598,11 @@
{ {
label: "Group Target", label: "Group Target",
value: scene.target_group || "all_panels", value: scene.target_group || "all_panels",
detail: `${(apiState.groups || []).length} groups available`, detail: `${(apiState.catalog?.groups || []).length} groups available`,
}, },
{ {
label: "Transition", label: "Transition",
value: `${global.transition_duration_ms} ms`, value: `${global.transition_style} / ${global.transition_duration_ms} ms`,
detail: engine.active_transition detail: engine.active_transition
? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%` ? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%`
: "idle", : "idle",
@@ -492,9 +623,9 @@
detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`,
}, },
{ {
label: "Preview Timestamp", label: "Creative Snapshots",
value: `${generatedAtMillis} ms`, value: `${creativeSnapshotCount}`,
detail: `${state.system.schema_version} schema`, detail: `${(apiState.catalog?.presets || []).length} presets in library`,
}, },
]; ];
@@ -512,28 +643,55 @@
} }
function renderSnapshotJson() { function renderSnapshotJson() {
dom.snapshotJson.textContent = apiState.snapshot dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2);
? JSON.stringify(apiState.snapshot, null, 2) }
: "No snapshot loaded.";
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) { function pushEvent(entry) {
apiState.events.unshift(entry); apiState.events.unshift({
apiState.events = apiState.events.slice(0, 12); kind: entry.kind || "info",
code: entry.code || null,
...entry,
});
apiState.events = apiState.events.slice(0, 50);
renderEvents(); renderEvents();
} }
function renderEvents() { function renderEvents() {
if (!apiState.events.length) { const kindFilter = dom.eventKindFilter.value;
dom.eventList.innerHTML = '<div class="empty-state">No websocket notices yet.</div>'; 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 = '<div class="empty-state">No events match the current filter.</div>';
return; return;
} }
dom.eventList.innerHTML = apiState.events dom.eventList.innerHTML = filtered
.map( .map(
(entry) => ` (entry) => `
<article class="event-item"> <article class="event-item event-${entry.kind}">
<div class="event-meta">${entry.at}</div> <div class="event-meta">${entry.at}</div>
${entry.code ? `<span class="event-code">${entry.code}</span>` : ""}
<strong>${entry.message}</strong> <strong>${entry.message}</strong>
</article> </article>
` `

View File

@@ -45,11 +45,20 @@
</label> </label>
<label class="field"> <label class="field">
<span>Transition</span> <span>Transition Duration</span>
<input id="transition-slider" type="range" min="0" max="3000" step="10" /> <input id="transition-slider" type="range" min="0" max="3000" step="10" />
<strong id="transition-value">0 ms</strong> <strong id="transition-value">0 ms</strong>
</label> </label>
<label class="field">
<span>Transition Style</span>
<select id="transition-style-select">
<option value="snap">Snap</option>
<option value="crossfade">Crossfade</option>
<option value="chase">Chase</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Master Brightness</span> <span>Master Brightness</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" /> <input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
@@ -72,14 +81,65 @@
<div id="preset-list" class="pill-row"></div> <div id="preset-list" class="pill-row"></div>
</div> </div>
<div class="subsection">
<div class="subsection-heading">
<h3>Preset Capture</h3>
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Preset ID</span>
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="preset-overwrite-input" type="checkbox" />
</label>
<button id="save-preset-button" class="ghost-button" type="button">
Save Current Scene As Preset
</button>
</div>
</div>
<div class="subsection"> <div class="subsection">
<div class="subsection-heading"> <div class="subsection-heading">
<h3>Groups</h3> <h3>Groups</h3>
<p>Focus looks on a subset while keeping the core scene model shared.</p> <p>Focus looks on a subset while keeping the core scene model shared.</p>
</div> </div>
<input
id="group-filter-input"
class="filter-input"
type="text"
placeholder="Filter groups by id or tag"
/>
<div id="group-list" class="pill-row"></div> <div id="group-list" class="pill-row"></div>
</div> </div>
<div class="subsection">
<div class="subsection-heading">
<h3>Creative Snapshots</h3>
<p>Capture exploratory variants without replacing curated presets.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Snapshot ID</span>
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
</label>
<label class="field">
<span>Label</span>
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="snapshot-overwrite-input" type="checkbox" />
</label>
<button id="save-snapshot-button" class="ghost-button" type="button">
Save Creative Snapshot
</button>
</div>
<div id="snapshot-list" class="snapshot-list"></div>
</div>
<div class="subsection"> <div class="subsection">
<div class="subsection-heading"> <div class="subsection-heading">
<h3>Scene Parameters</h3> <h3>Scene Parameters</h3>
@@ -111,6 +171,20 @@
<h2>Event Stream</h2> <h2>Event Stream</h2>
<p>Recent notices from the websocket feed.</p> <p>Recent notices from the websocket feed.</p>
</div> </div>
<div class="event-filter-bar">
<select id="event-kind-filter">
<option value="all">All kinds</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input
id="event-search-filter"
class="filter-input"
type="text"
placeholder="Filter by code or message"
/>
</div>
<div id="event-list" class="event-list"></div> <div id="event-list" class="event-list"></div>
</section> </section>
</main> </main>

View File

@@ -202,6 +202,7 @@ body::after {
} }
.control-grid, .control-grid,
.capture-grid,
.parameter-grid, .parameter-grid,
.summary-cards { .summary-cards {
display: grid; display: grid;
@@ -212,6 +213,11 @@ body::after {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.capture-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.parameter-grid, .parameter-grid,
.summary-cards { .summary-cards {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -295,11 +301,26 @@ input[type="text"] {
color: var(--text); color: var(--text);
} }
.filter-input {
margin-bottom: 12px;
}
input[type="range"] { input[type="range"] {
width: 100%; width: 100%;
accent-color: var(--accent); 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, .ghost-button,
.preset-button, .preset-button,
.group-button { .group-button {
@@ -374,6 +395,21 @@ input[type="range"] {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); 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 { .sample-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -429,11 +465,84 @@ input[type="range"] {
gap: 12px; gap: 12px;
} }
.event-filter-bar {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.event-item { .event-item {
display: grid; display: grid;
gap: 8px; 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 { .event-item strong {
color: var(--accent-strong); color: var(--accent-strong);
} }
@@ -460,7 +569,8 @@ input[type="range"] {
@media (max-width: 1080px) { @media (max-width: 1080px) {
.layout, .layout,
.hero, .hero,
.control-grid { .control-grid,
.event-filter-bar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }