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:
@@ -9,5 +9,6 @@ authors.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
infinity_config = { path = "../infinity_config" }
|
||||
infinity_protocol = { path = "../infinity_protocol" }
|
||||
|
||||
@@ -34,6 +34,7 @@ pub struct GlobalControlSnapshot {
|
||||
pub selected_pattern: String,
|
||||
pub selected_group: Option<String>,
|
||||
pub transition_duration_ms: u32,
|
||||
pub transition_style: SceneTransitionStyle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -52,6 +53,7 @@ pub struct CatalogSnapshot {
|
||||
pub patterns: Vec<PatternDefinition>,
|
||||
pub presets: Vec<PresetSummary>,
|
||||
pub groups: Vec<GroupSummary>,
|
||||
pub creative_snapshots: Vec<CreativeSnapshotSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -62,12 +64,21 @@ pub struct PatternDefinition {
|
||||
pub parameters: Vec<SceneParameterSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CatalogSource {
|
||||
BuiltIn,
|
||||
RuntimeUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PresetSummary {
|
||||
pub preset_id: String,
|
||||
pub pattern_id: String,
|
||||
pub target_group: Option<String>,
|
||||
pub transition_duration_ms: u32,
|
||||
pub transition_style: SceneTransitionStyle,
|
||||
pub source: CatalogSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -75,6 +86,18 @@ pub struct GroupSummary {
|
||||
pub group_id: String,
|
||||
pub member_count: usize,
|
||||
pub tags: Vec<String>,
|
||||
pub source: CatalogSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreativeSnapshotSummary {
|
||||
pub snapshot_id: String,
|
||||
pub label: Option<String>,
|
||||
pub pattern_id: String,
|
||||
pub target_group: Option<String>,
|
||||
pub transition_duration_ms: u32,
|
||||
pub transition_style: SceneTransitionStyle,
|
||||
pub saved_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -195,9 +218,19 @@ pub struct PanelSnapshot {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StatusEvent {
|
||||
pub at_millis: u64,
|
||||
pub kind: StatusEventKind,
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusEventKind {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeConnectionState {
|
||||
@@ -229,10 +262,29 @@ pub enum HostCommand {
|
||||
value: SceneParameterValue,
|
||||
},
|
||||
SetTransitionDurationMs(u32),
|
||||
SetTransitionStyle(SceneTransitionStyle),
|
||||
TriggerPanelTest {
|
||||
target: PanelTarget,
|
||||
pattern: TestPatternKind,
|
||||
},
|
||||
SavePreset {
|
||||
preset_id: String,
|
||||
overwrite: bool,
|
||||
},
|
||||
SaveCreativeSnapshot {
|
||||
snapshot_id: String,
|
||||
label: Option<String>,
|
||||
overwrite: bool,
|
||||
},
|
||||
RecallCreativeSnapshot {
|
||||
snapshot_id: String,
|
||||
},
|
||||
UpsertGroup {
|
||||
group_id: String,
|
||||
tags: Vec<String>,
|
||||
members: Vec<PanelTarget>,
|
||||
overwrite: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -241,9 +293,21 @@ pub enum TestPatternKind {
|
||||
WalkingPixel106,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CommandOutcome {
|
||||
pub generated_at_millis: u64,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HostCommandError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub trait HostApiPort: Send + Sync {
|
||||
fn snapshot(&self) -> HostSnapshot;
|
||||
fn send_command(&self, command: HostCommand);
|
||||
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError>;
|
||||
}
|
||||
|
||||
pub trait HostUiPort: HostApiPort {}
|
||||
@@ -260,6 +324,35 @@ impl NodeConnectionState {
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneTransitionStyle {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Snap => "snap",
|
||||
Self::Crossfade => "crossfade",
|
||||
Self::Chase => "chase",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CatalogSource {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::BuiltIn => "built_in",
|
||||
Self::RuntimeUser => "runtime_user",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusEventKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TestPatternKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -287,3 +380,12 @@ impl SceneParameterValue {
|
||||
Self::Text(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl HostCommandError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 runtime;
|
||||
pub mod scene;
|
||||
pub mod show_store;
|
||||
pub mod simulation;
|
||||
pub mod external_control;
|
||||
|
||||
pub use control::*;
|
||||
pub use external_control::*;
|
||||
pub use runtime::*;
|
||||
pub use scene::*;
|
||||
pub use show_store::*;
|
||||
pub use simulation::*;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::control::{
|
||||
ActiveSceneSnapshot, CatalogSnapshot, GroupSummary, PatternDefinition, PresetSummary,
|
||||
SceneParameterKind, SceneParameterSpec, SceneParameterState, SceneParameterValue,
|
||||
SceneTransitionStyle, TransitionSnapshot,
|
||||
ActiveSceneSnapshot, CatalogSnapshot, CatalogSource, GroupSummary, PatternDefinition,
|
||||
PresetSummary, SceneParameterKind, SceneParameterSpec, SceneParameterState,
|
||||
SceneParameterValue, SceneTransitionStyle, TransitionSnapshot,
|
||||
};
|
||||
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
hash::{Hash, Hasher},
|
||||
@@ -12,7 +13,7 @@ use std::{
|
||||
|
||||
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SceneRuntime {
|
||||
pub preset_id: Option<String>,
|
||||
pub pattern_id: String,
|
||||
@@ -70,6 +71,8 @@ impl PatternRegistry {
|
||||
pattern_id: preset.scene.effect.clone(),
|
||||
target_group: preset.target_group.clone(),
|
||||
transition_duration_ms: preset.transition_ms,
|
||||
transition_style: transition_style_from_duration(preset.transition_ms),
|
||||
source: CatalogSource::BuiltIn,
|
||||
})
|
||||
.collect(),
|
||||
groups: project
|
||||
@@ -80,8 +83,10 @@ impl PatternRegistry {
|
||||
group_id: group.group_id.clone(),
|
||||
member_count: group.members.len(),
|
||||
tags: group.tags.clone(),
|
||||
source: CatalogSource::BuiltIn,
|
||||
})
|
||||
.collect(),
|
||||
creative_snapshots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +107,10 @@ impl PatternRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pattern_definitions(&self) -> Vec<PatternDefinition> {
|
||||
self.definitions.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn scene_from_preset_id(&self, project: &ProjectConfig, preset_id: &str) -> Option<SceneRuntime> {
|
||||
project
|
||||
.presets
|
||||
@@ -246,7 +255,7 @@ impl PatternRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
|
||||
pub fn scene_from_preset_config(&self, preset: &PresetConfig) -> SceneRuntime {
|
||||
let mut scene = self.scene_for_pattern(
|
||||
&preset.scene.effect,
|
||||
Some(preset.preset_id.clone()),
|
||||
@@ -264,6 +273,10 @@ impl PatternRegistry {
|
||||
scene
|
||||
}
|
||||
|
||||
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
|
||||
self.scene_from_preset_config(preset)
|
||||
}
|
||||
|
||||
fn definition_or_default(&self, pattern_id: &str) -> &PatternDefinition {
|
||||
self.definitions
|
||||
.get(pattern_id)
|
||||
|
||||
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::{
|
||||
control::{
|
||||
EngineSnapshot, GlobalControlSnapshot, HostApiPort, HostCommand, HostSnapshot,
|
||||
HOST_API_VERSION, NodeConnectionState, NodeSnapshot, PanelSnapshot, PanelTarget,
|
||||
PreviewPanelSnapshot, PreviewSource, StatusEvent, SystemSnapshot,
|
||||
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
|
||||
HostCommand, HostCommandError, HostSnapshot, HOST_API_VERSION, NodeConnectionState,
|
||||
NodeSnapshot, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
|
||||
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot,
|
||||
},
|
||||
runtime::TickSchedule,
|
||||
scene::{
|
||||
apply_group_gate, blackout_preview, blend_previews, build_group_members,
|
||||
panel_membership_key, panel_test_preview, transition_style_from_duration, PatternRegistry,
|
||||
RenderedPreview, SceneRuntime, TransitionRuntime,
|
||||
apply_group_gate, blackout_preview, blend_previews, panel_membership_key,
|
||||
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
|
||||
},
|
||||
show_store::{
|
||||
PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError,
|
||||
},
|
||||
};
|
||||
use infinity_config::{PanelPosition, ProjectConfig};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@@ -29,9 +33,10 @@ pub struct SimulationHostService {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SimulationState {
|
||||
project: ProjectConfig,
|
||||
registry: PatternRegistry,
|
||||
group_members: BTreeMap<String, std::collections::BTreeSet<String>>,
|
||||
show_store: ShowStore,
|
||||
runtime_storage: Option<RuntimeStateStorage>,
|
||||
started_at: Instant,
|
||||
next_seed: u64,
|
||||
tick_count: u64,
|
||||
@@ -45,9 +50,7 @@ struct SimulationState {
|
||||
|
||||
impl SimulationHostService {
|
||||
pub fn new(project: ProjectConfig) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(SimulationState::new(project))),
|
||||
}
|
||||
Self::try_new(project).expect("memory-only simulation host service must initialize")
|
||||
}
|
||||
|
||||
pub fn spawn_shared(project: ProjectConfig) -> Arc<Self> {
|
||||
@@ -56,6 +59,33 @@ impl SimulationHostService {
|
||||
service
|
||||
}
|
||||
|
||||
pub fn try_new(project: ProjectConfig) -> Result<Self, ShowStoreError> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(SimulationState::try_new(project, None)?)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_new_with_persistence(
|
||||
project: ProjectConfig,
|
||||
runtime_state_path: impl Into<PathBuf>,
|
||||
) -> Result<Self, ShowStoreError> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(SimulationState::try_new(
|
||||
project,
|
||||
Some(RuntimeStateStorage::new(runtime_state_path)),
|
||||
)?)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_spawn_shared_with_persistence(
|
||||
project: ProjectConfig,
|
||||
runtime_state_path: impl Into<PathBuf>,
|
||||
) -> Result<Arc<Self>, ShowStoreError> {
|
||||
let service = Arc::new(Self::try_new_with_persistence(project, runtime_state_path)?);
|
||||
Self::spawn_simulation_loop(Arc::clone(&service));
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
fn spawn_simulation_loop(service: Arc<Self>) {
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
@@ -74,25 +104,38 @@ impl HostApiPort for SimulationHostService {
|
||||
.unwrap_or_else(|_| unavailable_snapshot())
|
||||
}
|
||||
|
||||
fn send_command(&self, command: HostCommand) {
|
||||
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
|
||||
if let Ok(mut state) = self.inner.lock() {
|
||||
state.apply_command(command);
|
||||
state.apply_command(command)
|
||||
} else {
|
||||
Err(HostCommandError::new(
|
||||
"service_unavailable",
|
||||
"simulation state lock was unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimulationState {
|
||||
fn new(project: ProjectConfig) -> Self {
|
||||
fn try_new(
|
||||
project: ProjectConfig,
|
||||
runtime_storage: Option<RuntimeStateStorage>,
|
||||
) -> Result<Self, ShowStoreError> {
|
||||
let registry = PatternRegistry::new();
|
||||
let group_members = build_group_members(&project);
|
||||
let mut show_store = ShowStore::from_project(&project, ®istry);
|
||||
let persisted_runtime = if let Some(storage) = &runtime_storage {
|
||||
storage.load()?
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let restored_scene = persisted_runtime.active_scene.clone();
|
||||
let restored_global = persisted_runtime.global.clone();
|
||||
show_store.apply_persisted(persisted_runtime);
|
||||
let group_members = show_store.group_members_map();
|
||||
let schedule = TickSchedule::default();
|
||||
let current_scene = registry.initial_scene(&project);
|
||||
let catalog = registry.catalog(&project);
|
||||
let available_patterns = catalog
|
||||
.patterns
|
||||
.iter()
|
||||
.map(|pattern| pattern.pattern_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry));
|
||||
let catalog = show_store.catalog(®istry);
|
||||
let available_patterns = show_store.available_patterns(®istry);
|
||||
let nodes = project
|
||||
.topology
|
||||
.nodes
|
||||
@@ -131,10 +174,12 @@ impl SimulationState {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let selected_pattern = current_scene.pattern_id.clone();
|
||||
let mut state = Self {
|
||||
project: project.clone(),
|
||||
registry,
|
||||
group_members,
|
||||
show_store,
|
||||
runtime_storage,
|
||||
started_at: Instant::now(),
|
||||
next_seed: 100,
|
||||
tick_count: 0,
|
||||
@@ -153,11 +198,12 @@ impl SimulationState {
|
||||
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
|
||||
},
|
||||
global: GlobalControlSnapshot {
|
||||
blackout: false,
|
||||
master_brightness: 0.20,
|
||||
selected_pattern: "solid_color".to_string(),
|
||||
blackout: restored_global.blackout,
|
||||
master_brightness: restored_global.master_brightness,
|
||||
selected_pattern,
|
||||
selected_group: None,
|
||||
transition_duration_ms: 150,
|
||||
transition_duration_ms: restored_global.transition_duration_ms,
|
||||
transition_style: restored_global.transition_style,
|
||||
},
|
||||
engine: EngineSnapshot {
|
||||
logic_hz: schedule.logic_hz,
|
||||
@@ -188,9 +234,16 @@ impl SimulationState {
|
||||
state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone();
|
||||
state.snapshot.global.selected_group = state.current_scene.target_group.clone();
|
||||
state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene);
|
||||
state.push_event("simulation host service started".to_string());
|
||||
state.push_event(StatusEventKind::Info, None, "simulation host service started".to_string());
|
||||
if state.runtime_storage.is_some() {
|
||||
state.push_event(
|
||||
StatusEventKind::Info,
|
||||
Some("runtime_state_restored".to_string()),
|
||||
"runtime state persistence enabled".to_string(),
|
||||
);
|
||||
}
|
||||
state.simulate_tick();
|
||||
state
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn simulate_tick(&mut self) {
|
||||
@@ -218,22 +271,29 @@ impl SimulationState {
|
||||
self.snapshot.preview.panels = self.render_preview_panels(elapsed_ms);
|
||||
}
|
||||
|
||||
fn apply_command(&mut self, command: HostCommand) {
|
||||
match command {
|
||||
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
|
||||
let mut should_persist = false;
|
||||
let summary = match command {
|
||||
HostCommand::SetBlackout(enabled) => {
|
||||
self.snapshot.global.blackout = enabled;
|
||||
self.push_event(if enabled {
|
||||
should_persist = true;
|
||||
let summary = if enabled {
|
||||
"global blackout enabled".to_string()
|
||||
} else {
|
||||
"global blackout released".to_string()
|
||||
});
|
||||
};
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetMasterBrightness(value) => {
|
||||
self.snapshot.global.master_brightness = value.clamp(0.0, 1.0);
|
||||
self.push_event(format!(
|
||||
should_persist = true;
|
||||
let summary = format!(
|
||||
"master brightness set to {:.0}%",
|
||||
self.snapshot.global.master_brightness * 100.0
|
||||
));
|
||||
);
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SelectPattern(pattern_id) => {
|
||||
let mut new_scene = self.registry.scene_for_pattern(
|
||||
@@ -255,55 +315,95 @@ impl SimulationState {
|
||||
}
|
||||
|
||||
let duration_ms = self.snapshot.global.transition_duration_ms;
|
||||
let style = self.snapshot.global.transition_style;
|
||||
should_persist = true;
|
||||
self.start_scene_transition(
|
||||
new_scene,
|
||||
duration_ms,
|
||||
transition_style_from_duration(duration_ms),
|
||||
style,
|
||||
format!("pattern selected: {pattern_id}"),
|
||||
);
|
||||
format!("pattern selected: {pattern_id}")
|
||||
}
|
||||
HostCommand::RecallPreset { preset_id } => {
|
||||
if let Some(scene) = self.registry.scene_from_preset_id(&self.project, &preset_id) {
|
||||
let duration_ms = self
|
||||
.project
|
||||
.presets
|
||||
.iter()
|
||||
.find(|preset| preset.preset_id == preset_id)
|
||||
.map(|preset| preset.transition_ms)
|
||||
.unwrap_or(self.snapshot.global.transition_duration_ms);
|
||||
self.snapshot.global.transition_duration_ms = duration_ms;
|
||||
self.start_scene_transition(
|
||||
scene,
|
||||
duration_ms,
|
||||
self.registry
|
||||
.transition_style_for_preset(&self.project, &preset_id),
|
||||
format!("preset recalled: {preset_id}"),
|
||||
let Some(scene) = self.show_store.scene_from_preset_id(&preset_id) else {
|
||||
let error = HostCommandError::new(
|
||||
"unknown_preset",
|
||||
format!("preset '{preset_id}' does not exist"),
|
||||
);
|
||||
} else {
|
||||
self.push_event(format!("ignored unknown preset request: {preset_id}"));
|
||||
}
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
};
|
||||
let (duration_ms, style) = self
|
||||
.show_store
|
||||
.transition_for_preset(&preset_id)
|
||||
.unwrap_or((
|
||||
self.snapshot.global.transition_duration_ms,
|
||||
self.snapshot.global.transition_style,
|
||||
));
|
||||
self.snapshot.global.transition_duration_ms = duration_ms;
|
||||
self.snapshot.global.transition_style = style;
|
||||
should_persist = true;
|
||||
self.start_scene_transition(
|
||||
scene,
|
||||
duration_ms,
|
||||
style,
|
||||
format!("preset recalled: {preset_id}"),
|
||||
);
|
||||
format!("preset recalled: {preset_id}")
|
||||
}
|
||||
HostCommand::SelectGroup { group_id } => {
|
||||
if let Some(group_id_ref) = &group_id {
|
||||
if !self.show_store.has_group(group_id_ref) {
|
||||
let error = HostCommandError::new(
|
||||
"unknown_group",
|
||||
format!("group '{group_id_ref}' does not exist"),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
self.current_scene.target_group = group_id.clone();
|
||||
self.snapshot.global.selected_group = group_id.clone();
|
||||
self.current_scene.preset_id = None;
|
||||
self.push_event(format!(
|
||||
should_persist = true;
|
||||
let summary = format!(
|
||||
"target group set to {}",
|
||||
group_id.as_deref().unwrap_or("all_panels")
|
||||
));
|
||||
);
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetSceneParameter { key, value } => {
|
||||
self.registry
|
||||
.set_scene_parameter(&mut self.current_scene, &key, value.clone());
|
||||
self.current_scene.preset_id = None;
|
||||
self.push_event(format!("scene parameter updated: {key} = {value:?}"));
|
||||
should_persist = true;
|
||||
let summary = format!("scene parameter updated: {key} = {value:?}");
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetTransitionDurationMs(duration_ms) => {
|
||||
self.snapshot.global.transition_duration_ms = duration_ms;
|
||||
self.push_event(format!(
|
||||
"default transition duration set to {} ms",
|
||||
duration_ms
|
||||
));
|
||||
should_persist = true;
|
||||
let summary = format!("default transition duration set to {} ms", duration_ms);
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetTransitionStyle(style) => {
|
||||
self.snapshot.global.transition_style = style;
|
||||
should_persist = true;
|
||||
let summary = format!("default transition style set to {}", style.label());
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::TriggerPanelTest { target, pattern } => {
|
||||
let now = self.elapsed_millis();
|
||||
@@ -329,18 +429,114 @@ impl SimulationState {
|
||||
);
|
||||
}
|
||||
|
||||
self.push_event(message);
|
||||
self.push_event(StatusEventKind::Info, None, message.clone());
|
||||
message
|
||||
}
|
||||
}
|
||||
HostCommand::SavePreset {
|
||||
preset_id,
|
||||
overwrite,
|
||||
} => {
|
||||
self.show_store.save_preset_from_scene(
|
||||
&preset_id,
|
||||
&self.current_scene,
|
||||
self.snapshot.global.transition_duration_ms,
|
||||
self.snapshot.global.transition_style,
|
||||
overwrite,
|
||||
)?;
|
||||
self.rebuild_catalog();
|
||||
self.group_members = self.show_store.group_members_map();
|
||||
should_persist = true;
|
||||
let summary = if overwrite {
|
||||
format!("preset overwritten: {preset_id}")
|
||||
} else {
|
||||
format!("preset saved: {preset_id}")
|
||||
};
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SaveCreativeSnapshot {
|
||||
snapshot_id,
|
||||
label,
|
||||
overwrite,
|
||||
} => {
|
||||
self.show_store.save_creative_snapshot(
|
||||
&snapshot_id,
|
||||
label.clone(),
|
||||
&self.current_scene,
|
||||
self.snapshot.global.transition_duration_ms,
|
||||
self.snapshot.global.transition_style,
|
||||
overwrite,
|
||||
)?;
|
||||
self.rebuild_catalog();
|
||||
should_persist = true;
|
||||
let summary = if overwrite {
|
||||
format!("creative snapshot overwritten: {snapshot_id}")
|
||||
} else {
|
||||
format!("creative snapshot saved: {snapshot_id}")
|
||||
};
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::RecallCreativeSnapshot { snapshot_id } => {
|
||||
let Some(snapshot) = self.show_store.recall_creative_snapshot(&snapshot_id) else {
|
||||
let error = HostCommandError::new(
|
||||
"unknown_creative_snapshot",
|
||||
format!("creative snapshot '{snapshot_id}' does not exist"),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
};
|
||||
self.snapshot.global.transition_duration_ms = snapshot.transition_duration_ms;
|
||||
self.snapshot.global.transition_style = snapshot.transition_style;
|
||||
should_persist = true;
|
||||
self.start_scene_transition(
|
||||
snapshot.scene,
|
||||
snapshot.transition_duration_ms,
|
||||
snapshot.transition_style,
|
||||
format!("creative snapshot recalled: {snapshot_id}"),
|
||||
);
|
||||
format!("creative snapshot recalled: {snapshot_id}")
|
||||
}
|
||||
HostCommand::UpsertGroup {
|
||||
group_id,
|
||||
tags,
|
||||
members,
|
||||
overwrite,
|
||||
} => {
|
||||
self.show_store
|
||||
.upsert_group(&group_id, tags, members, overwrite)?;
|
||||
self.group_members = self.show_store.group_members_map();
|
||||
self.rebuild_catalog();
|
||||
should_persist = true;
|
||||
let summary = if overwrite {
|
||||
format!("group updated: {group_id}")
|
||||
} else {
|
||||
format!("group saved: {group_id}")
|
||||
};
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
};
|
||||
|
||||
self.simulate_tick();
|
||||
if should_persist {
|
||||
self.persist_runtime_state()?;
|
||||
}
|
||||
Ok(CommandOutcome {
|
||||
generated_at_millis: self.snapshot.generated_at_millis,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_scene_transition(
|
||||
&mut self,
|
||||
new_scene: SceneRuntime,
|
||||
duration_ms: u32,
|
||||
style: crate::control::SceneTransitionStyle,
|
||||
style: SceneTransitionStyle,
|
||||
event_message: String,
|
||||
) {
|
||||
let previous_scene = self.current_scene.clone();
|
||||
@@ -355,7 +551,7 @@ impl SimulationState {
|
||||
from_scene: previous_scene,
|
||||
})
|
||||
};
|
||||
self.push_event(event_message);
|
||||
self.push_event(StatusEventKind::Info, None, event_message);
|
||||
}
|
||||
|
||||
fn resolve_transition_if_complete(&mut self) {
|
||||
@@ -367,7 +563,7 @@ impl SimulationState {
|
||||
|
||||
if finished {
|
||||
self.active_transition = None;
|
||||
self.push_event(format!(
|
||||
self.push_event(StatusEventKind::Info, None, format!(
|
||||
"transition completed to {}",
|
||||
self.current_scene.pattern_id
|
||||
));
|
||||
@@ -403,7 +599,11 @@ impl SimulationState {
|
||||
}
|
||||
|
||||
for message in transition_messages {
|
||||
self.push_event(message);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some("node_connection_state".to_string()),
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,11 +701,58 @@ impl SimulationState {
|
||||
(scale_preview(preview, self.snapshot.global.master_brightness), source)
|
||||
}
|
||||
|
||||
fn push_event(&mut self, message: String) {
|
||||
fn rebuild_catalog(&mut self) {
|
||||
self.snapshot.catalog = self.show_store.catalog(&self.registry);
|
||||
}
|
||||
|
||||
fn persisted_global_state(&self) -> PersistedGlobalState {
|
||||
PersistedGlobalState {
|
||||
blackout: self.snapshot.global.blackout,
|
||||
master_brightness: self.snapshot.global.master_brightness,
|
||||
transition_duration_ms: self.snapshot.global.transition_duration_ms,
|
||||
transition_style: self.snapshot.global.transition_style,
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_runtime_state(&mut self) -> Result<(), HostCommandError> {
|
||||
let Some(storage) = &self.runtime_storage else {
|
||||
return Ok(());
|
||||
};
|
||||
let storage_path = storage.path().to_path_buf();
|
||||
|
||||
let runtime_state = self
|
||||
.show_store
|
||||
.persisted_runtime(&self.current_scene, self.persisted_global_state());
|
||||
if let Err(error) = storage.save(&runtime_state) {
|
||||
let command_error = HostCommandError::new(
|
||||
"persist_failed",
|
||||
format!(
|
||||
"runtime state could not be saved to {}: {error}",
|
||||
storage_path.display()
|
||||
),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Error,
|
||||
Some(command_error.code.clone()),
|
||||
command_error.message.clone(),
|
||||
);
|
||||
return Err(command_error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_event(
|
||||
&mut self,
|
||||
kind: StatusEventKind,
|
||||
code: Option<String>,
|
||||
message: String,
|
||||
) {
|
||||
self.snapshot.recent_events.insert(
|
||||
0,
|
||||
StatusEvent {
|
||||
at_millis: self.elapsed_millis(),
|
||||
kind,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
);
|
||||
@@ -533,6 +780,7 @@ fn unavailable_snapshot() -> HostSnapshot {
|
||||
selected_pattern: "unavailable".to_string(),
|
||||
selected_group: None,
|
||||
transition_duration_ms: 0,
|
||||
transition_style: SceneTransitionStyle::Snap,
|
||||
},
|
||||
engine: EngineSnapshot {
|
||||
logic_hz: 0,
|
||||
@@ -543,10 +791,11 @@ fn unavailable_snapshot() -> HostSnapshot {
|
||||
dropped_frames: 0,
|
||||
active_transition: None,
|
||||
},
|
||||
catalog: crate::control::CatalogSnapshot {
|
||||
catalog: CatalogSnapshot {
|
||||
patterns: Vec::new(),
|
||||
presets: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
creative_snapshots: Vec::new(),
|
||||
},
|
||||
active_scene: crate::control::ActiveSceneSnapshot {
|
||||
preset_id: None,
|
||||
@@ -563,6 +812,8 @@ fn unavailable_snapshot() -> HostSnapshot {
|
||||
panels: Vec::new(),
|
||||
recent_events: vec![StatusEvent {
|
||||
at_millis: 0,
|
||||
kind: StatusEventKind::Error,
|
||||
code: Some("service_unavailable".to_string()),
|
||||
message: "simulation service lock was unavailable".to_string(),
|
||||
}],
|
||||
}
|
||||
@@ -668,13 +919,13 @@ mod tests {
|
||||
#[test]
|
||||
fn commands_update_scene_and_group() {
|
||||
let service = SimulationHostService::new(sample_project());
|
||||
service.send_command(HostCommand::SelectGroup {
|
||||
let _ = service.send_command(HostCommand::SelectGroup {
|
||||
group_id: Some("top_panels".to_string()),
|
||||
});
|
||||
service.send_command(HostCommand::RecallPreset {
|
||||
let _ = service.send_command(HostCommand::RecallPreset {
|
||||
preset_id: "mapping_walk_test".to_string(),
|
||||
});
|
||||
service.send_command(HostCommand::SetSceneParameter {
|
||||
let _ = service.send_command(HostCommand::SetSceneParameter {
|
||||
key: "speed".to_string(),
|
||||
value: SceneParameterValue::Scalar(2.0),
|
||||
});
|
||||
@@ -688,4 +939,22 @@ mod tests {
|
||||
.iter()
|
||||
.any(|parameter| parameter.key == "speed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_group_returns_typed_error_and_warning_event() {
|
||||
let service = SimulationHostService::new(sample_project());
|
||||
let error = service
|
||||
.send_command(HostCommand::SelectGroup {
|
||||
group_id: Some("does_not_exist".to_string()),
|
||||
})
|
||||
.expect_err("unknown group should fail");
|
||||
|
||||
let snapshot = service.snapshot();
|
||||
assert_eq!(error.code, "unknown_group");
|
||||
assert!(snapshot
|
||||
.recent_events
|
||||
.iter()
|
||||
.any(|event| event.kind == StatusEventKind::Warning
|
||||
&& event.code.as_deref() == Some("unknown_group")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user