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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1706,6 +1706,7 @@ dependencies = [
|
|||||||
"infinity_protocol",
|
"infinity_protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
124
crates/infinity_host/src/external_control.rs
Normal file
124
crates/infinity_host/src/external_control.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
605
crates/infinity_host/src/show_store.rs
Normal file
605
crates/infinity_host/src/show_store.rs
Normal 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(), ®istry);
|
||||||
|
let catalog = store.catalog(®istry);
|
||||||
|
assert!(catalog
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset.preset_id == "ocean_gradient"));
|
||||||
|
assert!(catalog.groups.iter().any(|group| group.group_id == "top_panels"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_presets_and_snapshots_can_be_saved() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let scene = registry.scene_for_pattern(
|
||||||
|
"gradient",
|
||||||
|
None,
|
||||||
|
Some("top_panels".to_string()),
|
||||||
|
77,
|
||||||
|
vec!["#112233".to_string(), "#445566".to_string()],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.save_preset_from_scene(
|
||||||
|
"user_gradient",
|
||||||
|
&scene,
|
||||||
|
420,
|
||||||
|
SceneTransitionStyle::Crossfade,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("preset save should succeed");
|
||||||
|
store
|
||||||
|
.save_creative_snapshot(
|
||||||
|
"variant_a",
|
||||||
|
Some("Variant A".to_string()),
|
||||||
|
&scene,
|
||||||
|
240,
|
||||||
|
SceneTransitionStyle::Chase,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("snapshot save should succeed");
|
||||||
|
|
||||||
|
assert!(store.scene_from_preset_id("user_gradient").is_some());
|
||||||
|
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let scene = registry.scene_for_pattern(
|
||||||
|
"noise",
|
||||||
|
None,
|
||||||
|
Some("bottom_panels".to_string()),
|
||||||
|
99,
|
||||||
|
vec!["#AA8844".to_string()],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_preset_from_scene(
|
||||||
|
"roundtrip_noise",
|
||||||
|
&scene,
|
||||||
|
220,
|
||||||
|
SceneTransitionStyle::Chase,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("preset save should succeed");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"infinity_vis_show_store_{}.json",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis()
|
||||||
|
));
|
||||||
|
let storage = RuntimeStateStorage::new(&path);
|
||||||
|
let runtime = store.persisted_runtime(
|
||||||
|
&scene,
|
||||||
|
PersistedGlobalState {
|
||||||
|
blackout: false,
|
||||||
|
master_brightness: 0.42,
|
||||||
|
transition_duration_ms: 220,
|
||||||
|
transition_style: SceneTransitionStyle::Chase,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
storage.save(&runtime).expect("save should work");
|
||||||
|
let loaded = storage.load().expect("load should work");
|
||||||
|
|
||||||
|
assert_eq!(loaded.active_scene, Some(scene));
|
||||||
|
assert!(loaded
|
||||||
|
.user_presets
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset.preset_id == "roundtrip_noise"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ®istry);
|
||||||
|
let persisted_runtime = if let Some(storage) = &runtime_storage {
|
||||||
|
storage.load()?
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
};
|
||||||
|
let restored_scene = persisted_runtime.active_scene.clone();
|
||||||
|
let restored_global = persisted_runtime.global.clone();
|
||||||
|
show_store.apply_persisted(persisted_runtime);
|
||||||
|
let group_members = show_store.group_members_map();
|
||||||
let schedule = TickSchedule::default();
|
let schedule = TickSchedule::default();
|
||||||
let current_scene = registry.initial_scene(&project);
|
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry));
|
||||||
let catalog = registry.catalog(&project);
|
let catalog = show_store.catalog(®istry);
|
||||||
let available_patterns = catalog
|
let available_patterns = show_store.available_patterns(®istry);
|
||||||
.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)
|
self.push_event(
|
||||||
.map(|preset| preset.transition_ms)
|
StatusEventKind::Warning,
|
||||||
.unwrap_or(self.snapshot.global.transition_duration_ms);
|
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_duration_ms = duration_ms;
|
||||||
|
self.snapshot.global.transition_style = style;
|
||||||
|
should_persist = true;
|
||||||
self.start_scene_transition(
|
self.start_scene_transition(
|
||||||
scene,
|
scene,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
self.registry
|
style,
|
||||||
.transition_style_for_preset(&self.project, &preset_id),
|
|
||||||
format!("preset recalled: {preset_id}"),
|
format!("preset recalled: {preset_id}"),
|
||||||
);
|
);
|
||||||
} else {
|
format!("preset recalled: {preset_id}")
|
||||||
self.push_event(format!("ignored unknown preset request: {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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
513
docs/host_api.md
513
docs/host_api.md
@@ -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,186 +112,42 @@ 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": [
|
|
||||||
{
|
|
||||||
"node_id": "node-01",
|
|
||||||
"panel_position": "top",
|
|
||||||
"representative_color_hex": "#33CCFF",
|
|
||||||
"sample_led_hex": [
|
|
||||||
"#33CCFF",
|
|
||||||
"#28A3CC",
|
|
||||||
"#1E7A99"
|
|
||||||
],
|
|
||||||
"energy_percent": 28,
|
|
||||||
"source": "scene"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/v1/catalog`
|
|
||||||
|
|
||||||
Returns the creative catalog:
|
|
||||||
|
|
||||||
- patterns
|
|
||||||
- presets
|
|
||||||
- groups
|
|
||||||
|
|
||||||
### GET `/api/v1/presets`
|
|
||||||
|
|
||||||
Returns only preset summaries.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"presets": [
|
|
||||||
{
|
|
||||||
"preset_id": "ocean_gradient",
|
|
||||||
"pattern_id": "gradient",
|
|
||||||
"target_group": null,
|
|
||||||
"transition_duration_ms": 320
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/v1/groups`
|
|
||||||
|
|
||||||
Returns only group summaries.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"group_id": "top_panels",
|
|
||||||
"member_count": 6,
|
|
||||||
"tags": [
|
|
||||||
"row",
|
|
||||||
"top"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST `/api/v1/command`
|
|
||||||
|
|
||||||
Accepts a versioned command envelope.
|
|
||||||
|
|
||||||
Example request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"request_id": "web-1713352662000",
|
|
||||||
"command": {
|
|
||||||
"type": "set_master_brightness",
|
|
||||||
"payload": {
|
|
||||||
"value": 0.42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"accepted": true,
|
|
||||||
"request_id": "web-1713352662000",
|
|
||||||
"generated_at_millis": 522,
|
|
||||||
"summary": "master brightness set to 42%"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors use a stable error object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"error": {
|
|
||||||
"code": "invalid_command",
|
|
||||||
"message": "command request was rejected: missing field `enabled`"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### WS `/api/v1/stream`
|
|
||||||
|
|
||||||
The WebSocket stream emits envelopes with a monotonic sequence and a typed payload.
|
|
||||||
|
|
||||||
Stream message types:
|
|
||||||
|
|
||||||
- `snapshot`
|
|
||||||
- `preview`
|
|
||||||
- `event`
|
|
||||||
|
|
||||||
Example snapshot envelope:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"sequence": 17,
|
|
||||||
"generated_at_millis": 875,
|
|
||||||
"message": {
|
|
||||||
"type": "snapshot",
|
|
||||||
"payload": {
|
|
||||||
"global": {
|
|
||||||
"blackout": false,
|
|
||||||
"master_brightness": 0.35,
|
|
||||||
"selected_pattern": "gradient",
|
|
||||||
"selected_group": "top_panels",
|
|
||||||
"transition_duration_ms": 320
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example preview envelope:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_version": "v1",
|
|
||||||
"sequence": 18,
|
|
||||||
"generated_at_millis": 875,
|
|
||||||
"message": {
|
|
||||||
"type": "preview",
|
|
||||||
"payload": {
|
|
||||||
"generated_at_millis": 875,
|
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
"node_id": "node-01",
|
"node_id": "node-01",
|
||||||
@@ -258,11 +163,125 @@ Example preview envelope:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/v1/snapshot`
|
||||||
|
|
||||||
|
Returns the convenience composition of `state` plus `preview`.
|
||||||
|
|
||||||
|
This route exists for lightweight clients and debugging. Consumers that want strict separation should prefer `GET /api/v1/state` and `GET /api/v1/preview`.
|
||||||
|
|
||||||
|
### GET `/api/v1/catalog`
|
||||||
|
|
||||||
|
Returns the stable creative library:
|
||||||
|
|
||||||
|
- patterns
|
||||||
|
- presets
|
||||||
|
- groups
|
||||||
|
- creative snapshots
|
||||||
|
|
||||||
|
Example preset summary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset_id": "ocean_gradient",
|
||||||
|
"pattern_id": "gradient",
|
||||||
|
"target_group": null,
|
||||||
|
"transition_duration_ms": 320,
|
||||||
|
"transition_style": "crossfade",
|
||||||
|
"source": "built_in"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example creative snapshot summary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"snapshot_id": "variant_floor",
|
||||||
|
"label": "Variant Floor",
|
||||||
|
"pattern_id": "noise",
|
||||||
|
"target_group": "bottom_panels",
|
||||||
|
"transition_duration_ms": 220,
|
||||||
|
"transition_style": "chase",
|
||||||
|
"saved_at_unix_ms": 1760000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/v1/presets`
|
||||||
|
|
||||||
|
Returns only preset summaries.
|
||||||
|
|
||||||
|
### GET `/api/v1/groups`
|
||||||
|
|
||||||
|
Returns only group summaries.
|
||||||
|
|
||||||
|
### POST `/api/v1/command`
|
||||||
|
|
||||||
|
Accepts one versioned command envelope.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "web-1713352662000",
|
||||||
|
"command": {
|
||||||
|
"type": "save_creative_snapshot",
|
||||||
|
"payload": {
|
||||||
|
"snapshot_id": "variant_floor",
|
||||||
|
"label": "Variant Floor",
|
||||||
|
"overwrite": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example event envelope:
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"accepted": true,
|
||||||
|
"request_id": "web-1713352662000",
|
||||||
|
"generated_at_millis": 522,
|
||||||
|
"command_type": "save_creative_snapshot",
|
||||||
|
"summary": "creative snapshot saved: variant_floor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stable Error Object
|
||||||
|
|
||||||
|
All API failures return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"error": {
|
||||||
|
"code": "unknown_creative_snapshot",
|
||||||
|
"message": "creative snapshot 'does_not_exist' does not exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stable `v1` error families currently include:
|
||||||
|
|
||||||
|
- `invalid_request_json`
|
||||||
|
- `invalid_command`
|
||||||
|
- `unknown_group`
|
||||||
|
- `unknown_preset`
|
||||||
|
- `unknown_creative_snapshot`
|
||||||
|
- `preset_exists`
|
||||||
|
- `snapshot_exists`
|
||||||
|
- `group_exists`
|
||||||
|
- `persist_failed`
|
||||||
|
- `missing_websocket_key`
|
||||||
|
- `not_found`
|
||||||
|
|
||||||
|
## WebSocket Event Stream
|
||||||
|
|
||||||
|
### WS `/api/v1/stream`
|
||||||
|
|
||||||
|
The stream emits a typed envelope with a monotonic sequence counter:
|
||||||
|
|
||||||
```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
|
|
||||||
|
|||||||
268
web/v1/app.js
268
web/v1/app.js
@@ -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>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user