Stabilize control surface and external bridge v1
This commit is contained in:
@@ -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),
|
||||
|
||||
483
crates/infinity_host/src/external_bridge.rs
Normal file
483
crates/infinity_host/src/external_bridge.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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(), ®istry);
|
||||
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(®istry)
|
||||
.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");
|
||||
|
||||
@@ -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(®istry));
|
||||
let catalog = show_store.catalog(®istry);
|
||||
let available_patterns = show_store.available_patterns(®istry);
|
||||
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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
307
crates/infinity_host/tests/show_control_v1_golden.rs
Normal file
307
crates/infinity_host/tests/show_control_v1_golden.rs
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user