Stabilize control surface and external bridge v1

This commit is contained in:
jan
2026-04-20 01:13:27 +02:00
parent a56cecb23d
commit 07c52db5fb
29 changed files with 8818 additions and 1510 deletions

View File

@@ -1,4 +1,4 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
use serde::{Deserialize, Serialize};
pub const HOST_API_VERSION: u16 = 1;
@@ -9,6 +9,7 @@ pub struct HostSnapshot {
pub backend_label: String,
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub technical: TechnicalSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub catalog: CatalogSnapshot,
@@ -27,6 +28,21 @@ pub struct SystemSnapshot {
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TechnicalSnapshot {
pub backend_mode: OutputBackendMode,
pub output_enabled: bool,
pub output_fps: u16,
pub live_status: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputBackendMode {
PreviewOnly,
DdpWled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobalControlSnapshot {
pub blackout: bool,
@@ -204,6 +220,7 @@ pub struct NodeSnapshot {
pub struct PanelSnapshot {
pub target: PanelTarget,
pub physical_output_name: String,
pub driver_kind: DriverKind,
pub driver_reference: String,
pub led_count: u16,
pub direction: LedDirection,
@@ -248,6 +265,23 @@ pub struct PanelTarget {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
pub enum HostCommand {
SetOutputBackendMode(OutputBackendMode),
SetLiveOutputEnabled(bool),
SetOutputFps(u16),
SetNodeReservedIp {
node_id: String,
reserved_ip: Option<String>,
},
UpdatePanelMapping {
target: PanelTarget,
physical_output_name: String,
driver_kind: DriverKind,
driver_reference: String,
led_count: u16,
direction: LedDirection,
color_order: ColorOrder,
enabled: bool,
},
SetBlackout(bool),
SetMasterBrightness(f32),
SelectPattern(String),
@@ -271,6 +305,9 @@ pub enum HostCommand {
preset_id: String,
overwrite: bool,
},
DeletePreset {
preset_id: String,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
@@ -334,6 +371,22 @@ impl SceneTransitionStyle {
}
}
impl OutputBackendMode {
pub fn label(self) -> &'static str {
match self {
Self::PreviewOnly => "preview_only",
Self::DdpWled => "ddp_wled",
}
}
pub fn display_label(self) -> &'static str {
match self {
Self::PreviewOnly => "Preview Only",
Self::DdpWled => "DDP (WLED)",
}
}
}
impl CatalogSource {
pub fn label(self) -> &'static str {
match self {
@@ -369,6 +422,13 @@ impl SceneParameterValue {
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(value) => Some(value.as_str()),
_ => None,
}
}
pub fn as_toggle(&self) -> Option<bool> {
match self {
Self::Toggle(value) => Some(*value),

View File

@@ -0,0 +1,483 @@
use crate::{
ActiveSceneSnapshot, BufferedShowControlAdapter, EngineSnapshot, ExternalShowControlAdapter,
ExternalShowControlPort, GlobalControlSnapshot, HostApiPort, HostCommandError, HostSnapshot,
NodeSnapshot, ShowControlPendingState, ShowControlPrimitive, ShowControlPrimitiveOutcome,
StatusEvent, SystemSnapshot, SHOW_CONTROL_V1_VERSION,
};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
io::{self, BufRead, Write},
sync::Arc,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeRequest {
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
pub command: ExternalBridgeCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ExternalBridgeCommand {
ExecutePrimitive { primitive: ShowControlPrimitive },
GetState,
ClearSession,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeResponse {
pub semantic_version: String,
pub request_id: Option<String>,
pub session_id: Option<String>,
pub result: ExternalBridgeResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<ExternalBridgeSessionView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ExternalBridgeResult {
PrimitiveBuffered {
summary: String,
},
CommandAccepted {
generated_at_millis: u64,
summary: String,
},
Snapshot {
snapshot: HostSnapshot,
},
State {
state: ExternalBridgeStateView,
},
SessionCleared {
had_session: bool,
},
Error {
error: ExternalBridgeError,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalBridgeError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeSessionView {
pub session_id: String,
pub pending: ShowControlPendingState,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeStateView {
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub active_scene: ActiveSceneSnapshot,
pub nodes: Vec<NodeSnapshot>,
pub panels: Vec<crate::PanelSnapshot>,
pub recent_events: Vec<StatusEvent>,
}
pub struct ExternalControlBridge {
service: Arc<dyn HostApiPort>,
sessions: BTreeMap<String, BufferedShowControlAdapter>,
}
impl ExternalControlBridge {
pub fn new(service: Arc<dyn HostApiPort>) -> Self {
Self {
service,
sessions: BTreeMap::new(),
}
}
pub fn handle_request(&mut self, request: ExternalBridgeRequest) -> ExternalBridgeResponse {
match self.try_handle_request(request.clone()) {
Ok(response) => response,
Err(error) => {
let session_view = self.session_view(request.session_id.as_deref());
ExternalBridgeResponse::error(
request.request_id,
request.session_id,
error.into(),
session_view,
)
}
}
}
pub fn run_jsonl<R: BufRead, W: Write>(
&mut self,
reader: &mut R,
writer: &mut W,
) -> io::Result<()> {
let mut line = String::new();
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
return Ok(());
}
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<ExternalBridgeRequest>(&line) {
Ok(request) => self.handle_request(request),
Err(error) => ExternalBridgeResponse::error(
None,
None,
ExternalBridgeError::new(
"invalid_bridge_request_json",
format!("bridge request JSON could not be parsed: {error}"),
),
None,
),
};
let payload = serde_json::to_string(&response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
writer.write_all(payload.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
}
}
fn try_handle_request(
&mut self,
request: ExternalBridgeRequest,
) -> Result<ExternalBridgeResponse, HostCommandError> {
let request_id = request.request_id.clone();
let session_id = request.session_id.clone();
match request.command {
ExternalBridgeCommand::ExecutePrimitive { primitive } => {
let outcome = if let Some(session_id) = session_id.as_deref() {
self.sessions
.entry(session_id.to_string())
.or_default()
.apply_primitive(self.service.as_ref(), primitive)?
} else {
self.service.execute_primitive(primitive)?
};
let result = match outcome {
ShowControlPrimitiveOutcome::Buffered { summary } => {
ExternalBridgeResult::PrimitiveBuffered { summary }
}
ShowControlPrimitiveOutcome::Command(outcome) => {
ExternalBridgeResult::CommandAccepted {
generated_at_millis: outcome.generated_at_millis,
summary: outcome.summary,
}
}
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
ExternalBridgeResult::Snapshot { snapshot }
}
};
Ok(ExternalBridgeResponse::success(
request_id,
session_id.clone(),
result,
self.session_view(session_id.as_deref()),
))
}
ExternalBridgeCommand::GetState => {
let snapshot = self.service.snapshot();
Ok(ExternalBridgeResponse::success(
request_id,
session_id.clone(),
ExternalBridgeResult::State {
state: ExternalBridgeStateView::from_snapshot(&snapshot),
},
self.session_view(session_id.as_deref()),
))
}
ExternalBridgeCommand::ClearSession => {
let Some(session_id) = session_id else {
return Err(HostCommandError::new(
"session_id_required",
"clear_session requires a session_id",
));
};
let had_session = self.sessions.remove(&session_id).is_some();
Ok(ExternalBridgeResponse::success(
request_id,
Some(session_id),
ExternalBridgeResult::SessionCleared { had_session },
None,
))
}
}
}
fn session_view(&self, session_id: Option<&str>) -> Option<ExternalBridgeSessionView> {
let session_id = session_id?;
let adapter = self.sessions.get(session_id)?;
Some(ExternalBridgeSessionView {
session_id: session_id.to_string(),
pending: adapter.session().pending_state(),
})
}
}
impl ExternalBridgeResponse {
fn success(
request_id: Option<String>,
session_id: Option<String>,
result: ExternalBridgeResult,
session: Option<ExternalBridgeSessionView>,
) -> Self {
Self {
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
request_id,
session_id,
result,
session,
}
}
fn error(
request_id: Option<String>,
session_id: Option<String>,
error: ExternalBridgeError,
session: Option<ExternalBridgeSessionView>,
) -> Self {
Self {
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
request_id,
session_id,
result: ExternalBridgeResult::Error { error },
session,
}
}
}
impl ExternalBridgeError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl From<HostCommandError> for ExternalBridgeError {
fn from(value: HostCommandError) -> Self {
Self {
code: value.code,
message: value.message,
}
}
}
impl ExternalBridgeStateView {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
generated_at_millis: snapshot.generated_at_millis,
system: snapshot.system.clone(),
global: snapshot.global.clone(),
engine: snapshot.engine.clone(),
active_scene: snapshot.active_scene.clone(),
nodes: snapshot.nodes.clone(),
panels: snapshot.panels.clone(),
recent_events: snapshot.recent_events.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{HostApiPort, SimulationHostService};
use infinity_config::{PanelPosition, ProjectConfig};
use std::sync::Arc;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
fn bridge() -> ExternalControlBridge {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
ExternalControlBridge::new(service)
}
#[test]
fn bridge_preserves_session_pending_state_for_staged_flows() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("r1".to_string()),
session_id: Some("desk-a".to_string()),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
},
},
});
assert_eq!(response.semantic_version, SHOW_CONTROL_V1_VERSION);
match response.result {
ExternalBridgeResult::PrimitiveBuffered { summary } => {
assert!(summary.contains("pattern staged: noise"));
}
other => panic!("expected buffered result, got {other:?}"),
}
assert_eq!(
response
.session
.expect("session view")
.pending
.pattern_id
.as_deref(),
Some("noise")
);
}
#[test]
fn bridge_passes_through_session_required_error_unchanged() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("r1".to_string()),
session_id: None,
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::TriggerTransition,
},
});
match response.result {
ExternalBridgeResult::Error { error } => {
assert_eq!(error.code, "show_control_session_required");
}
other => panic!("expected error result, got {other:?}"),
}
}
#[test]
fn bridge_get_state_returns_projection_without_preview() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("state-1".to_string()),
session_id: None,
command: ExternalBridgeCommand::GetState,
});
match response.result {
ExternalBridgeResult::State { state } => {
assert_eq!(state.system.project_name, "Infinity Vis");
assert_eq!(state.nodes.len(), 6);
assert_eq!(state.panels.len(), 18);
}
other => panic!("expected state result, got {other:?}"),
}
}
#[test]
fn bridge_clear_session_requires_session_id() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("clear-1".to_string()),
session_id: None,
command: ExternalBridgeCommand::ClearSession,
});
match response.result {
ExternalBridgeResult::Error { error } => {
assert_eq!(error.code, "session_id_required");
}
other => panic!("expected error result, got {other:?}"),
}
}
#[test]
fn bridge_can_upsert_group_and_commit_staged_transition() {
let mut bridge = bridge();
let session_id = Some("desk-a".to_string());
let requests = [
ExternalBridgeRequest {
request_id: Some("group".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::UpsertGroup {
group_id: "focus_pair".to_string(),
tags: vec!["runtime".to_string()],
members: vec![
crate::PanelTarget {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Top,
},
crate::PanelTarget {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Middle,
},
],
overwrite: true,
},
},
},
ExternalBridgeRequest {
request_id: Some("param".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetGroupParameter {
group_id: Some("focus_pair".to_string()),
key: "grain".to_string(),
value: crate::SceneParameterValue::Scalar(0.88),
},
},
},
ExternalBridgeRequest {
request_id: Some("style".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetTransitionStyle {
style: crate::SceneTransitionStyle::Chase,
duration_ms: Some(420),
},
},
},
ExternalBridgeRequest {
request_id: Some("pattern".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
},
},
},
ExternalBridgeRequest {
request_id: Some("trigger".to_string()),
session_id,
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::TriggerTransition,
},
},
];
for request in requests {
let _ = bridge.handle_request(request);
}
let state = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("state".to_string()),
session_id: None,
command: ExternalBridgeCommand::GetState,
});
match state.result {
ExternalBridgeResult::State { state } => {
assert_eq!(state.global.selected_group.as_deref(), Some("focus_pair"));
assert_eq!(state.global.transition_duration_ms, 420);
assert_eq!(state.active_scene.pattern_id, "noise");
}
other => panic!("expected state result, got {other:?}"),
}
}
}

View File

@@ -5,6 +5,8 @@ use crate::{
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const SHOW_CONTROL_V1_VERSION: &str = "v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
pub enum ShowControlPrimitive {
@@ -80,6 +82,16 @@ pub enum ShowControlPrimitiveOutcome {
Snapshot(HostSnapshot),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ShowControlPendingState {
pub pattern_id: Option<String>,
pub has_group_target: bool,
pub group_id: Option<String>,
pub parameters: BTreeMap<String, SceneParameterValue>,
pub transition_style: Option<SceneTransitionStyle>,
pub transition_duration_ms: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShowControlSession {
pending_pattern_id: Option<String>,
@@ -246,6 +258,25 @@ impl ShowControlSession {
self.pending_transition_style = None;
self.pending_transition_duration_ms = None;
}
pub fn pending_state(&self) -> ShowControlPendingState {
ShowControlPendingState {
pattern_id: self.pending_pattern_id.clone(),
has_group_target: self.pending_group_id.is_some(),
group_id: self.pending_group_id.clone().flatten(),
parameters: self.pending_parameters.clone(),
transition_style: self.pending_transition_style,
transition_duration_ms: self.pending_transition_duration_ms,
}
}
pub fn has_pending_transition(&self) -> bool {
self.pending_pattern_id.is_some()
|| self.pending_group_id.is_some()
|| !self.pending_parameters.is_empty()
|| self.pending_transition_style.is_some()
|| self.pending_transition_duration_ms.is_some()
}
}
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
@@ -374,6 +405,10 @@ impl<P: HostApiPort> ReferenceShowControlClient<P> {
self.adapter.session()
}
pub fn pending_state(&self) -> ShowControlPendingState {
self.adapter.session().pending_state()
}
pub fn apply_primitive(
&mut self,
primitive: ShowControlPrimitive,

View File

@@ -1,4 +1,5 @@
pub mod control;
pub mod external_bridge;
pub mod external_control;
pub mod runtime;
pub mod scene;
@@ -6,6 +7,7 @@ pub mod show_store;
pub mod simulation;
pub use control::*;
pub use external_bridge::*;
pub use external_control::*;
pub use runtime::*;
pub use scene::*;

View File

@@ -1,7 +1,7 @@
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};
use infinity_host::{ExternalControlBridge, HostApiPort, RealtimeEngine, SimulationHostService};
use std::{io, path::PathBuf, process::ExitCode, sync::Arc};
#[derive(Debug, Parser)]
#[command(
@@ -32,6 +32,12 @@ enum Command {
#[arg(long)]
config: PathBuf,
},
ExternalControlBridge {
#[arg(long)]
config: PathBuf,
#[arg(long, default_value = "data/runtime_state.json")]
runtime_state: PathBuf,
},
OpenValidationPoints,
}
@@ -56,6 +62,10 @@ fn main() -> ExitCode {
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::ExternalControlBridge {
config,
runtime_state,
} => external_control_bridge_command(config, runtime_state),
Command::OpenValidationPoints => {
print_open_validation_points();
ExitCode::SUCCESS
@@ -147,6 +157,47 @@ fn snapshot_command(config: PathBuf) -> ExitCode {
}
}
fn external_control_bridge_command(config: PathBuf, runtime_state: 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 =
match SimulationHostService::try_spawn_shared_with_persistence(project, &runtime_state) {
Ok(service) => service,
Err(error) => {
eprintln!(
"Failed to initialize external control bridge with runtime state '{}': {error}",
runtime_state.display()
);
return ExitCode::FAILURE;
}
};
println!(
"Infinity Vis external control bridge listening on stdin/stdout JSONL with runtime state {}",
runtime_state.display()
);
let service: Arc<dyn HostApiPort> = service;
let mut bridge = ExternalControlBridge::new(service);
let stdin = io::stdin();
let stdout = io::stdout();
let mut reader = stdin.lock();
let mut writer = stdout.lock();
match bridge.run_jsonl(&mut reader, &mut writer) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("External control bridge failed: {error}");
ExitCode::FAILURE
}
}
}
fn print_open_validation_points() {
for line in [
"Pending hardware validation gates:",

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
OutputBackendMode, PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{PatternRegistry, SceneRuntime},
};
use infinity_config::{PanelPosition, ProjectConfig};
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -59,16 +59,64 @@ impl Default for PersistedGlobalState {
Self {
blackout: false,
master_brightness: 0.20,
transition_duration_ms: 150,
transition_duration_ms: 2_000,
transition_style: SceneTransitionStyle::Crossfade,
}
}
}
fn default_output_fps() -> u16 {
40
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersistedNodeState {
pub node_id: String,
pub reserved_ip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersistedPanelState {
pub target: PanelTarget,
pub physical_output_name: String,
pub driver_kind: DriverKind,
pub driver_reference: String,
pub led_count: u16,
pub direction: LedDirection,
pub color_order: ColorOrder,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersistedTechnicalState {
pub backend_mode: OutputBackendMode,
pub output_enabled: bool,
#[serde(default = "default_output_fps")]
pub output_fps: u16,
#[serde(default)]
pub nodes: Vec<PersistedNodeState>,
#[serde(default)]
pub panels: Vec<PersistedPanelState>,
}
impl Default for PersistedTechnicalState {
fn default() -> Self {
Self {
backend_mode: OutputBackendMode::PreviewOnly,
output_enabled: false,
output_fps: default_output_fps(),
nodes: Vec::new(),
panels: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PersistedRuntimeState {
pub active_scene: Option<SceneRuntime>,
pub global: PersistedGlobalState,
#[serde(default)]
pub technical: PersistedTechnicalState,
pub user_presets: Vec<StoredPreset>,
pub user_groups: Vec<StoredGroup>,
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
@@ -467,6 +515,36 @@ impl ShowStore {
Ok(())
}
pub fn delete_preset(&mut self, preset_id: &str) -> Result<(), HostCommandError> {
if preset_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_preset_id",
"preset_id must not be empty",
));
}
let preset_index = self
.presets
.iter()
.position(|preset| preset.preset_id == preset_id)
.ok_or_else(|| {
HostCommandError::new(
"unknown_preset",
format!("preset '{preset_id}' does not exist"),
)
})?;
if self.presets[preset_index].source != CatalogSource::RuntimeUser {
return Err(HostCommandError::new(
"preset_delete_forbidden",
format!("preset '{preset_id}' is built-in and cannot be deleted"),
));
}
self.presets.remove(preset_index);
Ok(())
}
pub fn save_creative_snapshot(
&mut self,
snapshot_id: &str,
@@ -554,10 +632,12 @@ impl ShowStore {
&self,
active_scene: &SceneRuntime,
global: PersistedGlobalState,
technical: PersistedTechnicalState,
) -> PersistedRuntimeState {
PersistedRuntimeState {
active_scene: Some(active_scene.clone()),
global,
technical,
user_presets: self
.presets
.iter()
@@ -663,6 +743,45 @@ mod tests {
assert!(store.recall_creative_snapshot("variant_a").is_some());
}
#[test]
fn runtime_presets_can_be_deleted_but_builtins_cannot() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"noise",
None,
Some("top_panels".to_string()),
31,
vec!["#AA8844".to_string()],
false,
);
store
.save_preset_from_scene(
"runtime_delete_me",
&scene,
210,
SceneTransitionStyle::Crossfade,
false,
)
.expect("runtime preset save should succeed");
store
.delete_preset("runtime_delete_me")
.expect("runtime preset delete should succeed");
assert!(store.scene_from_preset_id("runtime_delete_me").is_none());
let built_in_preset_id = store
.catalog(&registry)
.presets
.iter()
.find(|preset| preset.source == CatalogSource::BuiltIn)
.map(|preset| preset.preset_id.clone())
.expect("sample project should contain built-in presets");
let delete_error = store
.delete_preset(&built_in_preset_id)
.expect_err("built-in presets should not be deletable");
assert_eq!(delete_error.code, "preset_delete_forbidden");
}
#[test]
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
let registry = PatternRegistry::new();
@@ -701,6 +820,7 @@ mod tests {
transition_duration_ms: 220,
transition_style: SceneTransitionStyle::Chase,
},
PersistedTechnicalState::default(),
);
storage.save(&runtime).expect("save should work");
let loaded = storage.load().expect("load should work");

View File

@@ -2,15 +2,19 @@ use crate::{
control::{
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
HostCommand, HostCommandError, HostSnapshot, NodeConnectionState, NodeSnapshot,
PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource, SceneTransitionStyle,
StatusEvent, StatusEventKind, SystemSnapshot, HOST_API_VERSION,
OutputBackendMode, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot, TechnicalSnapshot,
HOST_API_VERSION,
},
runtime::TickSchedule,
scene::{
apply_group_gate, blackout_preview, blend_previews, panel_membership_key,
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
},
show_store::{PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError},
show_store::{
PersistedGlobalState, PersistedNodeState, PersistedPanelState, PersistedTechnicalState,
RuntimeStateStorage, ShowStore, ShowStoreError,
},
};
use infinity_config::{PanelPosition, ProjectConfig};
use std::{
@@ -43,6 +47,7 @@ struct SimulationState {
schedule: TickSchedule,
current_scene: SceneRuntime,
active_transition: Option<TransitionRuntime>,
technical_state: PersistedTechnicalState,
snapshot: HostSnapshot,
}
@@ -135,12 +140,17 @@ impl SimulationState {
let persisted_runtime = runtime_load.runtime;
let restored_scene = persisted_runtime.active_scene.clone();
let restored_global = persisted_runtime.global.clone();
let restored_technical = persisted_runtime.technical.clone();
show_store.apply_persisted(persisted_runtime);
let group_members = show_store.group_members_map();
let schedule = TickSchedule::default();
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(&registry));
let catalog = show_store.catalog(&registry);
let available_patterns = show_store.available_patterns(&registry);
let initial_offline_status = offline_status_message(
restored_technical.backend_mode,
restored_technical.output_enabled,
);
let nodes = project
.topology
.nodes
@@ -149,9 +159,9 @@ impl SimulationState {
node_id: node.node_id.clone(),
display_name: node.display_name.clone(),
reserved_ip: node.network.reserved_ip.clone(),
connection: NodeConnectionState::Online,
last_contact_ms: 10,
error_status: None,
connection: NodeConnectionState::Offline,
last_contact_ms: 0,
error_status: Some(initial_offline_status.clone()),
panel_count: node.outputs.len(),
})
.collect::<Vec<_>>();
@@ -160,21 +170,23 @@ impl SimulationState {
.nodes
.iter()
.flat_map(|node| {
let initial_offline_status = initial_offline_status.clone();
node.outputs.iter().map(move |output| PanelSnapshot {
target: PanelTarget {
node_id: node.node_id.clone(),
panel_position: output.panel_position.clone(),
},
physical_output_name: output.physical_output_name.clone(),
driver_kind: output.driver_channel.kind.clone(),
driver_reference: output.driver_channel.reference.clone(),
led_count: output.led_count,
direction: output.direction.clone(),
color_order: output.color_order.clone(),
enabled: output.enabled,
validation_state: output.validation_state.clone(),
connection: NodeConnectionState::Online,
connection: NodeConnectionState::Offline,
last_test_trigger_ms: None,
error_status: None,
error_status: Some(initial_offline_status.clone()),
})
})
.collect::<Vec<_>>();
@@ -193,15 +205,22 @@ impl SimulationState {
schedule: schedule.clone(),
current_scene,
active_transition: None,
technical_state: restored_technical.clone(),
snapshot: HostSnapshot {
api_version: HOST_API_VERSION,
backend_label: "simulation-core".to_string(),
backend_label: "preview-only simulation".to_string(),
generated_at_millis: 0,
system: SystemSnapshot {
project_name: project.metadata.project_name.clone(),
schema_version: project.metadata.schema_version,
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
},
technical: TechnicalSnapshot {
backend_mode: restored_technical.backend_mode,
output_enabled: restored_technical.output_enabled,
output_fps: restored_technical.output_fps,
live_status: "preview only - live output disabled".to_string(),
},
global: GlobalControlSnapshot {
blackout: restored_global.blackout,
master_brightness: restored_global.master_brightness,
@@ -236,13 +255,28 @@ impl SimulationState {
recent_events: Vec::new(),
},
};
state.apply_technical_state(&restored_technical);
state.refresh_technical_status();
state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone();
state.snapshot.global.selected_group = state.current_scene.target_group.clone();
state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene);
let (startup_code, startup_message) = match state.technical_state.backend_mode {
OutputBackendMode::PreviewOnly => (
"preview_only_mode",
"preview-only simulation active; no live nodes connected".to_string(),
),
OutputBackendMode::DdpWled => (
"output_backend_mode",
format!(
"ddp (wled) mode active; {}",
state.snapshot.technical.live_status
),
),
};
state.push_event(
StatusEventKind::Info,
None,
"simulation host service started".to_string(),
Some(startup_code.to_string()),
startup_message,
);
if state.runtime_storage.is_some() {
let (code, message) = if runtime_loaded_from_disk {
@@ -298,6 +332,145 @@ impl SimulationState {
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
let mut should_persist = false;
let summary = match command {
HostCommand::SetOutputBackendMode(mode) => {
self.technical_state.backend_mode = mode;
self.refresh_technical_status();
should_persist = true;
let summary = format!("output backend mode set to {}", mode.display_label());
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetLiveOutputEnabled(enabled) => {
self.technical_state.output_enabled = enabled;
self.refresh_technical_status();
should_persist = true;
let summary = if enabled {
"live output enabled".to_string()
} else {
"live output disabled".to_string()
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetOutputFps(output_fps) => {
if !(1..=240).contains(&output_fps) {
let error = HostCommandError::new(
"invalid_output_fps",
format!("output_fps must be between 1 and 240, got {output_fps}"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
}
self.technical_state.output_fps = output_fps;
self.refresh_technical_status();
should_persist = true;
let summary = format!("output fps set to {output_fps}");
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetNodeReservedIp {
node_id,
reserved_ip,
} => {
let Some(node) = self
.snapshot
.nodes
.iter_mut()
.find(|node| node.node_id == node_id)
else {
let error = HostCommandError::new(
"unknown_node",
format!("node '{node_id}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
node.reserved_ip = reserved_ip.clone();
replace_or_append_node_state(
&mut self.technical_state.nodes,
PersistedNodeState {
node_id: node_id.clone(),
reserved_ip: reserved_ip.clone(),
},
);
should_persist = true;
let summary = format!(
"node target updated: {} -> {}",
node_id,
reserved_ip.as_deref().unwrap_or("unassigned")
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::UpdatePanelMapping {
target,
physical_output_name,
driver_kind,
driver_reference,
led_count,
direction,
color_order,
enabled,
} => {
let Some(panel) = self
.snapshot
.panels
.iter_mut()
.find(|panel| panel.target == target)
else {
let error = HostCommandError::new(
"unknown_panel",
format!(
"panel '{}:{}' does not exist",
target.node_id,
panel_position_label(&target.panel_position)
),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
panel.physical_output_name = physical_output_name.clone();
panel.driver_kind = driver_kind.clone();
panel.driver_reference = driver_reference.clone();
panel.led_count = led_count;
panel.direction = direction.clone();
panel.color_order = color_order.clone();
panel.enabled = enabled;
replace_or_append_panel_state(
&mut self.technical_state.panels,
PersistedPanelState {
target: target.clone(),
physical_output_name,
driver_kind,
driver_reference,
led_count,
direction,
color_order,
enabled,
},
);
self.refresh_technical_status();
should_persist = true;
let summary = format!(
"panel mapping updated: {}:{}",
target.node_id,
panel_position_label(&target.panel_position)
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetBlackout(enabled) => {
self.snapshot.global.blackout = enabled;
should_persist = true;
@@ -329,14 +502,9 @@ impl SimulationState {
false,
);
self.next_seed += 1;
if let Some(speed) = self.current_scene.parameters.get("speed").cloned() {
for (key, value) in self.current_scene.parameters.clone() {
self.registry
.set_scene_parameter(&mut new_scene, "speed", speed);
}
if let Some(intensity) = self.current_scene.parameters.get("intensity").cloned() {
self.registry
.set_scene_parameter(&mut new_scene, "intensity", intensity);
.set_scene_parameter(&mut new_scene, &key, value);
}
let duration_ms = self.snapshot.global.transition_duration_ms;
@@ -479,6 +647,18 @@ impl SimulationState {
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::DeletePreset { preset_id } => {
self.show_store.delete_preset(&preset_id)?;
if self.current_scene.preset_id.as_deref() == Some(preset_id.as_str()) {
self.current_scene.preset_id = None;
}
self.rebuild_catalog();
self.group_members = self.show_store.group_members_map();
should_persist = true;
let summary = format!("preset deleted: {preset_id}");
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
@@ -596,43 +776,64 @@ impl SimulationState {
}
}
fn update_node_states(&mut self) {
let previous_states: BTreeMap<_, _> = self
.snapshot
.nodes
.iter()
.map(|node| (node.node_id.clone(), node.connection))
.collect();
let mut transition_messages = Vec::new();
for (index, node) in self.snapshot.nodes.iter_mut().enumerate() {
let connection = simulated_connection_state(index, self.tick_count);
node.connection = connection;
node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection);
node.error_status = simulated_error_status(connection);
if previous_states
.get(&node.node_id)
.copied()
.unwrap_or(NodeConnectionState::Offline)
!= connection
fn apply_technical_state(&mut self, technical: &PersistedTechnicalState) {
self.technical_state = technical.clone();
for node_state in &technical.nodes {
if let Some(node) = self
.snapshot
.nodes
.iter_mut()
.find(|node| node.node_id == node_state.node_id)
{
transition_messages.push(format!(
"{} is now {}",
node.display_name,
connection.label()
));
node.reserved_ip = node_state.reserved_ip.clone();
}
}
for message in transition_messages {
self.push_event(
StatusEventKind::Warning,
Some("node_connection_state".to_string()),
message,
);
for panel_state in &technical.panels {
if let Some(panel) = self
.snapshot
.panels
.iter_mut()
.find(|panel| panel.target == panel_state.target)
{
panel.physical_output_name = panel_state.physical_output_name.clone();
panel.driver_kind = panel_state.driver_kind.clone();
panel.driver_reference = panel_state.driver_reference.clone();
panel.led_count = panel_state.led_count;
panel.direction = panel_state.direction.clone();
panel.color_order = panel_state.color_order.clone();
panel.enabled = panel_state.enabled;
}
}
}
fn refresh_technical_status(&mut self) {
self.snapshot.backend_label = technical_backend_label(self.technical_state.backend_mode);
self.snapshot.technical = TechnicalSnapshot {
backend_mode: self.technical_state.backend_mode,
output_enabled: self.technical_state.output_enabled,
output_fps: self.technical_state.output_fps,
live_status: technical_live_status(
self.technical_state.backend_mode,
self.technical_state.output_enabled,
&self.snapshot.nodes,
),
};
}
fn update_node_states(&mut self) {
let elapsed_ms = self.elapsed_millis();
let offline_status = offline_status_message(
self.technical_state.backend_mode,
self.technical_state.output_enabled,
);
for node in &mut self.snapshot.nodes {
node.connection = NodeConnectionState::Offline;
node.last_contact_ms = elapsed_ms;
node.error_status = Some(offline_status.clone());
}
self.refresh_technical_status();
}
fn update_panel_states(&mut self) {
let node_states: BTreeMap<_, _> = self
.snapshot
@@ -652,7 +853,10 @@ impl SimulationState {
panel.error_status = match (node_error, panel.enabled) {
(_, false) => Some("output disabled".to_string()),
(Some(error), _) => Some(error.clone()),
(None, true) => None,
(None, true) => Some(offline_status_message(
self.technical_state.backend_mode,
self.technical_state.output_enabled,
)),
};
}
}
@@ -679,7 +883,7 @@ impl SimulationState {
fn render_preview_for_panel(
&self,
panel: &PanelSnapshot,
panel_index: usize,
_panel_index: usize,
elapsed_ms: u64,
) -> (RenderedPreview, PreviewSource) {
if self.snapshot.global.blackout || self.current_scene.blackout || !panel.enabled {
@@ -697,16 +901,38 @@ impl SimulationState {
}
}
let panel_count = self.snapshot.panels.len();
let current =
self.registry
.render_preview(&self.current_scene, panel_index, panel_count, elapsed_ms);
let panel_row = match panel.target.panel_position {
PanelPosition::Top => 0,
PanelPosition::Middle => 1,
PanelPosition::Bottom => 2,
};
let panel_col = self
.snapshot
.nodes
.iter()
.position(|node| node.node_id == panel.target.node_id)
.unwrap_or(0);
let panel_rows = 3;
let panel_cols = self.snapshot.nodes.len().max(1);
let led_count = panel.led_count as usize;
let current = self.registry.render_preview(
&self.current_scene,
panel_row,
panel_col,
panel_rows,
panel_cols,
led_count,
elapsed_ms,
);
let mut source = PreviewSource::Scene;
let mut preview = if let Some(transition) = &self.active_transition {
let from = self.registry.render_preview(
&transition.from_scene,
panel_index,
panel_count,
panel_row,
panel_col,
panel_rows,
panel_cols,
led_count,
elapsed_ms,
);
let progress = self
@@ -757,9 +983,11 @@ impl SimulationState {
};
let storage_path = storage.path().to_path_buf();
let runtime_state = self
.show_store
.persisted_runtime(&self.current_scene, self.persisted_global_state());
let runtime_state = self.show_store.persisted_runtime(
&self.current_scene,
self.persisted_global_state(),
self.technical_state.clone(),
);
if let Err(error) = storage.save(&runtime_state) {
let command_error = HostCommandError::new(
"persist_failed",
@@ -806,6 +1034,12 @@ fn unavailable_snapshot() -> HostSnapshot {
schema_version: 0,
topology_label: "unknown".to_string(),
},
technical: TechnicalSnapshot {
backend_mode: OutputBackendMode::PreviewOnly,
output_enabled: false,
output_fps: 40,
live_status: "service unavailable".to_string(),
},
global: GlobalControlSnapshot {
blackout: true,
master_brightness: 0.0,
@@ -851,46 +1085,6 @@ fn unavailable_snapshot() -> HostSnapshot {
}
}
fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState {
match index {
4 => {
if tick_count % 24 < 8 {
NodeConnectionState::Degraded
} else {
NodeConnectionState::Online
}
}
5 => {
if tick_count % 32 < 7 {
NodeConnectionState::Offline
} else {
NodeConnectionState::Online
}
}
_ => NodeConnectionState::Online,
}
}
fn simulated_last_contact_ms(
index: usize,
tick_count: u64,
connection: NodeConnectionState,
) -> u64 {
match connection {
NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6),
NodeConnectionState::Degraded => 180 + (tick_count % 90),
NodeConnectionState::Offline => 2_500 + (tick_count % 700),
}
}
fn simulated_error_status(connection: NodeConnectionState) -> Option<String> {
match connection {
NodeConnectionState::Online => None,
NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()),
NodeConnectionState::Offline => Some("awaiting reconnect".to_string()),
}
}
fn panel_position_label(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
@@ -899,6 +1093,65 @@ fn panel_position_label(position: &PanelPosition) -> &'static str {
}
}
fn technical_backend_label(mode: OutputBackendMode) -> String {
match mode {
OutputBackendMode::PreviewOnly => "preview-only simulation".to_string(),
OutputBackendMode::DdpWled => "ddp (wled) simulation".to_string(),
}
}
fn offline_status_message(mode: OutputBackendMode, output_enabled: bool) -> String {
match mode {
OutputBackendMode::PreviewOnly => "preview only - live output disabled".to_string(),
OutputBackendMode::DdpWled if output_enabled => {
"ddp (wled) output enabled - no live client connected".to_string()
}
OutputBackendMode::DdpWled => "ddp (wled) selected - output disabled".to_string(),
}
}
fn technical_live_status(
mode: OutputBackendMode,
output_enabled: bool,
nodes: &[NodeSnapshot],
) -> String {
let online_count = nodes
.iter()
.filter(|node| node.connection == NodeConnectionState::Online)
.count();
match mode {
OutputBackendMode::PreviewOnly => "Preview Only active - no live output".to_string(),
OutputBackendMode::DdpWled if !output_enabled => {
"DDP (WLED) selected - output disabled".to_string()
}
OutputBackendMode::DdpWled => {
format!(
"DDP (WLED) armed - {online_count}/{} nodes online",
nodes.len()
)
}
}
}
fn replace_or_append_node_state(states: &mut Vec<PersistedNodeState>, next: PersistedNodeState) {
if let Some(existing) = states
.iter_mut()
.find(|state| state.node_id == next.node_id)
{
*existing = next;
} else {
states.push(next);
}
}
fn replace_or_append_panel_state(states: &mut Vec<PersistedPanelState>, next: PersistedPanelState) {
if let Some(existing) = states.iter_mut().find(|state| state.target == next.target) {
*existing = next;
} else {
states.push(next);
}
}
fn scale_preview(mut preview: RenderedPreview, factor: f32) -> RenderedPreview {
let factor = factor.clamp(0.0, 1.0);
preview.representative_color_hex = scale_hex_color(&preview.representative_color_hex, factor);
@@ -946,6 +1199,19 @@ mod tests {
.any(|preset| preset.preset_id == "amber_chase_top"));
assert_eq!(snapshot.preview.panels.len(), 18);
assert_eq!(snapshot.nodes.len(), 6);
assert_eq!(snapshot.backend_label, "preview-only simulation");
assert!(snapshot
.nodes
.iter()
.all(|node| node.connection == NodeConnectionState::Offline));
assert!(snapshot
.panels
.iter()
.all(|panel| panel.connection == NodeConnectionState::Offline));
assert!(snapshot.recent_events.iter().any(|event| {
event.kind == StatusEventKind::Info
&& event.code.as_deref() == Some("preview_only_mode")
}));
}
#[test]
@@ -1020,4 +1286,54 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn simulation_ticks_do_not_emit_fake_node_connection_events() {
let mut state = SimulationState::try_new(sample_project(), None).expect("simulation state");
for _ in 0..8 {
state.simulate_tick();
}
assert!(state
.snapshot
.nodes
.iter()
.all(|node| node.connection == NodeConnectionState::Offline));
assert!(state
.snapshot
.panels
.iter()
.all(|panel| panel.connection == NodeConnectionState::Offline));
assert!(state.snapshot.recent_events.iter().all(|event| {
event
.code
.as_deref()
.map(|code| !code.starts_with("node_connection_"))
.unwrap_or(true)
}));
}
#[test]
fn pattern_switch_preserves_edit_parameters_like_the_legacy_tool() {
let service = SimulationHostService::new(sample_project());
let _ = service.send_command(HostCommand::SetSceneParameter {
key: "fade".to_string(),
value: SceneParameterValue::Scalar(0.62),
});
let _ = service.send_command(HostCommand::SetSceneParameter {
key: "tempo_multiplier".to_string(),
value: SceneParameterValue::Scalar(1.75),
});
let _ = service.send_command(HostCommand::SelectPattern("scan".to_string()));
let snapshot = service.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "scan");
assert!(snapshot.active_scene.parameters.iter().any(|parameter| {
parameter.key == "fade" && parameter.value == SceneParameterValue::Scalar(0.62)
}));
assert!(snapshot.active_scene.parameters.iter().any(|parameter| {
parameter.key == "tempo_multiplier"
&& parameter.value == SceneParameterValue::Scalar(1.75)
}));
}
}

View File

@@ -0,0 +1,96 @@
{
"name": "staged pattern plus transition commit",
"steps": [
{
"request": {
"request_id": "style",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "chase",
"duration_ms": 480
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged",
"session": {
"parameter_keys": [],
"transition_style": "chase",
"transition_duration_ms": 480
}
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"parameter_keys": [],
"transition_style": "chase",
"transition_duration_ms": 480
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise",
"session": {
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"active_pattern_id": "noise",
"transition_style": "chase",
"transition_duration_ms": 480
}
}
}
]
}

View File

@@ -0,0 +1,160 @@
{
"name": "group update plus parameter change plus commit",
"steps": [
{
"request": {
"request_id": "group",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "upsert_group",
"payload": {
"group_id": "focus_pair",
"tags": ["runtime", "focus"],
"members": [
{ "node_id": "node-01", "panel_position": "top" },
{ "node_id": "node-01", "panel_position": "middle" }
],
"overwrite": true
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "group updated: focus_pair"
}
},
{
"request": {
"request_id": "param",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_group_parameter",
"payload": {
"group_id": "focus_pair",
"key": "grain",
"value": { "kind": "scalar", "value": 0.81 }
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "group parameter staged: grain",
"session": {
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "style",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "chase",
"duration_ms": 420
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged",
"session": {
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"],
"transition_style": "chase",
"transition_duration_ms": 420
}
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"],
"transition_style": "chase",
"transition_duration_ms": 420
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise on focus_pair",
"session": {
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"selected_group": "focus_pair",
"active_pattern_id": "noise",
"active_scene_group": "focus_pair",
"transition_style": "chase",
"transition_duration_ms": 420,
"scalar_parameters": [
{ "key": "grain", "value": 0.81 }
]
}
}
}
]
}

View File

@@ -0,0 +1,103 @@
{
"name": "preset recall during running transition",
"steps": [
{
"request": {
"request_id": "style",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "crossfade",
"duration_ms": 1600
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged"
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise"
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise"
}
},
{
"request": {
"request_id": "recall",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "recall_preset",
"payload": {
"preset_id": "ocean_gradient"
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "preset recalled: ocean_gradient"
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"preset_id": "ocean_gradient",
"active_pattern_id": "gradient",
"active_transition_present": true,
"event_message_contains": "preset recalled: ocean_gradient"
}
}
}
]
}

View File

@@ -0,0 +1,123 @@
{
"name": "blackout during staged session",
"steps": [
{
"request": {
"request_id": "pattern",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "param",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_group_parameter",
"payload": {
"group_id": "bottom_panels",
"key": "grain",
"value": { "kind": "scalar", "value": 0.74 }
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "group parameter staged: grain",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "bottom_panels",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "blackout",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "blackout",
"payload": {
"enabled": true
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "blackout enabled",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "bottom_panels",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise on bottom_panels"
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"blackout": true,
"active_pattern_id": "noise",
"active_scene_group": "bottom_panels",
"scalar_parameters": [
{ "key": "grain", "value": 0.74 }
]
}
}
}
]
}

View File

@@ -0,0 +1,25 @@
{
"name": "stateless bridge path rejects staged primitive",
"steps": [
{
"request": {
"request_id": "reject",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "error",
"error_code": "show_control_session_required"
}
}
]
}

View File

@@ -0,0 +1,307 @@
use infinity_config::ProjectConfig;
use infinity_host::{
ExternalBridgeRequest, ExternalBridgeResponse, ExternalBridgeResult, ExternalBridgeSessionView,
ExternalBridgeStateView, ExternalControlBridge, HostApiPort, SceneParameterValue,
SimulationHostService,
};
use serde::Deserialize;
use std::{fs, path::PathBuf, sync::Arc};
#[derive(Debug, Deserialize)]
struct GoldenTrace {
name: String,
steps: Vec<GoldenStep>,
}
#[derive(Debug, Deserialize)]
struct GoldenStep {
request: ExternalBridgeRequest,
expect: GoldenExpectation,
}
#[derive(Debug, Deserialize)]
struct GoldenExpectation {
result_type: String,
#[serde(default)]
summary_contains: Option<String>,
#[serde(default)]
error_code: Option<String>,
#[serde(default)]
session: Option<ExpectedSession>,
#[serde(default)]
state: Option<ExpectedState>,
}
#[derive(Debug, Deserialize)]
struct ExpectedSession {
#[serde(default)]
pattern_id: Option<String>,
#[serde(default)]
has_group_target: Option<bool>,
#[serde(default)]
group_id: Option<String>,
#[serde(default)]
parameter_keys: Vec<String>,
#[serde(default)]
transition_style: Option<String>,
#[serde(default)]
transition_duration_ms: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ExpectedState {
#[serde(default)]
blackout: Option<bool>,
#[serde(default)]
selected_group: Option<String>,
#[serde(default)]
active_pattern_id: Option<String>,
#[serde(default)]
preset_id: Option<String>,
#[serde(default)]
active_scene_group: Option<String>,
#[serde(default)]
transition_style: Option<String>,
#[serde(default)]
transition_duration_ms: Option<u32>,
#[serde(default)]
active_transition_present: Option<bool>,
#[serde(default)]
event_message_contains: Option<String>,
#[serde(default)]
scalar_parameters: Vec<ExpectedScalarParameter>,
}
#[derive(Debug, Deserialize)]
struct ExpectedScalarParameter {
key: String,
value: f32,
}
#[test]
fn show_control_v1_golden_traces_replay_cleanly() {
for path in golden_trace_paths() {
let fixture = fs::read_to_string(&path).expect("golden trace fixture must be readable");
let trace: GoldenTrace =
serde_json::from_str(&fixture).expect("golden trace fixture must parse");
run_trace(&trace, &path);
}
}
fn run_trace(trace: &GoldenTrace, path: &PathBuf) {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
let mut bridge = ExternalControlBridge::new(service);
for (index, step) in trace.steps.iter().enumerate() {
let response = bridge.handle_request(step.request.clone());
assert_response(trace, path, index, &response, &step.expect);
}
}
fn assert_response(
trace: &GoldenTrace,
path: &PathBuf,
step_index: usize,
response: &ExternalBridgeResponse,
expect: &GoldenExpectation,
) {
let context = format!(
"{} step {} ({})",
path.display(),
step_index + 1,
trace.name
);
match expect.result_type.as_str() {
"primitive_buffered" => match &response.result {
ExternalBridgeResult::PrimitiveBuffered { summary } => {
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
}
other => panic!("{context}: expected primitive_buffered, got {other:?}"),
},
"command_accepted" => match &response.result {
ExternalBridgeResult::CommandAccepted { summary, .. } => {
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
}
other => panic!("{context}: expected command_accepted, got {other:?}"),
},
"state" => match &response.result {
ExternalBridgeResult::State { state } => {
assert_state(state, expect.state.as_ref(), &context);
}
other => panic!("{context}: expected state, got {other:?}"),
},
"error" => match &response.result {
ExternalBridgeResult::Error { error } => {
let expected_code = expect
.error_code
.as_deref()
.expect("error result requires error_code");
assert_eq!(
error.code, expected_code,
"{context}: unexpected error code"
);
}
other => panic!("{context}: expected error, got {other:?}"),
},
other => panic!("{context}: unsupported expected result type '{other}'"),
}
assert_session(response.session.as_ref(), expect.session.as_ref(), &context);
}
fn assert_summary_contains(summary: &str, expected: Option<&str>, context: &str) {
if let Some(expected) = expected {
assert!(
summary.contains(expected),
"{context}: summary '{summary}' does not contain '{expected}'"
);
}
}
fn assert_session(
actual: Option<&ExternalBridgeSessionView>,
expected: Option<&ExpectedSession>,
context: &str,
) {
let Some(expected) = expected else {
return;
};
let actual = actual.expect("expected session view");
assert_eq!(
actual.pending.pattern_id.as_deref(),
expected.pattern_id.as_deref(),
"{context}: unexpected pending pattern"
);
if let Some(has_group_target) = expected.has_group_target {
assert_eq!(
actual.pending.has_group_target, has_group_target,
"{context}: unexpected pending group target flag"
);
}
assert_eq!(
actual.pending.group_id.as_deref(),
expected.group_id.as_deref(),
"{context}: unexpected pending group id"
);
if let Some(style) = expected.transition_style.as_deref() {
assert_eq!(
actual.pending.transition_style.map(|value| value.label()),
Some(style),
"{context}: unexpected pending transition style"
);
}
if let Some(duration_ms) = expected.transition_duration_ms {
assert_eq!(
actual.pending.transition_duration_ms,
Some(duration_ms),
"{context}: unexpected pending transition duration"
);
}
for key in &expected.parameter_keys {
assert!(
actual.pending.parameters.contains_key(key),
"{context}: missing pending parameter '{key}'"
);
}
}
fn assert_state(actual: &ExternalBridgeStateView, expected: Option<&ExpectedState>, context: &str) {
let Some(expected) = expected else {
return;
};
if let Some(blackout) = expected.blackout {
assert_eq!(
actual.global.blackout, blackout,
"{context}: unexpected blackout state"
);
}
if let Some(selected_group) = expected.selected_group.as_deref() {
assert_eq!(
actual.global.selected_group.as_deref(),
Some(selected_group),
"{context}: unexpected selected group"
);
}
if let Some(active_pattern_id) = expected.active_pattern_id.as_deref() {
assert_eq!(
actual.active_scene.pattern_id, active_pattern_id,
"{context}: unexpected active pattern"
);
}
if let Some(preset_id) = expected.preset_id.as_deref() {
assert_eq!(
actual.active_scene.preset_id.as_deref(),
Some(preset_id),
"{context}: unexpected preset id"
);
}
if let Some(active_scene_group) = expected.active_scene_group.as_deref() {
assert_eq!(
actual.active_scene.target_group.as_deref(),
Some(active_scene_group),
"{context}: unexpected active scene group"
);
}
if let Some(transition_style) = expected.transition_style.as_deref() {
assert_eq!(
actual.global.transition_style.label(),
transition_style,
"{context}: unexpected transition style"
);
}
if let Some(transition_duration_ms) = expected.transition_duration_ms {
assert_eq!(
actual.global.transition_duration_ms, transition_duration_ms,
"{context}: unexpected transition duration"
);
}
if let Some(active_transition_present) = expected.active_transition_present {
assert_eq!(
actual.engine.active_transition.is_some(),
active_transition_present,
"{context}: unexpected active transition presence"
);
}
if let Some(message_fragment) = expected.event_message_contains.as_deref() {
assert!(
actual
.recent_events
.iter()
.any(|event| event.message.contains(message_fragment)),
"{context}: missing event containing '{message_fragment}'"
);
}
for expected_parameter in &expected.scalar_parameters {
assert!(
actual.active_scene.parameters.iter().any(|parameter| {
parameter.key == expected_parameter.key
&& parameter.value == SceneParameterValue::Scalar(expected_parameter.value)
}),
"{context}: missing scalar parameter '{}'={}",
expected_parameter.key,
expected_parameter.value
);
}
}
fn golden_trace_paths() -> Vec<PathBuf> {
let mut paths = fs::read_dir(golden_trace_dir())
.expect("golden trace directory must exist")
.map(|entry| entry.expect("golden trace entry").path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect::<Vec<_>>();
paths.sort();
paths
}
fn golden_trace_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden_traces")
}
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}