Software-only show-control readiness baseline

This commit is contained in:
jan
2026-04-17 21:17:23 +02:00
commit a56cecb23d
51 changed files with 16340 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use serde::{Deserialize, Serialize};
pub const HOST_API_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HostSnapshot {
pub api_version: u16,
pub backend_label: String,
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub catalog: CatalogSnapshot,
pub active_scene: ActiveSceneSnapshot,
pub preview: PreviewSnapshot,
pub available_patterns: Vec<String>,
pub nodes: Vec<NodeSnapshot>,
pub panels: Vec<PanelSnapshot>,
pub recent_events: Vec<StatusEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemSnapshot {
pub project_name: String,
pub schema_version: u32,
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobalControlSnapshot {
pub blackout: bool,
pub master_brightness: f32,
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EngineSnapshot {
pub logic_hz: u16,
pub frame_hz: u16,
pub preview_hz: u16,
pub uptime_ms: u64,
pub frame_index: u64,
pub dropped_frames: u64,
pub active_transition: Option<TransitionSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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)]
pub struct PatternDefinition {
pub pattern_id: String,
pub display_name: String,
pub description: String,
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)]
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)]
pub struct ActiveSceneSnapshot {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: Vec<SceneParameterState>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneParameterState {
pub key: String,
pub label: String,
pub kind: SceneParameterKind,
pub value: SceneParameterValue,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneParameterSpec {
pub key: String,
pub label: String,
pub kind: SceneParameterKind,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
pub default_value: SceneParameterValue,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SceneParameterKind {
Scalar,
Toggle,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum SceneParameterValue {
Scalar(f32),
Toggle(bool),
Text(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PreviewSnapshot {
pub panels: Vec<PreviewPanelSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PreviewPanelSnapshot {
pub target: PanelTarget,
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
pub preview_source: PreviewSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PreviewSource {
Scene,
Transition,
PanelTest,
Blackout,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransitionSnapshot {
pub style: SceneTransitionStyle,
pub from_pattern_id: String,
pub to_pattern_id: String,
pub duration_ms: u32,
pub progress: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SceneTransitionStyle {
Snap,
Crossfade,
Chase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeSnapshot {
pub node_id: String,
pub display_name: String,
pub reserved_ip: Option<String>,
pub connection: NodeConnectionState,
pub last_contact_ms: u64,
pub error_status: Option<String>,
pub panel_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelSnapshot {
pub target: PanelTarget,
pub physical_output_name: String,
pub driver_reference: String,
pub led_count: u16,
pub direction: LedDirection,
pub color_order: ColorOrder,
pub enabled: bool,
pub validation_state: ValidationState,
pub connection: NodeConnectionState,
pub last_test_trigger_ms: Option<u64>,
pub error_status: Option<String>,
}
#[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 {
Online,
Degraded,
Offline,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelTarget {
pub node_id: String,
pub panel_position: PanelPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
pub enum HostCommand {
SetBlackout(bool),
SetMasterBrightness(f32),
SelectPattern(String),
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
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)]
#[serde(rename_all = "snake_case")]
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) -> Result<CommandOutcome, HostCommandError>;
}
pub trait HostUiPort: HostApiPort {}
impl<T: HostApiPort + ?Sized> HostUiPort for T {}
impl NodeConnectionState {
pub fn label(self) -> &'static str {
match self {
Self::Online => "online",
Self::Degraded => "degraded",
Self::Offline => "offline",
}
}
}
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 {
Self::WalkingPixel106 => "walking_pixel_106",
}
}
}
impl SceneParameterValue {
pub fn as_scalar(&self) -> Option<f32> {
match self {
Self::Scalar(value) => Some(*value),
_ => None,
}
}
pub fn as_toggle(&self) -> Option<bool> {
match self {
Self::Toggle(value) => Some(*value),
_ => None,
}
}
pub fn text(value: impl Into<String>) -> Self {
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,699 @@
use crate::{
CommandOutcome, HostApiPort, HostCommand, HostCommandError, HostSnapshot, PanelTarget,
SceneParameterValue, SceneTransitionStyle,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
pub enum ShowControlPrimitive {
Blackout {
enabled: bool,
},
RecallPreset {
preset_id: String,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
SetMasterBrightness {
value: f32,
},
SetPattern {
pattern_id: String,
},
SetGroupParameter {
group_id: Option<String>,
key: String,
value: SceneParameterValue,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
SetTransitionStyle {
style: SceneTransitionStyle,
duration_ms: Option<u32>,
},
TriggerTransition,
RequestSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalAdapterCapabilities {
pub supports_blackout: bool,
pub supports_preset_recall: bool,
pub supports_creative_snapshot_recall: bool,
pub supports_master_brightness: bool,
pub supports_pattern_staging: bool,
pub supports_group_parameter_staging: bool,
pub supports_group_upsert: bool,
pub supports_transition_staging: bool,
pub supports_explicit_trigger: bool,
pub supports_snapshot_request: bool,
}
impl Default for ExternalAdapterCapabilities {
fn default() -> Self {
Self {
supports_blackout: true,
supports_preset_recall: true,
supports_creative_snapshot_recall: true,
supports_master_brightness: true,
supports_pattern_staging: true,
supports_group_parameter_staging: true,
supports_group_upsert: true,
supports_transition_staging: true,
supports_explicit_trigger: true,
supports_snapshot_request: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShowControlPrimitiveOutcome {
Buffered { summary: String },
Command(CommandOutcome),
Snapshot(HostSnapshot),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShowControlSession {
pending_pattern_id: Option<String>,
pending_group_id: Option<Option<String>>,
pending_parameters: BTreeMap<String, SceneParameterValue>,
pending_transition_style: Option<SceneTransitionStyle>,
pending_transition_duration_ms: Option<u32>,
}
pub trait ExternalShowControlPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn execute_primitive(
&self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
pub trait ExternalShowControlAdapter: Send {
fn adapter_id(&self) -> &str;
fn capabilities(&self) -> ExternalAdapterCapabilities {
ExternalAdapterCapabilities::default()
}
fn apply_primitive(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
impl ShowControlSession {
pub fn apply(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ShowControlPrimitive::SetPattern { pattern_id } => {
if pattern_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_pattern_id",
"pattern_id must not be empty",
));
}
self.pending_pattern_id = Some(pattern_id.clone());
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("pattern staged: {pattern_id}"),
})
}
ShowControlPrimitive::SetGroupParameter {
group_id,
key,
value,
} => {
if key.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_group_parameter_key",
"group parameter key must not be empty",
));
}
self.pending_group_id = Some(group_id.clone());
self.pending_parameters.insert(key.clone(), value);
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!(
"group parameter staged: {} for {}",
key,
group_id.as_deref().unwrap_or("current_group")
),
})
}
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::SetTransitionStyle { style, duration_ms } => {
self.pending_transition_style = Some(style);
if let Some(duration_ms) = duration_ms {
self.pending_transition_duration_ms = Some(duration_ms);
}
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("transition style staged: {}", style.label()),
})
}
ShowControlPrimitive::TriggerTransition => self.trigger_transition(port),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(port.snapshot()))
}
}
}
fn trigger_transition(
&mut self,
port: &dyn HostApiPort,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
let Some(pattern_id) = self.pending_pattern_id.clone() else {
return Err(HostCommandError::new(
"transition_pattern_required",
"trigger_transition requires a staged pattern",
));
};
if let Some(group_id) = self.pending_group_id.clone() {
port.send_command(HostCommand::SelectGroup { group_id })?;
}
if let Some(duration_ms) = self.pending_transition_duration_ms {
port.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
}
if let Some(style) = self.pending_transition_style {
port.send_command(HostCommand::SetTransitionStyle(style))?;
}
let mut outcome = port.send_command(HostCommand::SelectPattern(pattern_id.clone()))?;
for (key, value) in self.pending_parameters.clone() {
outcome = port.send_command(HostCommand::SetSceneParameter { key, value })?;
}
let summary = if let Some(group_id) = self.pending_group_id.as_ref() {
format!(
"transition triggered: {} on {}",
pattern_id,
group_id.as_deref().unwrap_or("all_panels")
)
} else {
format!("transition triggered: {pattern_id}")
};
self.clear_transition_buffer();
Ok(ShowControlPrimitiveOutcome::Command(CommandOutcome {
generated_at_millis: outcome.generated_at_millis,
summary,
}))
}
pub fn clear_transition_buffer(&mut self) {
self.pending_pattern_id = None;
self.pending_group_id = None;
self.pending_parameters.clear();
self.pending_transition_style = None;
self.pending_transition_duration_ms = None;
}
}
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(self)
}
fn execute_primitive(
&self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(self.snapshot()))
}
ShowControlPrimitive::SetPattern { .. }
| ShowControlPrimitive::SetGroupParameter { .. }
| ShowControlPrimitive::SetTransitionStyle { .. }
| ShowControlPrimitive::TriggerTransition => Err(HostCommandError::new(
"show_control_session_required",
"staged show-control primitives require a stateful ShowControlSession or adapter",
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct BufferedShowControlAdapter {
session: ShowControlSession,
}
impl BufferedShowControlAdapter {
pub fn new() -> Self {
Self::default()
}
pub fn session(&self) -> &ShowControlSession {
&self.session
}
}
impl ExternalShowControlAdapter for BufferedShowControlAdapter {
fn adapter_id(&self) -> &str {
"buffered_show_control"
}
fn apply_primitive(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
self.session.apply(port, primitive)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceShowControlMode {
StatefulSession,
StatelessPort,
}
#[derive(Debug, Clone)]
pub struct ReferenceShowControlClient<P> {
port: P,
mode: ReferenceShowControlMode,
adapter: BufferedShowControlAdapter,
}
impl<P: HostApiPort> ReferenceShowControlClient<P> {
pub fn stateful(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatefulSession,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn stateless(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatelessPort,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn mode(&self) -> ReferenceShowControlMode {
self.mode
}
pub fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(&self.port)
}
pub fn pending_session(&self) -> &ShowControlSession {
self.adapter.session()
}
pub fn apply_primitive(
&mut self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match self.mode {
ReferenceShowControlMode::StatefulSession => {
self.adapter.apply_primitive(&self.port, primitive)
}
ReferenceShowControlMode::StatelessPort => self.port.execute_primitive(primitive),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SimulationHostService;
use infinity_config::ProjectConfig;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn staged_pattern_and_transition_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
let outcome = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
match outcome {
ShowControlPrimitiveOutcome::Command(outcome) => {
assert!(outcome.summary.contains("transition triggered: noise"));
}
other => panic!("expected command outcome, got {other:?}"),
}
let snapshot = client.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn trigger_transition_requires_a_staged_pattern() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
let error = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect_err("trigger without pattern should fail");
assert_eq!(error.code, "transition_pattern_required");
}
#[test]
fn staged_primitives_only_mutate_host_when_transition_is_triggered() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let staged = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
staged,
ShowControlPrimitiveOutcome::Buffered {
summary: "pattern staged: noise".to_string(),
}
);
let staged_snapshot = client.snapshot();
assert_eq!(
staged_snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn group_update_parameter_change_and_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::UpsertGroup {
group_id: "focus_pair".to_string(),
tags: vec!["runtime".to_string(), "focus".to_string()],
members: vec![
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Top,
},
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Middle,
},
],
overwrite: true,
})
.expect("group upsert should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("focus_pair".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.81),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.global.selected_group.as_deref(),
Some("focus_pair")
);
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("focus_pair")
);
assert!(snapshot
.active_scene
.parameters
.iter()
.any(|parameter| parameter.key == "grain"
&& parameter.value == SceneParameterValue::Scalar(0.81)));
assert!(snapshot
.catalog
.groups
.iter()
.any(|group| group.group_id == "focus_pair"));
}
#[test]
fn preset_recall_interrupts_running_transition_with_a_new_transition() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Crossfade,
duration_ms: Some(1600),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let active_before_recall = client.snapshot();
assert!(active_before_recall.engine.active_transition.is_some());
client
.apply_primitive(ShowControlPrimitive::RecallPreset {
preset_id: "ocean_gradient".to_string(),
})
.expect("preset recall should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.active_scene.preset_id.as_deref(),
Some("ocean_gradient")
);
assert_eq!(snapshot.active_scene.pattern_id, "gradient");
assert!(snapshot.engine.active_transition.is_some());
assert!(snapshot
.recent_events
.iter()
.any(|event| event.message.contains("preset recalled: ocean_gradient")));
}
#[test]
fn blackout_during_staged_session_keeps_pending_transition_buffer() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("bottom_panels".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.74),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::Blackout { enabled: true })
.expect("blackout should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
assert_eq!(
client.pending_session().pending_group_id,
Some(Some("bottom_panels".to_string()))
);
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(snapshot.global.blackout, true);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("bottom_panels")
);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn request_snapshot_is_read_only() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let outcome = client
.apply_primitive(ShowControlPrimitive::RequestSnapshot)
.expect("snapshot request should succeed");
match outcome {
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
assert_eq!(
snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
other => panic!("expected snapshot outcome, got {other:?}"),
}
let after = client.snapshot();
assert_eq!(
after.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn staged_primitives_are_rejected_on_a_stateless_port() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateless(service);
let error = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect_err("staged primitive should require a session");
assert_eq!(error.code, "show_control_session_required");
}
#[test]
fn invalid_group_parameter_key_is_rejected() {
let service = SimulationHostService::new(sample_project());
let mut session = ShowControlSession::default();
let error = session
.apply(
&service,
ShowControlPrimitive::SetGroupParameter {
group_id: Some("top_panels".to_string()),
key: " ".to_string(),
value: SceneParameterValue::Scalar(0.4),
},
)
.expect_err("empty parameter key should fail");
assert_eq!(error.code, "invalid_group_parameter_key");
}
}

View File

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

View File

@@ -0,0 +1,161 @@
use clap::{Parser, Subcommand, ValueEnum};
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
use std::{path::PathBuf, process::ExitCode};
#[derive(Debug, Parser)]
#[command(
author,
version,
about = "Infinity Vis host-side validation and planning CLI"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Validate {
#[arg(long)]
config: PathBuf,
#[arg(long, value_enum, default_value_t = CliValidationMode::Structural)]
mode: CliValidationMode,
},
PlanBootScene {
#[arg(long)]
config: PathBuf,
#[arg(long)]
preset_id: String,
},
Snapshot {
#[arg(long)]
config: PathBuf,
},
OpenValidationPoints,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliValidationMode {
Structural,
Activation,
}
impl From<CliValidationMode> for ValidationMode {
fn from(value: CliValidationMode) -> Self {
match value {
CliValidationMode::Structural => ValidationMode::Structural,
CliValidationMode::Activation => ValidationMode::Activation,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Validate { config, mode } => validate_command(config, mode),
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
Command::Snapshot { config } => snapshot_command(config),
Command::OpenValidationPoints => {
print_open_validation_points();
ExitCode::SUCCESS
}
}
}
fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
let project = match load_project_from_path(&config) {
Ok(project) => project,
Err(error) => {
eprintln!("Failed to load config '{}': {error}", config.display());
return ExitCode::FAILURE;
}
};
let engine = RealtimeEngine::default();
let report = engine.validate_project(&project, mode.into());
println!(
"Validation finished: {} error(s), {} warning(s)",
report.error_count(),
report.warning_count()
);
for issue in &report.issues {
let level = match issue.severity {
ValidationSeverity::Warning => "WARN",
ValidationSeverity::Error => "ERROR",
};
println!(
"[{level}] {} at {}: {}",
issue.code, issue.path, issue.message
);
}
if report.is_ok() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn plan_boot_scene_command(config: PathBuf, preset_id: &str) -> ExitCode {
let project = match load_project_from_path(&config) {
Ok(project) => project,
Err(error) => {
eprintln!("Failed to load config '{}': {error}", config.display());
return ExitCode::FAILURE;
}
};
let engine = RealtimeEngine::default();
let plan = engine.plan_boot_scene(&project, preset_id);
if plan.is_empty() {
eprintln!("Preset '{preset_id}' was not found.");
return ExitCode::FAILURE;
}
match serde_json::to_string_pretty(&plan) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Failed to serialize boot plan: {error}");
ExitCode::FAILURE
}
}
}
fn snapshot_command(config: PathBuf) -> ExitCode {
let project = match load_project_from_path(&config) {
Ok(project) => project,
Err(error) => {
eprintln!("Failed to load config '{}': {error}", config.display());
return ExitCode::FAILURE;
}
};
let service = SimulationHostService::new(project);
match serde_json::to_string_pretty(&service.snapshot()) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Failed to serialize snapshot: {error}");
ExitCode::FAILURE
}
}
}
fn print_open_validation_points() {
for line in [
"Pending hardware validation gates:",
"1. Confirm whether 'UART 6 / 5 / 4' are GPIO labels, logical channels, or real UART peripherals.",
"2. Confirm the exact LED chipset and timing backend required per output.",
"3. Confirm color order, start pixel, and direction for all 18 outputs.",
"4. Confirm whether all outputs truly have 106 active LEDs without dummy or reserve pixels.",
"5. Confirm the final node-to-physical-panel mapping before enabling layout-specific scenes.",
] {
println!("{line}");
}
}

View File

@@ -0,0 +1,175 @@
use infinity_config::{
PanelPosition, ProjectConfig, TransportMode, ValidationIssue, ValidationMode, ValidationReport,
ValidationSeverity,
};
use infinity_protocol::{
ControlEnvelope, ControlMessage, PanelAddress, PanelAssignment, PanelSlot, RealtimeEnvelope,
RealtimeMessage, RealtimeMode, SceneParametersFrame, TransitionMode, TransitionSpec,
};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TickSchedule {
pub logic_hz: u16,
pub frame_synthesis_hz: u16,
pub network_send_hz: u16,
pub preview_hz: u16,
}
impl Default for TickSchedule {
fn default() -> Self {
Self {
logic_hz: 120,
frame_synthesis_hz: 60,
network_send_hz: 60,
preview_hz: 15,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PlannedSend {
pub node_id: String,
pub control: Vec<ControlEnvelope>,
pub realtime: Vec<RealtimeEnvelope>,
}
#[derive(Debug, Clone)]
pub struct RealtimeEngine {
pub schedule: TickSchedule,
}
impl Default for RealtimeEngine {
fn default() -> Self {
Self {
schedule: TickSchedule::default(),
}
}
}
impl RealtimeEngine {
pub fn validate_project(
&self,
project: &ProjectConfig,
mode: ValidationMode,
) -> ValidationReport {
let mut report = project.validate(mode);
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
report.issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: "preview_rate_too_high",
path: "runtime.schedule.preview_hz".to_string(),
message: "preview rate should stay below frame synthesis rate".to_string(),
});
}
report
}
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
let Some(preset) = project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
else {
return Vec::new();
};
let transport = project
.transport_profiles
.iter()
.find(|profile| profile.profile_id == project.metadata.default_transport_profile);
let safety = project
.safety_profiles
.iter()
.find(|profile| profile.profile_id == project.metadata.default_safety_profile);
project
.topology
.nodes
.iter()
.enumerate()
.map(|(index, node)| {
let assignments = node
.outputs
.iter()
.map(|output| PanelAssignment {
panel: PanelAddress {
node_id: node.node_id.clone(),
panel_slot: map_panel_slot(&output.panel_position),
},
physical_output_name: output.physical_output_name.clone(),
driver_reference: output.driver_channel.reference.clone(),
})
.collect::<Vec<_>>();
let targets = assignments
.iter()
.map(|assignment| assignment.panel.clone())
.collect::<Vec<_>>();
let transition = TransitionSpec {
transition_ms: preset.transition_ms,
mode: TransitionMode::Crossfade,
};
let mode = match transport.map(|profile| &profile.mode) {
Some(TransportMode::FrameStreaming) => RealtimeMode::FrameStreaming,
_ => RealtimeMode::DistributedScene,
};
PlannedSend {
node_id: node.node_id.clone(),
control: vec![
ControlEnvelope::new(
(index as u32) * 2 + 1,
0,
ControlMessage::ConfigSync {
topology_revision: format!(
"schema-{}",
project.metadata.schema_version
),
outputs: assignments,
},
),
ControlEnvelope::new(
(index as u32) * 2 + 2,
0,
ControlMessage::PresetRecall {
preset_id: preset.preset_id.clone(),
transition,
},
),
],
realtime: vec![RealtimeEnvelope::new(
(index as u32) + 1,
0,
RealtimeMessage::SceneParameters(SceneParametersFrame {
node_id: node.node_id.clone(),
mode,
preset_id: Some(preset.preset_id.clone()),
effect: preset.scene.effect.clone(),
seed: preset.scene.seed,
palette: preset.scene.palette.clone(),
master_brightness: safety
.map(|profile| profile.default_start_brightness)
.unwrap_or(0.2),
speed: preset.scene.speed,
intensity: preset.scene.intensity,
target_group: preset.target_group.clone(),
target_outputs: targets,
}),
)],
}
})
.collect()
}
}
fn map_panel_slot(position: &PanelPosition) -> PanelSlot {
match position {
PanelPosition::Top => PanelSlot::Top,
PanelPosition::Middle => PanelSlot::Middle,
PanelPosition::Bottom => PanelSlot::Bottom,
}
}

View File

@@ -0,0 +1,772 @@
use crate::control::{
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},
time::Instant,
};
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneRuntime {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: BTreeMap<String, SceneParameterValue>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone)]
pub struct TransitionRuntime {
pub style: SceneTransitionStyle,
pub duration_ms: u32,
pub started_at: Instant,
pub from_scene: SceneRuntime,
}
#[derive(Debug, Clone)]
pub struct RenderedPreview {
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
}
#[derive(Debug, Clone)]
pub struct PatternRegistry {
definitions: BTreeMap<String, PatternDefinition>,
}
#[derive(Debug, Clone, Copy)]
struct RgbColor {
r: u8,
g: u8,
b: u8,
}
impl PatternRegistry {
pub fn new() -> Self {
let definitions = default_pattern_definitions()
.into_iter()
.map(|definition| (definition.pattern_id.clone(), definition))
.collect();
Self { definitions }
}
pub fn catalog(&self, project: &ProjectConfig) -> CatalogSnapshot {
CatalogSnapshot {
patterns: self.definitions.values().cloned().collect(),
presets: project
.presets
.iter()
.map(|preset| PresetSummary {
preset_id: preset.preset_id.clone(),
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
.topology
.groups
.iter()
.map(|group| GroupSummary {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: CatalogSource::BuiltIn,
})
.collect(),
creative_snapshots: Vec::new(),
}
}
pub fn initial_scene(&self, project: &ProjectConfig) -> SceneRuntime {
project
.presets
.first()
.map(|preset| self.scene_from_preset(preset))
.unwrap_or_else(|| {
self.scene_for_pattern(
"solid_color",
Some("bootstrap-solid-color".to_string()),
None,
1,
vec!["#ffffff".to_string()],
false,
)
})
}
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
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| self.scene_from_preset(preset))
}
pub fn transition_style_for_preset(
&self,
project: &ProjectConfig,
preset_id: &str,
) -> SceneTransitionStyle {
project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| transition_style_from_duration(preset.transition_ms))
.unwrap_or(SceneTransitionStyle::Crossfade)
}
pub fn scene_for_pattern(
&self,
pattern_id: &str,
preset_id: Option<String>,
target_group: Option<String>,
seed: u64,
palette: Vec<String>,
blackout: bool,
) -> SceneRuntime {
let definition = self.definition_or_default(pattern_id);
let mut parameters = BTreeMap::new();
for spec in &definition.parameters {
parameters.insert(spec.key.clone(), spec.default_value.clone());
}
SceneRuntime {
preset_id,
pattern_id: definition.pattern_id.clone(),
seed,
palette: if palette.is_empty() {
vec!["#ffffff".to_string()]
} else {
palette
},
parameters,
target_group,
blackout,
}
}
pub fn set_scene_parameter(
&self,
scene: &mut SceneRuntime,
key: &str,
value: SceneParameterValue,
) {
scene.parameters.insert(key.to_string(), value);
}
pub fn active_scene_snapshot(&self, scene: &SceneRuntime) -> ActiveSceneSnapshot {
let definition = self.definition_or_default(&scene.pattern_id);
let parameters = definition
.parameters
.iter()
.map(|spec| SceneParameterState {
key: spec.key.clone(),
label: spec.label.clone(),
kind: spec.kind,
value: scene
.parameters
.get(&spec.key)
.cloned()
.unwrap_or_else(|| spec.default_value.clone()),
min_scalar: spec.min_scalar,
max_scalar: spec.max_scalar,
step: spec.step,
})
.collect();
ActiveSceneSnapshot {
preset_id: scene.preset_id.clone(),
pattern_id: scene.pattern_id.clone(),
seed: scene.seed,
palette: scene.palette.clone(),
parameters,
target_group: scene.target_group.clone(),
blackout: scene.blackout,
}
}
pub fn transition_snapshot(
&self,
scene: &SceneRuntime,
transition: &TransitionRuntime,
) -> TransitionSnapshot {
let elapsed_ms = transition.started_at.elapsed().as_millis() as u64;
let progress = if transition.duration_ms == 0 {
1.0
} else {
(elapsed_ms as f32 / transition.duration_ms as f32).clamp(0.0, 1.0)
};
TransitionSnapshot {
style: transition.style,
from_pattern_id: transition.from_scene.pattern_id.clone(),
to_pattern_id: scene.pattern_id.clone(),
duration_ms: transition.duration_ms,
progress,
}
}
pub fn transition_finished(&self, transition: &TransitionRuntime) -> bool {
transition.started_at.elapsed().as_millis() as u32 >= transition.duration_ms
}
pub fn render_preview(
&self,
scene: &SceneRuntime,
panel_index: usize,
panel_count: usize,
elapsed_ms: u64,
) -> RenderedPreview {
let colors = palette_from_scene(scene);
let sample_leds = (0..DEFAULT_SAMPLE_LED_COUNT)
.map(|sample_index| {
self.render_led_color(
scene,
&colors,
panel_index,
panel_count,
sample_index,
DEFAULT_SAMPLE_LED_COUNT,
elapsed_ms,
)
})
.collect::<Vec<_>>();
let representative = RgbColor::average(&sample_leds);
let energy_percent = sample_leds
.iter()
.map(|color| color.energy_percent() as u32)
.sum::<u32>()
/ sample_leds.len().max(1) as u32;
RenderedPreview {
representative_color_hex: representative.to_hex(),
sample_led_hex: sample_leds
.into_iter()
.map(|color| color.to_hex())
.collect(),
energy_percent: energy_percent.min(100) as u8,
}
}
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()),
preset.target_group.clone(),
preset.scene.seed,
preset.scene.palette.clone(),
preset.scene.blackout,
);
self.set_scene_parameter(
&mut scene,
"speed",
SceneParameterValue::Scalar(preset.scene.speed),
);
self.set_scene_parameter(
&mut scene,
"intensity",
SceneParameterValue::Scalar(preset.scene.intensity),
);
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)
.or_else(|| self.definitions.get("solid_color"))
.expect("pattern registry must contain a solid_color definition")
}
fn render_led_color(
&self,
scene: &SceneRuntime,
palette: &[RgbColor],
panel_index: usize,
panel_count: usize,
sample_index: usize,
sample_count: usize,
elapsed_ms: u64,
) -> RgbColor {
let speed = scene
.parameters
.get("speed")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(1.0);
let intensity = scene
.parameters
.get("intensity")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let spread = scene
.parameters
.get("spread")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.55);
let width = scene
.parameters
.get("width")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.25)
.clamp(0.05, 1.0);
let grain = scene
.parameters
.get("grain")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.65)
.clamp(0.0, 1.0);
let trail = scene
.parameters
.get("trail")
.and_then(SceneParameterValue::as_scalar)
.unwrap_or(0.45)
.clamp(0.05, 1.0);
let panel_phase = if panel_count == 0 {
0.0
} else {
panel_index as f32 / panel_count as f32
};
let led_phase = if sample_count == 0 {
0.0
} else {
sample_index as f32 / sample_count as f32
};
let time_phase = elapsed_ms as f32 / 1000.0 * speed.max(0.01);
let raw = match scene.pattern_id.as_str() {
"solid_color" => palette.first().copied().unwrap_or(RgbColor::WHITE),
"gradient" => {
let from = palette.first().copied().unwrap_or(RgbColor::BLACK);
let to = palette.get(1).copied().unwrap_or(from);
let blend = (panel_phase + led_phase * spread + time_phase * 0.12).fract();
from.blend(to, blend)
}
"chase" => {
let position = (time_phase * 0.65 + panel_phase + led_phase).fract();
let highlight = distance_wrap(position, 0.0);
let strength = smooth_peak(highlight, width);
palette
.first()
.copied()
.unwrap_or(RgbColor::WHITE)
.scale(strength)
}
"pulse" => {
let wave = ((time_phase * 2.8 + panel_phase * 0.6).sin() + 1.0) * 0.5;
palette
.first()
.copied()
.unwrap_or(RgbColor::WHITE)
.scale(wave)
}
"noise" => {
let noise = hashed_noise(scene.seed, panel_index, sample_index, elapsed_ms);
let color_index = ((noise * palette.len().max(1) as f32).floor() as usize)
.min(palette.len().saturating_sub(1));
palette
.get(color_index)
.copied()
.unwrap_or(RgbColor::WHITE)
.scale((0.3 + noise * grain).clamp(0.0, 1.0))
}
"walking_pixel" => {
let head = (time_phase * 0.8 + panel_phase * 0.4).fract();
let sample = led_phase;
let distance = distance_wrap(sample, head);
let strength = smooth_peak(distance, trail);
let base = palette.first().copied().unwrap_or(RgbColor::WHITE);
base.scale(strength.max(0.08))
}
_ => palette.first().copied().unwrap_or(RgbColor::WHITE),
};
raw.scale(intensity)
}
}
pub fn transition_style_from_duration(duration_ms: u32) -> SceneTransitionStyle {
if duration_ms == 0 {
SceneTransitionStyle::Snap
} else if duration_ms <= 120 {
SceneTransitionStyle::Chase
} else {
SceneTransitionStyle::Crossfade
}
}
pub fn apply_group_gate(preview: &RenderedPreview, active_in_group: bool) -> RenderedPreview {
if active_in_group {
return preview.clone();
}
let dimmed = preview
.sample_led_hex
.iter()
.map(|hex| parse_color(hex).scale(0.18).to_hex())
.collect::<Vec<_>>();
let representative = parse_color(&preview.representative_color_hex).scale(0.18);
RenderedPreview {
representative_color_hex: representative.to_hex(),
sample_led_hex: dimmed,
energy_percent: ((preview.energy_percent as f32) * 0.18) as u8,
}
}
pub fn blend_previews(
from: &RenderedPreview,
to: &RenderedPreview,
progress: f32,
) -> RenderedPreview {
let blend = progress.clamp(0.0, 1.0);
let from_color = parse_color(&from.representative_color_hex);
let to_color = parse_color(&to.representative_color_hex);
let sample_count = from.sample_led_hex.len().min(to.sample_led_hex.len());
let mut sample_led_hex = Vec::with_capacity(sample_count);
for index in 0..sample_count {
let left = parse_color(&from.sample_led_hex[index]);
let right = parse_color(&to.sample_led_hex[index]);
sample_led_hex.push(left.blend(right, blend).to_hex());
}
RenderedPreview {
representative_color_hex: from_color.blend(to_color, blend).to_hex(),
sample_led_hex,
energy_percent: ((from.energy_percent as f32)
+ (to.energy_percent as f32 - from.energy_percent as f32) * blend)
as u8,
}
}
pub fn panel_test_preview(elapsed_since_trigger_ms: u64) -> RenderedPreview {
let phase = ((elapsed_since_trigger_ms / 120) % DEFAULT_SAMPLE_LED_COUNT as u64) as usize;
let mut sample_led_hex = Vec::with_capacity(DEFAULT_SAMPLE_LED_COUNT);
for index in 0..DEFAULT_SAMPLE_LED_COUNT {
let strength = if index == phase {
1.0
} else if distance_wrap(
index as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
phase as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
) < 0.20
{
0.35
} else {
0.06
};
sample_led_hex.push(RgbColor::WHITE.scale(strength).to_hex());
}
RenderedPreview {
representative_color_hex: RgbColor::WHITE.scale(0.42).to_hex(),
sample_led_hex,
energy_percent: 42,
}
}
pub fn blackout_preview() -> RenderedPreview {
RenderedPreview {
representative_color_hex: "#000000".to_string(),
sample_led_hex: vec!["#000000".to_string(); DEFAULT_SAMPLE_LED_COUNT],
energy_percent: 0,
}
}
pub fn build_group_members(project: &ProjectConfig) -> BTreeMap<String, BTreeSet<String>> {
project
.topology
.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 panel_membership_key(node_id: &str, panel_position: &str) -> String {
format!("{node_id}:{panel_position}")
}
fn panel_position_key(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
fn default_pattern_definitions() -> Vec<PatternDefinition> {
vec![
PatternDefinition {
pattern_id: "solid_color".to_string(),
display_name: "Solid Color".to_string(),
description: "Static palette color for calm base looks and blackout recovery scenes."
.to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
],
},
PatternDefinition {
pattern_id: "gradient".to_string(),
display_name: "Gradient Drift".to_string(),
description: "Spatial gradient with slow temporal drift for mood development."
.to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.35),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.85),
scalar_spec("spread", "Spread", 0.1, 1.5, 0.05, 0.55),
],
},
PatternDefinition {
pattern_id: "chase".to_string(),
display_name: "Chase".to_string(),
description: "Directional chase motion that is easy to time and group.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 1.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
scalar_spec("width", "Width", 0.05, 0.8, 0.01, 0.25),
],
},
PatternDefinition {
pattern_id: "pulse".to_string(),
display_name: "Pulse".to_string(),
description: "Breathing pulse for soft transitions and level checks.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 0.75),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.9),
],
},
PatternDefinition {
pattern_id: "noise".to_string(),
display_name: "Noise".to_string(),
description: "Organic shimmer driven by deterministic pseudo noise.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 4.0, 0.05, 0.7),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.8),
scalar_spec("grain", "Grain", 0.0, 1.0, 0.01, 0.65),
],
},
PatternDefinition {
pattern_id: "walking_pixel".to_string(),
display_name: "Walking Pixel".to_string(),
description: "Single-pixel scan for mapping tests and alignment checks.".to_string(),
parameters: vec![
scalar_spec("speed", "Speed", 0.1, 8.0, 0.05, 1.0),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
scalar_spec("trail", "Trail", 0.05, 0.8, 0.01, 0.45),
],
},
]
}
fn scalar_spec(
key: &str,
label: &str,
min_scalar: f32,
max_scalar: f32,
step: f32,
default_value: f32,
) -> SceneParameterSpec {
SceneParameterSpec {
key: key.to_string(),
label: label.to_string(),
kind: SceneParameterKind::Scalar,
min_scalar: Some(min_scalar),
max_scalar: Some(max_scalar),
step: Some(step),
default_value: SceneParameterValue::Scalar(default_value),
}
}
fn palette_from_scene(scene: &SceneRuntime) -> Vec<RgbColor> {
if scene.palette.is_empty() {
vec![RgbColor::WHITE]
} else {
scene
.palette
.iter()
.map(|entry| parse_color(entry))
.collect()
}
}
fn parse_color(hex: &str) -> RgbColor {
let raw = hex.trim().trim_start_matches('#');
if raw.len() == 6 {
if let Ok(value) = u32::from_str_radix(raw, 16) {
return RgbColor {
r: ((value >> 16) & 0xff) as u8,
g: ((value >> 8) & 0xff) as u8,
b: (value & 0xff) as u8,
};
}
}
RgbColor::WHITE
}
fn hashed_noise(seed: u64, panel_index: usize, sample_index: usize, elapsed_ms: u64) -> f32 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
panel_index.hash(&mut hasher);
sample_index.hash(&mut hasher);
(elapsed_ms / 110).hash(&mut hasher);
let value = hasher.finish();
(value % 10_000) as f32 / 10_000.0
}
fn distance_wrap(a: f32, b: f32) -> f32 {
let diff = (a - b).abs();
diff.min(1.0 - diff)
}
fn smooth_peak(distance: f32, width: f32) -> f32 {
let normalized = (1.0 - distance / width.max(0.0001)).clamp(0.0, 1.0);
normalized * normalized
}
impl RgbColor {
const BLACK: Self = Self { r: 0, g: 0, b: 0 };
const WHITE: Self = Self {
r: 255,
g: 255,
b: 255,
};
fn blend(self, other: Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
Self {
r: (self.r as f32 + (other.r as f32 - self.r as f32) * t) as u8,
g: (self.g as f32 + (other.g as f32 - self.g as f32) * t) as u8,
b: (self.b as f32 + (other.b as f32 - self.b as f32) * t) as u8,
}
}
fn scale(self, amount: f32) -> Self {
let amount = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f32 * amount) as u8,
g: (self.g as f32 * amount) as u8,
b: (self.b as f32 * amount) as u8,
}
}
fn average(colors: &[Self]) -> Self {
if colors.is_empty() {
return Self::BLACK;
}
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
for color in colors {
r += color.r as u32;
g += color.g as u32;
b += color.b as u32;
}
let count = colors.len() as u32;
Self {
r: (r / count) as u8,
g: (g / count) as u8,
b: (b / count) as u8,
}
}
fn energy_percent(self) -> u8 {
(((self.r as u32 + self.g as u32 + self.b as u32) as f32 / (255.0 * 3.0)) * 100.0) as u8
}
fn to_hex(self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
}
#[cfg(test)]
mod tests {
use super::*;
use infinity_config::ProjectConfig;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn registry_builds_catalog_for_project() {
let registry = PatternRegistry::new();
let catalog = registry.catalog(&sample_project());
assert!(catalog
.patterns
.iter()
.any(|pattern| pattern.pattern_id == "solid_color"));
assert!(catalog
.presets
.iter()
.any(|preset| preset.preset_id == "ocean_gradient"));
assert!(catalog
.groups
.iter()
.any(|group| group.group_id == "bottom_panels"));
}
#[test]
fn preset_scene_uses_speed_and_intensity() {
let registry = PatternRegistry::new();
let project = sample_project();
let scene = registry
.scene_from_preset_id(&project, "mapping_walk_test")
.expect("preset must exist");
assert_eq!(scene.pattern_id, "walking_pixel");
assert_eq!(
scene.parameters.get("speed"),
Some(&SceneParameterValue::Scalar(1.0))
);
}
#[test]
fn preview_render_produces_hex_output() {
let registry = PatternRegistry::new();
let project = sample_project();
let scene = registry.initial_scene(&project);
let preview = registry.render_preview(&scene, 0, 18, 450);
assert_eq!(preview.sample_led_hex.len(), 6);
assert!(preview.representative_color_hex.starts_with('#'));
}
}

View File

@@ -0,0 +1,801 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{PatternRegistry, SceneRuntime},
};
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, Clone, PartialEq, Eq)]
pub struct RuntimeStateLoadWarning {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeStateLoadResult {
pub runtime: PersistedRuntimeState,
pub loaded_from_disk: bool,
pub warnings: Vec<RuntimeStateLoadWarning>,
}
#[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)?;
parse_runtime_state(&raw, &self.path)
}
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
if !self.path.exists() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: Vec::new(),
};
}
let raw = match fs::read_to_string(&self.path) {
Ok(raw) => raw,
Err(error) => {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
};
}
};
if raw.trim().is_empty() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_empty",
format!(
"runtime state at {} was empty and was reset to defaults",
self.path.display()
),
)],
};
}
match parse_runtime_state(&raw, &self.path) {
Ok(runtime) => RuntimeStateLoadResult {
runtime,
loaded_from_disk: true,
warnings: Vec::new(),
},
Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_parse_failed",
format!(
"runtime state at {} could not be parsed and was reset to defaults: {error}",
self.path.display()
),
)],
},
Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_schema_unsupported",
format!("{message}; runtime state was reset to defaults"),
)],
},
Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
},
}
}
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 RuntimeStateLoadWarning {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
fn parse_runtime_state(raw: &str, path: &Path) -> Result<PersistedRuntimeState, ShowStoreError> {
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,
path.display()
)));
}
Ok(envelope.runtime)
}
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);
}
#[test]
fn runtime_state_storage_recovers_from_empty_file() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_empty_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "").expect("empty file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_empty");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_invalid_json() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_invalid_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "{ definitely not json").expect("invalid file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_unsupported_schema() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_schema_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(
&path,
r#"{
"schema_version": 99,
"saved_at_unix_ms": 1,
"runtime": {
"active_scene": null,
"global": {
"blackout": false,
"master_brightness": 0.2,
"transition_duration_ms": 150,
"transition_style": "crossfade"
},
"user_presets": [],
"user_groups": [],
"creative_snapshots": []
}
}"#,
)
.expect("schema file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported");
let _ = std::fs::remove_file(path);
}
}

File diff suppressed because it is too large Load Diff