Software-only show-control readiness baseline
This commit is contained in:
391
crates/infinity_host/src/control.rs
Normal file
391
crates/infinity_host/src/control.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
699
crates/infinity_host/src/external_control.rs
Normal file
699
crates/infinity_host/src/external_control.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
13
crates/infinity_host/src/lib.rs
Normal file
13
crates/infinity_host/src/lib.rs
Normal 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::*;
|
||||
161
crates/infinity_host/src/main.rs
Normal file
161
crates/infinity_host/src/main.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
175
crates/infinity_host/src/runtime.rs
Normal file
175
crates/infinity_host/src/runtime.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
772
crates/infinity_host/src/scene.rs
Normal file
772
crates/infinity_host/src/scene.rs
Normal 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('#'));
|
||||
}
|
||||
}
|
||||
801
crates/infinity_host/src/show_store.rs
Normal file
801
crates/infinity_host/src/show_store.rs
Normal 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(), ®istry);
|
||||
let catalog = store.catalog(®istry);
|
||||
assert!(catalog
|
||||
.presets
|
||||
.iter()
|
||||
.any(|preset| preset.preset_id == "ocean_gradient"));
|
||||
assert!(catalog
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| group.group_id == "top_panels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_presets_and_snapshots_can_be_saved() {
|
||||
let registry = PatternRegistry::new();
|
||||
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||
let scene = registry.scene_for_pattern(
|
||||
"gradient",
|
||||
None,
|
||||
Some("top_panels".to_string()),
|
||||
77,
|
||||
vec!["#112233".to_string(), "#445566".to_string()],
|
||||
false,
|
||||
);
|
||||
|
||||
store
|
||||
.save_preset_from_scene(
|
||||
"user_gradient",
|
||||
&scene,
|
||||
420,
|
||||
SceneTransitionStyle::Crossfade,
|
||||
false,
|
||||
)
|
||||
.expect("preset save should succeed");
|
||||
store
|
||||
.save_creative_snapshot(
|
||||
"variant_a",
|
||||
Some("Variant A".to_string()),
|
||||
&scene,
|
||||
240,
|
||||
SceneTransitionStyle::Chase,
|
||||
false,
|
||||
)
|
||||
.expect("snapshot save should succeed");
|
||||
|
||||
assert!(store.scene_from_preset_id("user_gradient").is_some());
|
||||
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
||||
let registry = PatternRegistry::new();
|
||||
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||
let scene = registry.scene_for_pattern(
|
||||
"noise",
|
||||
None,
|
||||
Some("bottom_panels".to_string()),
|
||||
99,
|
||||
vec!["#AA8844".to_string()],
|
||||
false,
|
||||
);
|
||||
store
|
||||
.save_preset_from_scene(
|
||||
"roundtrip_noise",
|
||||
&scene,
|
||||
220,
|
||||
SceneTransitionStyle::Chase,
|
||||
false,
|
||||
)
|
||||
.expect("preset save should succeed");
|
||||
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"infinity_vis_show_store_{}.json",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_millis()
|
||||
));
|
||||
let storage = RuntimeStateStorage::new(&path);
|
||||
let runtime = store.persisted_runtime(
|
||||
&scene,
|
||||
PersistedGlobalState {
|
||||
blackout: false,
|
||||
master_brightness: 0.42,
|
||||
transition_duration_ms: 220,
|
||||
transition_style: SceneTransitionStyle::Chase,
|
||||
},
|
||||
);
|
||||
storage.save(&runtime).expect("save should work");
|
||||
let loaded = storage.load().expect("load should work");
|
||||
|
||||
assert_eq!(loaded.active_scene, Some(scene));
|
||||
assert!(loaded
|
||||
.user_presets
|
||||
.iter()
|
||||
.any(|preset| preset.preset_id == "roundtrip_noise"));
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
1023
crates/infinity_host/src/simulation.rs
Normal file
1023
crates/infinity_host/src/simulation.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user