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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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