From 07c52db5fb1137af82ebf710aa098a95bfc0b226 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 20 Apr 2026 01:13:27 +0200 Subject: [PATCH] Stabilize control surface and external bridge v1 --- README.md | 3 + crates/infinity_host/src/control.rs | 62 +- crates/infinity_host/src/external_bridge.rs | 483 ++++ crates/infinity_host/src/external_control.rs | 35 + crates/infinity_host/src/lib.rs | 2 + crates/infinity_host/src/main.rs | 55 +- crates/infinity_host/src/scene.rs | 1640 ++++++++++-- crates/infinity_host/src/show_store.rs | 126 +- crates/infinity_host/src/simulation.rs | 516 +++- .../01_staged_pattern_transition_commit.json | 96 + .../02_group_update_parameter_commit.json | 160 ++ .../03_preset_recall_during_transition.json | 103 + .../04_blackout_during_staged_session.json | 123 + ...05_stateless_rejects_staged_primitive.json | 25 + .../tests/show_control_v1_golden.rs | 307 +++ crates/infinity_host_api/src/dto.rs | 240 +- crates/infinity_host_api/src/server.rs | 303 ++- crates/infinity_host_api/tests/contract.rs | 223 +- data/runtime_state.json | 87 + docs/control_ownership.md | 92 + docs/external_control_bridge.md | 126 + docs/pattern_matrix_v1.md | 61 + docs/show_control_primitives.md | 7 + scripts/launch_software_only_web.sh | 49 + web/v1/app.js | 2347 +++++++++++++---- web/v1/index.html | 411 +-- web/v1/styles.css | 1489 +++++++---- web/v1/technical.html | 206 ++ web/v1/technical.js | 951 +++++++ 29 files changed, 8818 insertions(+), 1510 deletions(-) create mode 100644 crates/infinity_host/src/external_bridge.rs create mode 100644 crates/infinity_host/tests/golden_traces/01_staged_pattern_transition_commit.json create mode 100644 crates/infinity_host/tests/golden_traces/02_group_update_parameter_commit.json create mode 100644 crates/infinity_host/tests/golden_traces/03_preset_recall_during_transition.json create mode 100644 crates/infinity_host/tests/golden_traces/04_blackout_during_staged_session.json create mode 100644 crates/infinity_host/tests/golden_traces/05_stateless_rejects_staged_primitive.json create mode 100644 crates/infinity_host/tests/show_control_v1_golden.rs create mode 100644 data/runtime_state.json create mode 100644 docs/control_ownership.md create mode 100644 docs/external_control_bridge.md create mode 100644 docs/pattern_matrix_v1.md create mode 100755 scripts/launch_software_only_web.sh create mode 100644 web/v1/technical.html create mode 100644 web/v1/technical.js diff --git a/README.md b/README.md index d9bcda2..77afbae 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ The current baseline is intentionally strict about unresolved hardware facts. `U - [Host API](docs/host_api.md) - [Local Software-Only Runbook](docs/local_software_only_runbook.md) - [Show-Control Primitives](docs/show_control_primitives.md) +- [Pattern Matrix v1](docs/pattern_matrix_v1.md) +- [External Control Bridge](docs/external_control_bridge.md) +- [Control Ownership](docs/control_ownership.md) - [Protocol](docs/protocol.md) - [Config Schema](docs/config_schema.md) - [Build and Deploy](docs/build_and_deploy.md) diff --git a/crates/infinity_host/src/control.rs b/crates/infinity_host/src/control.rs index 94cced2..7fa5292 100644 --- a/crates/infinity_host/src/control.rs +++ b/crates/infinity_host/src/control.rs @@ -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, + }, + 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, @@ -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 { match self { Self::Toggle(value) => Some(*value), diff --git a/crates/infinity_host/src/external_bridge.rs b/crates/infinity_host/src/external_bridge.rs new file mode 100644 index 0000000..d29408c --- /dev/null +++ b/crates/infinity_host/src/external_bridge.rs @@ -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, + #[serde(default)] + pub session_id: Option, + 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, + pub session_id: Option, + pub result: ExternalBridgeResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, +} + +#[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, + pub panels: Vec, + pub recent_events: Vec, +} + +pub struct ExternalControlBridge { + service: Arc, + sessions: BTreeMap, +} + +impl ExternalControlBridge { + pub fn new(service: Arc) -> 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( + &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::(&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 { + 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 { + 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, + session_id: Option, + result: ExternalBridgeResult, + session: Option, + ) -> Self { + Self { + semantic_version: SHOW_CONTROL_V1_VERSION.to_string(), + request_id, + session_id, + result, + session, + } + } + + fn error( + request_id: Option, + session_id: Option, + error: ExternalBridgeError, + session: Option, + ) -> 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, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +impl From 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 = 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:?}"), + } + } +} diff --git a/crates/infinity_host/src/external_control.rs b/crates/infinity_host/src/external_control.rs index 60bac7a..6a3990c 100644 --- a/crates/infinity_host/src/external_control.rs +++ b/crates/infinity_host/src/external_control.rs @@ -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, + pub has_group_target: bool, + pub group_id: Option, + pub parameters: BTreeMap, + pub transition_style: Option, + pub transition_duration_ms: Option, +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct ShowControlSession { pending_pattern_id: Option, @@ -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 ExternalShowControlPort for T { @@ -374,6 +405,10 @@ impl ReferenceShowControlClient

{ self.adapter.session() } + pub fn pending_state(&self) -> ShowControlPendingState { + self.adapter.session().pending_state() + } + pub fn apply_primitive( &mut self, primitive: ShowControlPrimitive, diff --git a/crates/infinity_host/src/lib.rs b/crates/infinity_host/src/lib.rs index 5c5b8b1..88d8a60 100644 --- a/crates/infinity_host/src/lib.rs +++ b/crates/infinity_host/src/lib.rs @@ -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::*; diff --git a/crates/infinity_host/src/main.rs b/crates/infinity_host/src/main.rs index 7b4c076..53c9ac2 100644 --- a/crates/infinity_host/src/main.rs +++ b/crates/infinity_host/src/main.rs @@ -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 = 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:", diff --git a/crates/infinity_host/src/scene.rs b/crates/infinity_host/src/scene.rs index cb72a0b..05c87f2 100644 --- a/crates/infinity_host/src/scene.rs +++ b/crates/infinity_host/src/scene.rs @@ -11,7 +11,7 @@ use std::{ time::Instant, }; -const DEFAULT_SAMPLE_LED_COUNT: usize = 6; +const DEFAULT_SAMPLE_LED_COUNT: usize = 106; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SceneRuntime { @@ -44,7 +44,7 @@ pub struct PatternRegistry { definitions: BTreeMap, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] struct RgbColor { r: u8, g: u8, @@ -234,38 +234,26 @@ impl PatternRegistry { pub fn render_preview( &self, scene: &SceneRuntime, - panel_index: usize, - panel_count: usize, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + led_count: usize, elapsed_ms: u64, ) -> RenderedPreview { - let colors = palette_from_scene(scene); - let sample_leds = (0..DEFAULT_SAMPLE_LED_COUNT) - .map(|sample_index| { - self.render_led_color( - scene, - &colors, - panel_index, - panel_count, - sample_index, - DEFAULT_SAMPLE_LED_COUNT, - elapsed_ms, - ) - }) - .collect::>(); - - let representative = RgbColor::average(&sample_leds); - let energy_percent = sample_leds + let leds = render_pattern_leds( + scene, panel_row, panel_col, rows, cols, led_count, elapsed_ms, + ); + let representative = RgbColor::average(&leds); + let energy_percent = leds .iter() .map(|color| color.energy_percent() as u32) .sum::() - / sample_leds.len().max(1) as u32; + / leds.len().max(1) as u32; RenderedPreview { representative_color_hex: representative.to_hex(), - sample_led_hex: sample_leds - .into_iter() - .map(|color| color.to_hex()) - .collect(), + sample_led_hex: leds.into_iter().map(|color| color.to_hex()).collect(), energy_percent: energy_percent.min(100) as u8, } } @@ -302,113 +290,6 @@ impl PatternRegistry { .or_else(|| self.definitions.get("solid_color")) .expect("pattern registry must contain a solid_color definition") } - - fn render_led_color( - &self, - scene: &SceneRuntime, - palette: &[RgbColor], - panel_index: usize, - panel_count: usize, - sample_index: usize, - sample_count: usize, - elapsed_ms: u64, - ) -> RgbColor { - let speed = scene - .parameters - .get("speed") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(1.0); - let intensity = scene - .parameters - .get("intensity") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - let spread = scene - .parameters - .get("spread") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(0.55); - let width = scene - .parameters - .get("width") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(0.25) - .clamp(0.05, 1.0); - let grain = scene - .parameters - .get("grain") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(0.65) - .clamp(0.0, 1.0); - let trail = scene - .parameters - .get("trail") - .and_then(SceneParameterValue::as_scalar) - .unwrap_or(0.45) - .clamp(0.05, 1.0); - - let panel_phase = if panel_count == 0 { - 0.0 - } else { - panel_index as f32 / panel_count as f32 - }; - let led_phase = if sample_count == 0 { - 0.0 - } else { - sample_index as f32 / sample_count as f32 - }; - let time_phase = elapsed_ms as f32 / 1000.0 * speed.max(0.01); - - let raw = match scene.pattern_id.as_str() { - "solid_color" => palette.first().copied().unwrap_or(RgbColor::WHITE), - "gradient" => { - let from = palette.first().copied().unwrap_or(RgbColor::BLACK); - let to = palette.get(1).copied().unwrap_or(from); - let blend = (panel_phase + led_phase * spread + time_phase * 0.12).fract(); - from.blend(to, blend) - } - "chase" => { - let position = (time_phase * 0.65 + panel_phase + led_phase).fract(); - let highlight = distance_wrap(position, 0.0); - let strength = smooth_peak(highlight, width); - palette - .first() - .copied() - .unwrap_or(RgbColor::WHITE) - .scale(strength) - } - "pulse" => { - let wave = ((time_phase * 2.8 + panel_phase * 0.6).sin() + 1.0) * 0.5; - palette - .first() - .copied() - .unwrap_or(RgbColor::WHITE) - .scale(wave) - } - "noise" => { - let noise = hashed_noise(scene.seed, panel_index, sample_index, elapsed_ms); - let color_index = ((noise * palette.len().max(1) as f32).floor() as usize) - .min(palette.len().saturating_sub(1)); - palette - .get(color_index) - .copied() - .unwrap_or(RgbColor::WHITE) - .scale((0.3 + noise * grain).clamp(0.0, 1.0)) - } - "walking_pixel" => { - let head = (time_phase * 0.8 + panel_phase * 0.4).fract(); - let sample = led_phase; - let distance = distance_wrap(sample, head); - let strength = smooth_peak(distance, trail); - let base = palette.first().copied().unwrap_or(RgbColor::WHITE); - base.scale(strength.max(0.08)) - } - _ => palette.first().copied().unwrap_or(RgbColor::WHITE), - }; - - raw.scale(intensity) - } } pub fn transition_style_from_duration(duration_ms: u32) -> SceneTransitionStyle { @@ -421,6 +302,1223 @@ pub fn transition_style_from_duration(duration_ms: u32) -> SceneTransitionStyle } } +fn render_pattern_leds( + scene: &SceneRuntime, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + led_count: usize, + elapsed_ms: u64, +) -> Vec { + let led_count = led_count.max(1); + let led_positions = perimeter_led_positions(led_count); + let pattern_id = canonical_pattern_id(&scene.pattern_id); + let speed = scene_scalar(scene, "speed", 0.45).max(0.05); + let tempo_multiplier = scene_scalar(scene, "tempo_multiplier", 1.0).clamp(0.25, 8.0); + let intensity = scene_scalar(scene, "intensity", 1.0).clamp(0.0, 1.0); + let fade = scene_scalar(scene, "fade", 0.35).clamp(0.0, 1.0); + let pattern_brightness = scene_scalar(scene, "brightness", 1.0).clamp(0.0, 2.0); + let block_size = scene_scalar(scene, "block_size", 1.0).max(0.1); + let on_width = scene_scalar(scene, "on_width", 1.0).clamp(0.1, 2.0); + let off_width = scene_scalar(scene, "off_width", 1.0).clamp(0.0, 2.0); + let band_thickness = scene_scalar(scene, "band_thickness", 0.8).clamp(0.1, 2.0); + let duty_cycle = scene_scalar(scene, "strobe_duty_cycle", 0.5).clamp(0.02, 0.98); + let randomness = scene_scalar(scene, "randomness", 0.35).clamp(0.0, 1.5); + let pixel_group_size = scene_scalar(scene, "pixel_group_size", 1.0) + .round() + .clamp(1.0, 5.0) as usize; + let direction = scene_text(scene, "direction", "left_to_right"); + let checker_mode = scene_text(scene, "checker_mode", "classic"); + let scan_style = scene_text(scene, "scan_style", "line"); + let stopwatch_mode = scene_text(scene, "stopwatch_mode", "sync"); + let center_pulse_mode = scene_text(scene, "center_pulse_mode", "expand"); + let color_mode = scene_text(scene, "color_mode", "dual"); + let symmetry = scene_text(scene, "symmetry", "none"); + let flip_horizontal = scene_toggle(scene, "flip_horizontal", false); + let flip_vertical = scene_toggle(scene, "flip_vertical", false); + let time_s = elapsed_ms as f32 / 1000.0; + let pattern_phase = time_s * speed * tempo_multiplier; + let amount = directional_amount(panel_row, panel_col, rows, cols, &direction); + let (primary, secondary) = choose_pair(scene, amount, panel_row, panel_col, &color_mode); + + let mut colors = match pattern_id { + "solid" => vec![primary; led_count], + "breathing" => { + let breathe = ease_in_out_sine(oscillate(pattern_phase, 0.25, 0.0)); + vec![secondary.blend(primary, breathe); led_count] + } + "column_gradient" => { + let gradient_amount = + mirror_amount(amount, matches!(symmetry.as_str(), "horizontal" | "both")); + vec![secondary.blend(primary, gradient_amount); led_count] + } + "row_gradient" => { + let raw_row_amount = if rows <= 1 { + 0.0 + } else { + panel_row as f32 / (rows - 1) as f32 + }; + let row_amount = if direction == "bottom_to_top" { + 1.0 - raw_row_amount + } else { + raw_row_amount + }; + let row_amount = + mirror_amount(row_amount, matches!(symmetry.as_str(), "vertical" | "both")); + vec![secondary.blend(primary, row_amount); led_count] + } + "checker" => { + let parity = (panel_row + panel_col) % 2; + let phase = pattern_phase.floor() as i32; + let diagonal = checker_mode == "diagonal" || checker_mode == "checkerd"; + if diagonal { + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let slash = if checker_mode == "checkerd" { + phase % 2 != 0 + } else { + false + }; + let first = if slash { + *y_pos <= 1.0 - *x_pos + } else { + *y_pos <= *x_pos + }; + if (parity == 0) == first { + primary + } else if color_mode == "mono" { + RgbColor::BLACK + } else { + secondary + } + }) + .collect() + } else { + let active_primary = (parity + phase as usize) % 2 == 0; + vec![if active_primary { primary } else { secondary }; led_count] + } + } + "wave_line" => { + let active = wave_line_active( + panel_row, + panel_col, + rows, + cols, + &direction, + pattern_phase.floor() as i32, + ); + vec![if active { primary } else { RgbColor::BLACK }; led_count] + } + "arrow" => { + let active = arrow_active( + panel_row, + panel_col, + rows, + cols, + &direction, + block_size, + pattern_phase.floor() as i32, + ); + vec![if active { primary } else { RgbColor::BLACK }; led_count] + } + "scan" => render_scan_leds( + primary, + panel_row, + panel_col, + rows, + cols, + &led_positions, + effective_scan_angle( + scene_scalar(scene, "angle", direction_to_angle(&direction)), + flip_horizontal, + flip_vertical, + ), + pattern_phase, + on_width, + off_width, + band_thickness, + scan_style, + ), + "scan_dual" => render_dual_axis_leds( + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + direction, + pattern_phase, + block_size, + &led_positions, + ), + "sweep" => render_sweep_leds( + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + direction, + pattern_phase, + &led_positions, + ), + "saw" => render_saw_leds( + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + direction, + symmetry, + pattern_phase, + &led_positions, + ), + "two_dots" => render_two_dots_leds( + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + direction, + pattern_phase, + block_size, + &led_positions, + ), + "center_pulse" => render_center_pulse_leds( + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + ¢er_pulse_mode, + pattern_phase, + &led_positions, + ), + "sparkle" => render_sparkle_leds( + scene.seed, + primary, + panel_row, + panel_col, + led_count, + pattern_phase, + duty_cycle, + ), + "strobe" => render_strobe_leds( + scene.seed, + primary, + panel_row, + panel_col, + led_count, + pattern_phase, + duty_cycle, + pixel_group_size, + scene_text(scene, "strobe_mode", "global"), + ), + "stopwatch" => render_stopwatch_leds( + scene.seed, + primary, + panel_row, + panel_col, + led_count, + pattern_phase, + &stopwatch_mode, + ), + "snake" => render_snake_leds( + scene.seed, + primary, + secondary, + panel_row, + panel_col, + rows, + cols, + led_count, + pattern_phase, + randomness, + ), + _ => vec![primary; led_count], + }; + + if fade > 0.0 && colors.len() > 2 { + colors = smooth_led_sequence(colors, fade); + } + + colors + .into_iter() + .map(|color| color.scale((intensity * pattern_brightness).clamp(0.0, 1.0))) + .collect() +} + +fn canonical_pattern_id(pattern_id: &str) -> &str { + match pattern_id { + "solid_color" => "solid", + "gradient" => "column_gradient", + "pulse" => "breathing", + "walking_pixel" => "scan", + "noise" => "sparkle", + "chase" => "sweep", + "checkerd" => "checker", + _ => pattern_id, + } +} + +fn perimeter_led_positions(led_count: usize) -> Vec<(f32, f32)> { + if led_count == 106 { + return side_positions(27, (0.02, 0.02), (0.02, 0.98)) + .into_iter() + .chain(side_positions(27, (0.02, 0.98), (0.98, 0.98))) + .chain(side_positions(27, (0.98, 0.98), (0.98, 0.02))) + .chain(side_positions(25, (0.98, 0.02), (0.02, 0.02))) + .collect(); + } + (0..led_count) + .map(|index| { + let phase = index as f32 / led_count.max(1) as f32; + if phase < 0.25 { + (0.02, 0.02 + phase / 0.25 * 0.96) + } else if phase < 0.5 { + (0.02 + (phase - 0.25) / 0.25 * 0.96, 0.98) + } else if phase < 0.75 { + (0.98, 0.98 - (phase - 0.5) / 0.25 * 0.96) + } else { + (0.98 - (phase - 0.75) / 0.25 * 0.96, 0.02) + } + }) + .collect() +} + +fn side_positions(count: usize, start: (f32, f32), end: (f32, f32)) -> Vec<(f32, f32)> { + (0..count) + .map(|index| { + let factor = if count <= 1 { + 0.0 + } else { + index as f32 / (count - 1) as f32 + }; + ( + start.0 + (end.0 - start.0) * factor, + start.1 + (end.1 - start.1) * factor, + ) + }) + .collect() +} + +fn scene_scalar(scene: &SceneRuntime, key: &str, default: f32) -> f32 { + scene + .parameters + .get(key) + .and_then(SceneParameterValue::as_scalar) + .unwrap_or(default) +} + +fn scene_text<'a>(scene: &'a SceneRuntime, key: &str, default: &'a str) -> String { + scene + .parameters + .get(key) + .and_then(SceneParameterValue::as_text) + .map(ToOwned::to_owned) + .unwrap_or_else(|| default.to_string()) +} + +fn scene_toggle(scene: &SceneRuntime, key: &str, default: bool) -> bool { + scene + .parameters + .get(key) + .and_then(|value| match value { + SceneParameterValue::Toggle(value) => Some(*value), + _ => None, + }) + .unwrap_or(default) +} + +fn directional_amount( + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: &str, +) -> f32 { + let row_pos = if rows <= 1 { + 0.0 + } else { + panel_row as f32 / (rows - 1) as f32 + }; + let col_pos = if cols <= 1 { + 0.0 + } else { + panel_col as f32 / (cols - 1) as f32 + }; + match direction { + "right_to_left" => 1.0 - col_pos, + "top_to_bottom" => row_pos, + "bottom_to_top" => 1.0 - row_pos, + "outward" => ((col_pos - 0.5).abs() * 2.0).clamp(0.0, 1.0), + "inward" => (1.0 - (col_pos - 0.5).abs() * 2.0).clamp(0.0, 1.0), + _ => col_pos, + } +} + +fn direction_to_angle(direction: &str) -> f32 { + match direction { + "top_to_bottom" => 90.0, + "bottom_to_top" => 270.0, + "right_to_left" => 180.0, + _ => 0.0, + } +} + +fn effective_scan_angle(angle: f32, flip_horizontal: bool, flip_vertical: bool) -> f32 { + let mut adjusted = angle.rem_euclid(360.0); + if flip_horizontal { + adjusted = (180.0 - adjusted).rem_euclid(360.0); + } + if flip_vertical { + adjusted = (-adjusted).rem_euclid(360.0); + } + adjusted +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + if (edge1 - edge0).abs() < f32::EPSILON { + return 0.0; + } + let amount = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + amount * amount * (3.0 - 2.0 * amount) +} + +fn ease_in_out_sine(value: f32) -> f32 { + -((std::f32::consts::PI * value.clamp(0.0, 1.0)).cos() - 1.0) / 2.0 +} + +fn mirror_amount(amount: f32, enabled: bool) -> f32 { + if enabled { + 1.0 - (2.0 * amount - 1.0).abs() + } else { + amount + } +} + +fn oscillate(value: f32, speed: f32, phase: f32) -> f32 { + 0.5 + 0.5 * ((value * speed * std::f32::consts::TAU) + phase).sin() +} + +fn choose_pair( + scene: &SceneRuntime, + amount: f32, + panel_row: usize, + panel_col: usize, + color_mode: &str, +) -> (RgbColor, RgbColor) { + let primary = scene_text(scene, "primary_color", "#4D7CFF"); + let secondary = scene_text(scene, "secondary_color", "#0E1630"); + let palette_name = scene_text(scene, "palette", "Laser Club"); + let seed_amount = amount + panel_row as f32 * 0.13 + panel_col as f32 * 0.07; + match color_mode { + "palette" => ( + sample_palette_named_or_scene(scene, &palette_name, amount), + sample_palette_named_or_scene(scene, &palette_name, (amount + 0.38) % 1.0), + ), + "mono" => (parse_color(&primary), RgbColor::BLACK), + "complementary" => { + let left = parse_color(&primary); + (left, left.complementary()) + } + "random_colors" => { + let vivid = sample_random_effect_color(seed_amount); + (vivid, vivid.scale(0.08)) + } + "custom_random" => { + let choices = custom_random_choices(parse_color(&primary), parse_color(&secondary)); + let picked = + choices[(hashed_noise(scene.seed, panel_row, panel_col, (amount * 1000.0) as u64) + * choices.len() as f32) as usize + % choices.len()]; + (picked, picked.scale(0.08)) + } + _ => (parse_color(&primary), parse_color(&secondary)), + } +} + +fn sample_palette_named_or_scene( + scene: &SceneRuntime, + palette_name: &str, + amount: f32, +) -> RgbColor { + named_palette(palette_name) + .map(|palette| sample_palette_list(palette, amount)) + .unwrap_or_else(|| { + let colors = palette_from_scene(scene); + sample_palette_list(&colors, amount) + }) +} + +fn sample_palette_list(colors: &[RgbColor], amount: f32) -> RgbColor { + if colors.is_empty() { + return RgbColor::WHITE; + } + if colors.len() == 1 { + return colors[0]; + } + let scaled = amount.clamp(0.0, 1.0) * (colors.len() - 1) as f32; + let left = scaled.floor() as usize; + let right = left.saturating_add(1).min(colors.len() - 1); + colors[left].blend(colors[right], scaled - left as f32) +} + +fn named_palette(name: &str) -> Option<&'static [RgbColor]> { + const LASER_CLUB: [RgbColor; 4] = [ + RgbColor { + r: 0, + g: 240, + b: 255, + }, + RgbColor { + r: 0, + g: 140, + b: 255, + }, + RgbColor { + r: 106, + g: 0, + b: 255, + }, + RgbColor { r: 6, g: 8, b: 20 }, + ]; + const AFTERHOURS: [RgbColor; 4] = [ + RgbColor { + r: 247, + g: 37, + b: 133, + }, + RgbColor { + r: 181, + g: 23, + b: 158, + }, + RgbColor { + r: 114, + g: 9, + b: 183, + }, + RgbColor { r: 20, g: 3, b: 26 }, + ]; + const VOLTAGE: [RgbColor; 4] = [ + RgbColor { + r: 0, + g: 229, + b: 255, + }, + RgbColor { + r: 0, + g: 179, + b: 255, + }, + RgbColor { + r: 58, + g: 134, + b: 255, + }, + RgbColor { r: 5, g: 10, b: 20 }, + ]; + const MAGENTA_DRIVE: [RgbColor; 4] = [ + RgbColor { + r: 255, + g: 0, + b: 110, + }, + RgbColor { + r: 255, + g: 77, + b: 166, + }, + RgbColor { + r: 122, + g: 0, + b: 255, + }, + RgbColor { r: 18, g: 3, b: 24 }, + ]; + const WAREHOUSE_HEAT: [RgbColor; 4] = [ + RgbColor { + r: 255, + g: 90, + b: 31, + }, + RgbColor { + r: 255, + g: 158, + b: 0, + }, + RgbColor { + r: 255, + g: 208, + b: 0, + }, + RgbColor { r: 20, g: 6, b: 0 }, + ]; + const UV_RIOT: [RgbColor; 4] = [ + RgbColor { + r: 122, + g: 0, + b: 255, + }, + RgbColor { + r: 177, + g: 0, + b: 255, + }, + RgbColor { + r: 255, + g: 0, + b: 168, + }, + RgbColor { r: 16, g: 0, b: 20 }, + ]; + const REDLINE: [RgbColor; 4] = [ + RgbColor { + r: 255, + g: 45, + b: 85, + }, + RgbColor { + r: 255, + g: 106, + b: 0, + }, + RgbColor { + r: 255, + g: 176, + b: 0, + }, + RgbColor { r: 22, g: 4, b: 6 }, + ]; + const SODIUM_HAZE: [RgbColor; 4] = [ + RgbColor { + r: 255, + g: 122, + b: 0, + }, + RgbColor { + r: 255, + g: 176, + b: 0, + }, + RgbColor { + r: 255, + g: 216, + b: 107, + }, + RgbColor { r: 18, g: 7, b: 0 }, + ]; + match name { + "Magenta Drive" => Some(&MAGENTA_DRIVE), + "Warehouse Heat" => Some(&WAREHOUSE_HEAT), + "UV Riot" => Some(&UV_RIOT), + "Redline" => Some(&REDLINE), + "Sodium Haze" => Some(&SODIUM_HAZE), + "Afterhours" => Some(&AFTERHOURS), + "Voltage" => Some(&VOLTAGE), + "Laser Club" => Some(&LASER_CLUB), + _ => None, + } +} + +fn sample_random_effect_color(amount: f32) -> RgbColor { + let colors = [ + RgbColor { + r: 255, + g: 30, + b: 30, + }, + RgbColor { + r: 255, + g: 107, + b: 0, + }, + RgbColor { + r: 255, + g: 188, + b: 0, + }, + RgbColor { + r: 178, + g: 255, + b: 0, + }, + RgbColor { + r: 0, + g: 255, + b: 76, + }, + RgbColor { + r: 0, + g: 219, + b: 255, + }, + RgbColor { + r: 0, + g: 81, + b: 255, + }, + RgbColor { + r: 178, + g: 0, + b: 255, + }, + ]; + colors[((amount.fract() * colors.len() as f32).floor() as usize).min(colors.len() - 1)] +} + +fn custom_random_choices(primary: RgbColor, secondary: RgbColor) -> Vec { + if primary == secondary { + return vec![primary]; + } + vec![ + primary, + primary.blend(secondary, 0.25), + primary.blend(secondary, 0.5), + primary.blend(secondary, 0.75), + secondary, + ] +} + +fn wave_line_active( + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: &str, + step: i32, +) -> bool { + let triangle = [0, 1, 2, 1]; + if direction == "top_to_bottom" || direction == "bottom_to_top" { + let phase = if direction == "top_to_bottom" { + step + } else { + -step + }; + let col_scale = ((cols.saturating_sub(1)) as f32 / 2.0).max(0.0); + let target = (triangle + [((panel_row as i32 - phase).rem_euclid(triangle.len() as i32)) as usize] + as f32 + * col_scale + / 2.0) + .round() as usize; + return panel_col == target.min(cols.saturating_sub(1)); + } + let phase = if direction == "left_to_right" { + step + } else { + -step + }; + let row_scale = ((rows.saturating_sub(1)) as f32 / 2.0).max(0.0); + let target = (triangle[((panel_col as i32 - phase).rem_euclid(triangle.len() as i32)) as usize] + as f32 + * row_scale) + .round() as usize; + panel_row == target.min(rows.saturating_sub(1)) +} + +fn arrow_active( + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: &str, + block_size: f32, + step: i32, +) -> bool { + let horizontal = !matches!(direction, "top_to_bottom" | "bottom_to_top"); + let major_count = if horizontal { cols } else { rows }; + let minor_count = if horizontal { rows } else { cols }; + let major = if horizontal { panel_col } else { panel_row }; + let minor = if horizontal { panel_row } else { panel_col }; + let gap = (block_size.round().max(1.0) - 1.0) as usize; + let span = 3 + gap; + let movement = step; + let middle_minor = (minor_count as f32 - 1.0) / 2.0; + let band = if (minor as f32 - middle_minor).abs() <= 0.55 { + 0 + } else { + 1 + }; + let orientation_right = matches!(direction, "left_to_right" | "top_to_bottom" | "outward"); + let target = if orientation_right { + if band == 0 { + 1 + } else { + 0 + } + } else if band == 0 { + 1 + } else { + 2 + }; + let local = if orientation_right { + (major as i32 - movement).rem_euclid(span as i32) + } else { + (major as i32 + movement).rem_euclid(span as i32) + }; + major_count > 0 && local as usize == target +} + +fn render_scan_leds( + primary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + led_positions: &[(f32, f32)], + angle: f32, + phase: f32, + on_width: f32, + off_width: f32, + band_thickness: f32, + scan_style: String, +) -> Vec { + let vector = scan_vector(angle); + let projections: Vec = led_positions + .iter() + .map(|(x_pos, y_pos)| { + (panel_col as f32 + *x_pos) * vector.0 + (panel_row as f32 + *y_pos) * vector.1 + }) + .collect(); + let min_progress = 0.0; + let max_progress = cols.max(rows) as f32 + 2.0; + projections + .into_iter() + .map(|progress| { + let amount = scan_band_amount( + progress, + phase, + min_progress, + max_progress, + if scan_style == "bands" { + band_thickness + } else { + on_width + }, + off_width, + &scan_style, + ); + primary.scale(amount) + }) + .collect() +} + +fn render_dual_axis_leds( + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: String, + phase: f32, + block_size: f32, + led_positions: &[(f32, f32)], +) -> Vec { + let vertical = matches!(direction.as_str(), "top_to_bottom" | "bottom_to_top"); + let axis_count = if vertical { rows.max(1) } else { cols.max(1) } as f32; + let mut lead = oscillate(phase, 0.6, 0.0) * (axis_count - 1.0); + if matches!(direction.as_str(), "right_to_left" | "bottom_to_top") { + lead = (axis_count - 1.0) - lead; + } + let mirror = (axis_count - 1.0) - lead; + let width = (block_size * 0.28).max(0.3); + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let pos = if vertical { + panel_row as f32 + *y_pos + } else { + panel_col as f32 + *x_pos + }; + let lead_amount: f32 = 1.0 - smoothstep(width, width + 1.0, (pos - lead).abs()); + let echo_amount: f32 = + (1.0 - smoothstep(width, width + 1.0, (pos - mirror).abs())) * 0.62; + let amount = lead_amount.max(echo_amount); + secondary.blend(primary, amount) + }) + .collect() +} + +fn render_sweep_leds( + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: String, + phase: f32, + led_positions: &[(f32, f32)], +) -> Vec { + let vertical = matches!(direction.as_str(), "top_to_bottom" | "bottom_to_top"); + let axis_count = if vertical { rows.max(1) } else { cols.max(1) } as f32; + let front = oscillate(phase, 0.45, 0.0) * (axis_count - 1.0); + let softness = 0.26; + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let pos = if vertical { + panel_row as f32 + *y_pos + } else { + panel_col as f32 + *x_pos + }; + let amount = if matches!(direction.as_str(), "right_to_left" | "bottom_to_top") { + smoothstep(front - softness, front + softness, pos) + } else { + 1.0 - smoothstep(front - softness, front + softness, pos) + }; + secondary.blend(primary, amount) + }) + .collect() +} + +fn render_saw_leds( + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: String, + symmetry: String, + phase: f32, + led_positions: &[(f32, f32)], +) -> Vec { + let quantization = rows.max(cols).max(1) as f32; + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let mut amount = directional_amount_from_point( + panel_row, panel_col, rows, cols, &direction, *x_pos, *y_pos, + ); + if matches!(direction.as_str(), "left_to_right" | "right_to_left") { + amount = mirror_amount(amount, matches!(symmetry.as_str(), "horizontal" | "both")); + } + if matches!(direction.as_str(), "top_to_bottom" | "bottom_to_top") { + amount = mirror_amount(amount, matches!(symmetry.as_str(), "vertical" | "both")); + } + let wave = (amount - phase * 0.7).rem_euclid(1.0); + let saw = if wave < 0.92 { + (wave / 0.92 * quantization).round() / quantization + } else { + 0.0 + }; + if scene_is_mono_color(primary, secondary) { + primary.scale(saw) + } else { + secondary.blend(primary, saw) + } + }) + .collect() +} + +fn smooth_led_sequence(colors: Vec, fade: f32) -> Vec { + let amount = (fade * 0.42).clamp(0.0, 0.42); + if amount <= 0.0 || colors.len() < 3 { + return colors; + } + + let len = colors.len(); + (0..len) + .map(|index| { + let previous = colors[(index + len - 1) % len]; + let current = colors[index]; + let next = colors[(index + 1) % len]; + let neighborhood = RgbColor::average(&[previous, current, next]); + current.blend(neighborhood, amount) + }) + .collect() +} + +fn render_two_dots_leds( + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: String, + phase: f32, + block_size: f32, + led_positions: &[(f32, f32)], +) -> Vec { + let vertical = matches!(direction.as_str(), "top_to_bottom" | "bottom_to_top"); + let axis_count = if vertical { rows.max(1) } else { cols.max(1) } as f32; + let orbit = oscillate(phase, 0.75, 0.0) * (axis_count - 1.0); + let dot_a = orbit; + let dot_b = (axis_count - 1.0) - orbit; + let width = (block_size * 0.22).max(0.25); + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let pos = if vertical { + panel_row as f32 + *y_pos + } else { + panel_col as f32 + *x_pos + }; + let pulse_a: f32 = 1.0 - smoothstep(width, width + 0.95, (pos - dot_a).abs()); + let pulse_b: f32 = 1.0 - smoothstep(width, width + 0.95, (pos - dot_b).abs()); + secondary.blend(primary, pulse_a.max(pulse_b)) + }) + .collect() +} + +fn render_center_pulse_leds( + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + mode: &str, + phase: f32, + led_positions: &[(f32, f32)], +) -> Vec { + let center_row = (rows as f32 - 1.0) / 2.0; + let center_col = (cols as f32 - 1.0) / 2.0; + let distance = (panel_row as f32 - center_row).abs() + (panel_col as f32 - center_col).abs(); + let max_distance = center_row + center_col; + let front = if matches!(mode, "reverse" | "outline_reverse") { + max_distance - (phase * 1.35).rem_euclid(max_distance + 1.0) + } else { + (phase * 1.35).rem_euclid(max_distance + 1.0) + }; + let amount = 1.0 - smoothstep(0.0, 0.7, (distance - front).abs()); + if matches!(mode, "outline" | "outline_reverse") { + led_positions + .iter() + .map(|(x_pos, y_pos)| { + let edge_distance = x_pos.min(1.0 - *x_pos).min(*y_pos).min(1.0 - *y_pos); + if edge_distance <= 0.08 { + primary.scale(amount) + } else { + RgbColor::BLACK + } + }) + .collect() + } else { + vec![secondary.blend(primary, amount); led_positions.len()] + } +} + +fn render_sparkle_leds( + seed: u64, + primary: RgbColor, + panel_row: usize, + panel_col: usize, + led_count: usize, + phase: f32, + duty_cycle: f32, +) -> Vec { + (0..led_count) + .map(|index| { + let sparkle = hashed_noise( + seed, + panel_row * 31 + panel_col, + index, + phase.floor() as u64, + ); + let burst = smoothstep(1.0 - duty_cycle, 1.0, sparkle); + primary.scale(burst) + }) + .collect() +} + +fn render_strobe_leds( + seed: u64, + primary: RgbColor, + panel_row: usize, + panel_col: usize, + led_count: usize, + phase: f32, + duty_cycle: f32, + pixel_group_size: usize, + mode: String, +) -> Vec { + if mode == "global" { + let on = (phase * 4.0).fract() < duty_cycle; + return vec![if on { primary } else { RgbColor::BLACK }; led_count]; + } + let grouped = mode != "random_leds"; + let group_size = if grouped { pixel_group_size.max(1) } else { 1 }; + let bucket = (phase * 10.0).floor() as u64; + let mut leds = Vec::with_capacity(led_count); + for group_start in (0..led_count).step_by(group_size) { + let group_index = group_start / group_size; + let active = + hashed_noise(seed, panel_row * 17 + panel_col * 29, group_index, bucket) < duty_cycle; + for offset in 0..group_size { + if leds.len() >= led_count { + break; + } + if mode == "random_leds" { + let per_led_active = hashed_noise( + seed, + panel_row * 19 + panel_col * 13, + group_start + offset, + bucket + 1, + ) < duty_cycle; + leds.push(if per_led_active { + primary + } else { + RgbColor::BLACK + }); + } else { + leds.push(if active { primary } else { RgbColor::BLACK }); + } + } + } + leds +} + +fn render_stopwatch_leds( + seed: u64, + primary: RgbColor, + panel_row: usize, + panel_col: usize, + led_count: usize, + phase: f32, + mode: &str, +) -> Vec { + let cycle_length = (led_count * 2).max(1) as f32; + let offset = if mode == "random" { + hashed_noise(seed, panel_row, panel_col, 0) * cycle_length + } else { + 0.0 + }; + let phase = (phase * 20.0 + offset).rem_euclid(cycle_length); + let active_count = if phase < led_count as f32 { + phase + 1.0 + } else { + 2.0 * led_count as f32 - phase + } + .round() as usize; + (0..led_count) + .map(|index| { + if index < active_count { + primary + } else { + RgbColor::BLACK + } + }) + .collect() +} + +fn render_snake_leds( + seed: u64, + primary: RgbColor, + secondary: RgbColor, + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + led_count: usize, + phase: f32, + randomness: f32, +) -> Vec { + let path = serpentine_path(rows, cols); + let head = ((phase * (2.2 + randomness)).floor() as usize + seed as usize) % path.len().max(1); + let length = 4usize; + let active = (0..length) + .any(|offset| path[(head + path.len() - offset) % path.len()] == (panel_row, panel_col)); + let base = if active { + primary + } else if path[(head + 3) % path.len()] == (panel_row, panel_col) { + secondary + } else { + RgbColor::BLACK + }; + vec![base; led_count] +} + +fn serpentine_path(rows: usize, cols: usize) -> Vec<(usize, usize)> { + let mut path = Vec::with_capacity(rows * cols); + for row in 0..rows { + if row % 2 == 0 { + for col in 0..cols { + path.push((row, col)); + } + } else { + for col in (0..cols).rev() { + path.push((row, col)); + } + } + } + path +} + +fn render_scan_angle_vector(angle: f32) -> (f32, f32) { + let radians = angle.to_radians(); + (radians.cos(), radians.sin()) +} + +fn scan_vector(angle: f32) -> (f32, f32) { + let (x_axis, y_axis) = render_scan_angle_vector(angle); + (x_axis.signum(), y_axis.signum()) +} + +fn scan_band_amount( + progress: f32, + phase: f32, + min_progress: f32, + max_progress: f32, + on_width: f32, + off_width: f32, + scan_style: &str, +) -> f32 { + if scan_style == "bands" { + let period = (on_width + off_width).max(0.1); + let local = (progress - min_progress - phase).rem_euclid(period); + return if local < on_width { 1.0 } else { 0.0 }; + } + let travel = ((max_progress - min_progress) + on_width + off_width.max(0.0)).max(0.1); + let band_center = min_progress + phase.rem_euclid(travel); + if (progress - band_center).abs() <= on_width * 0.5 { + 1.0 + } else { + 0.0 + } +} + +fn directional_amount_from_point( + panel_row: usize, + panel_col: usize, + rows: usize, + cols: usize, + direction: &str, + x_pos: f32, + y_pos: f32, +) -> f32 { + let row_pos = if rows <= 1 { + 0.0 + } else { + (panel_row as f32 + y_pos) / rows as f32 + }; + let col_pos = if cols <= 1 { + 0.0 + } else { + (panel_col as f32 + x_pos) / cols as f32 + }; + match direction { + "right_to_left" => 1.0 - col_pos, + "top_to_bottom" => row_pos, + "bottom_to_top" => 1.0 - row_pos, + _ => col_pos, + } +} + +fn scene_is_mono_color(primary: RgbColor, secondary: RgbColor) -> bool { + secondary == RgbColor::BLACK || primary == secondary +} + pub fn apply_group_gate(preview: &RenderedPreview, active_in_group: bool) -> RenderedPreview { if active_in_group { return preview.clone(); @@ -534,21 +1632,131 @@ fn panel_position_key(position: &PanelPosition) -> &'static str { fn default_pattern_definitions() -> Vec { vec![ + PatternDefinition { + pattern_id: "arrow".to_string(), + display_name: "Arrow".to_string(), + description: "Discrete chevrons like the former Python wall tool.".to_string(), + parameters: common_motion_parameters(vec!["direction", "block_size"]), + }, + PatternDefinition { + pattern_id: "breathing".to_string(), + display_name: "Breathing".to_string(), + description: "Slow collective inhale and exhale.".to_string(), + parameters: common_motion_parameters(vec![]), + }, + PatternDefinition { + pattern_id: "center_pulse".to_string(), + display_name: "Center Pulse".to_string(), + description: "Radial waves expanding from the center.".to_string(), + parameters: common_motion_parameters(vec!["center_pulse_mode"]), + }, + PatternDefinition { + pattern_id: "checker".to_string(), + display_name: "Checkerd".to_string(), + description: "Classic checker, diagonal split or animated Checkerd.".to_string(), + parameters: common_motion_parameters(vec!["checker_mode"]), + }, + PatternDefinition { + pattern_id: "column_gradient".to_string(), + display_name: "Column Gradient".to_string(), + description: "Horizontal blend across the columns.".to_string(), + parameters: common_motion_parameters(vec!["direction", "symmetry"]), + }, + PatternDefinition { + pattern_id: "row_gradient".to_string(), + display_name: "Row Gradient".to_string(), + description: "Vertical blend across the rows.".to_string(), + parameters: common_motion_parameters(vec!["direction", "symmetry"]), + }, + PatternDefinition { + pattern_id: "saw".to_string(), + display_name: "Saw".to_string(), + description: "Quantized saw-wave sweep.".to_string(), + parameters: common_motion_parameters(vec!["direction", "symmetry"]), + }, + PatternDefinition { + pattern_id: "scan".to_string(), + display_name: "Scan".to_string(), + description: "Unified scan renderer for row, column and diagonal motion.".to_string(), + parameters: common_motion_parameters(vec![ + "direction", + "angle", + "scan_style", + "on_width", + "off_width", + "band_thickness", + "flip_horizontal", + "flip_vertical", + ]), + }, + PatternDefinition { + pattern_id: "scan_dual".to_string(), + display_name: "Scan Dual".to_string(), + description: "Mirrored scan bands inspired by the old tool.".to_string(), + parameters: common_motion_parameters(vec!["direction", "block_size"]), + }, + PatternDefinition { + pattern_id: "snake".to_string(), + display_name: "Snake".to_string(), + description: "A self-playing snake roaming across the wall.".to_string(), + parameters: common_motion_parameters(vec!["randomness"]), + }, + PatternDefinition { + pattern_id: "solid".to_string(), + display_name: "Solid".to_string(), + description: "Uniform wash across the whole wall.".to_string(), + parameters: common_motion_parameters(vec![]), + }, + PatternDefinition { + pattern_id: "sparkle".to_string(), + display_name: "Sparkle".to_string(), + description: "Ambient base layer with random sparkling accents.".to_string(), + parameters: common_motion_parameters(vec!["strobe_duty_cycle"]), + }, + PatternDefinition { + pattern_id: "stopwatch".to_string(), + display_name: "Stopwatch".to_string(), + description: "LEDs fill from 1 to N and then clear back to 1.".to_string(), + parameters: common_motion_parameters(vec!["stopwatch_mode"]), + }, + PatternDefinition { + pattern_id: "strobe".to_string(), + display_name: "Strobe".to_string(), + description: "Fast on/off pulses with duty-cycle control.".to_string(), + parameters: common_motion_parameters(vec![ + "strobe_mode", + "pixel_group_size", + "strobe_duty_cycle", + ]), + }, + PatternDefinition { + pattern_id: "sweep".to_string(), + display_name: "Sweep".to_string(), + description: "Primary and secondary colors wipe through the wall.".to_string(), + parameters: common_motion_parameters(vec!["direction"]), + }, + PatternDefinition { + pattern_id: "two_dots".to_string(), + display_name: "Two Dots".to_string(), + description: "Two mirrored highlights travel across the wall.".to_string(), + parameters: common_motion_parameters(vec!["direction", "block_size"]), + }, + PatternDefinition { + pattern_id: "wave_line".to_string(), + display_name: "Wave Line".to_string(), + description: "Discrete wave line that travels across the wall.".to_string(), + parameters: common_motion_parameters(vec!["direction"]), + }, PatternDefinition { pattern_id: "solid_color".to_string(), - display_name: "Solid Color".to_string(), - description: "Static palette color for calm base looks and blackout recovery scenes." - .to_string(), - parameters: vec![ - scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.0), - scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0), - ], + display_name: "Solid Color (Compat)".to_string(), + description: "Compatibility alias for Solid.".to_string(), + parameters: common_motion_parameters(vec![]), }, PatternDefinition { pattern_id: "gradient".to_string(), - display_name: "Gradient Drift".to_string(), - description: "Spatial gradient with slow temporal drift for mood development." - .to_string(), + display_name: "Gradient (Compat)".to_string(), + description: "Compatibility alias for Column Gradient.".to_string(), parameters: vec![ scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.35), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.85), @@ -557,8 +1765,8 @@ fn default_pattern_definitions() -> Vec { }, PatternDefinition { pattern_id: "chase".to_string(), - display_name: "Chase".to_string(), - description: "Directional chase motion that is easy to time and group.".to_string(), + display_name: "Chase (Compat)".to_string(), + description: "Compatibility alias for Sweep.".to_string(), parameters: vec![ scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 1.0), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0), @@ -567,8 +1775,8 @@ fn default_pattern_definitions() -> Vec { }, PatternDefinition { pattern_id: "pulse".to_string(), - display_name: "Pulse".to_string(), - description: "Breathing pulse for soft transitions and level checks.".to_string(), + display_name: "Pulse (Compat)".to_string(), + description: "Compatibility alias for Breathing.".to_string(), parameters: vec![ scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 0.75), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.9), @@ -576,8 +1784,8 @@ fn default_pattern_definitions() -> Vec { }, PatternDefinition { pattern_id: "noise".to_string(), - display_name: "Noise".to_string(), - description: "Organic shimmer driven by deterministic pseudo noise.".to_string(), + display_name: "Noise (Compat)".to_string(), + description: "Compatibility alias for Sparkle.".to_string(), parameters: vec![ scalar_spec("speed", "Speed", 0.1, 4.0, 0.05, 0.7), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.8), @@ -586,8 +1794,8 @@ fn default_pattern_definitions() -> Vec { }, PatternDefinition { pattern_id: "walking_pixel".to_string(), - display_name: "Walking Pixel".to_string(), - description: "Single-pixel scan for mapping tests and alignment checks.".to_string(), + display_name: "Walking Pixel (Compat)".to_string(), + description: "Compatibility alias for Scan.".to_string(), parameters: vec![ scalar_spec("speed", "Speed", 0.1, 8.0, 0.05, 1.0), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0), @@ -616,6 +1824,75 @@ fn scalar_spec( } } +fn text_spec(key: &str, label: &str, default_value: &str) -> SceneParameterSpec { + SceneParameterSpec { + key: key.to_string(), + label: label.to_string(), + kind: SceneParameterKind::Text, + min_scalar: None, + max_scalar: None, + step: None, + default_value: SceneParameterValue::Text(default_value.to_string()), + } +} + +fn toggle_spec(key: &str, label: &str, default_value: bool) -> SceneParameterSpec { + SceneParameterSpec { + key: key.to_string(), + label: label.to_string(), + kind: SceneParameterKind::Toggle, + min_scalar: None, + max_scalar: None, + step: None, + default_value: SceneParameterValue::Toggle(default_value), + } +} + +fn common_motion_parameters(extra_keys: Vec<&str>) -> Vec { + let mut specs = vec![ + scalar_spec("speed", "Speed", 0.05, 8.0, 0.05, 0.45), + scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0), + scalar_spec("brightness", "Brightness", 0.0, 2.0, 0.01, 1.0), + scalar_spec("fade", "Smoothing", 0.0, 1.0, 0.01, 0.35), + scalar_spec("tempo_multiplier", "Tempo Multiplier", 0.25, 8.0, 0.05, 1.0), + text_spec("color_mode", "Color Mode", "dual"), + text_spec("primary_color", "Primary Color", "#4D7CFF"), + text_spec("secondary_color", "Secondary Color", "#0E1630"), + text_spec("palette", "Palette", "Laser Club"), + ]; + + for key in extra_keys { + specs.push(match key { + "direction" => text_spec("direction", "Direction", "left_to_right"), + "checker_mode" => text_spec("checker_mode", "Checker Mode", "classic"), + "scan_style" => text_spec("scan_style", "Scan Style", "line"), + "angle" => scalar_spec("angle", "Angle", 0.0, 315.0, 45.0, 0.0), + "on_width" => scalar_spec("on_width", "On Width", 0.1, 2.0, 0.05, 1.0), + "off_width" => scalar_spec("off_width", "Off Width", 0.0, 2.0, 0.05, 1.0), + "band_thickness" => { + scalar_spec("band_thickness", "Band Thickness", 0.1, 2.0, 0.05, 0.8) + } + "flip_horizontal" => toggle_spec("flip_horizontal", "Flip Horizontal", false), + "flip_vertical" => toggle_spec("flip_vertical", "Flip Vertical", false), + "strobe_mode" => text_spec("strobe_mode", "Strobe Mode", "global"), + "stopwatch_mode" => text_spec("stopwatch_mode", "Stopwatch Mode", "sync"), + "symmetry" => text_spec("symmetry", "Mirror", "none"), + "center_pulse_mode" => text_spec("center_pulse_mode", "Pulse Mode", "expand"), + "block_size" => scalar_spec("block_size", "Block Size", 0.1, 6.0, 0.1, 1.0), + "pixel_group_size" => { + scalar_spec("pixel_group_size", "Pixel Group", 1.0, 5.0, 1.0, 1.0) + } + "strobe_duty_cycle" => { + scalar_spec("strobe_duty_cycle", "Duty / Density", 0.02, 0.98, 0.01, 0.5) + } + "randomness" => scalar_spec("randomness", "Randomness", 0.0, 1.5, 0.01, 0.35), + _ => text_spec(key, key, ""), + }); + } + + specs +} + fn palette_from_scene(scene: &SceneRuntime) -> Vec { if scene.palette.is_empty() { vec![RgbColor::WHITE] @@ -657,11 +1934,6 @@ fn distance_wrap(a: f32, b: f32) -> f32 { diff.min(1.0 - diff) } -fn smooth_peak(distance: f32, width: f32) -> f32 { - let normalized = (1.0 - distance / width.max(0.0001)).clamp(0.0, 1.0); - normalized * normalized -} - impl RgbColor { const BLACK: Self = Self { r: 0, g: 0, b: 0 }; const WHITE: Self = Self { @@ -688,6 +1960,14 @@ impl RgbColor { } } + fn complementary(self) -> Self { + Self { + r: 255u8.saturating_sub(self.r), + g: 255u8.saturating_sub(self.g), + b: 255u8.saturating_sub(self.b), + } + } + fn average(colors: &[Self]) -> Self { if colors.is_empty() { return Self::BLACK; @@ -732,6 +2012,10 @@ mod tests { fn registry_builds_catalog_for_project() { let registry = PatternRegistry::new(); let catalog = registry.catalog(&sample_project()); + assert!(catalog + .patterns + .iter() + .any(|pattern| pattern.pattern_id == "solid")); assert!(catalog .patterns .iter() @@ -765,8 +2049,48 @@ mod tests { let registry = PatternRegistry::new(); let project = sample_project(); let scene = registry.initial_scene(&project); - let preview = registry.render_preview(&scene, 0, 18, 450); - assert_eq!(preview.sample_led_hex.len(), 6); + let preview = registry.render_preview(&scene, 0, 0, 3, 6, 106, 450); + assert_eq!(preview.sample_led_hex.len(), 106); assert!(preview.representative_color_hex.starts_with('#')); } + + #[test] + fn canonical_pattern_definitions_expose_legacy_common_parameters() { + let registry = PatternRegistry::new(); + let catalog = registry.catalog(&sample_project()); + let scan = catalog + .patterns + .iter() + .find(|pattern| pattern.pattern_id == "scan") + .expect("scan pattern"); + let row_gradient = catalog + .patterns + .iter() + .find(|pattern| pattern.pattern_id == "row_gradient") + .expect("row gradient pattern"); + + assert!(scan.parameters.iter().any(|param| param.key == "fade")); + assert!(scan + .parameters + .iter() + .any(|param| param.key == "tempo_multiplier")); + assert!(scan + .parameters + .iter() + .any(|param| param.key == "band_thickness")); + assert!(scan + .parameters + .iter() + .any(|param| param.key == "flip_horizontal")); + assert!(scan + .parameters + .iter() + .any(|param| param.key == "flip_vertical")); + assert!(row_gradient + .parameters + .iter() + .any(|param| param.key == "symmetry")); + assert!(scan.parameters.iter().any(|param| param.key == "speed" + && param.default_value == SceneParameterValue::Scalar(0.45))); + } } diff --git a/crates/infinity_host/src/show_store.rs b/crates/infinity_host/src/show_store.rs index b41189e..6e7e0af 100644 --- a/crates/infinity_host/src/show_store.rs +++ b/crates/infinity_host/src/show_store.rs @@ -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, +} + +#[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, + #[serde(default)] + pub panels: Vec, +} + +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, pub global: PersistedGlobalState, + #[serde(default)] + pub technical: PersistedTechnicalState, pub user_presets: Vec, pub user_groups: Vec, pub creative_snapshots: Vec, @@ -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"); diff --git a/crates/infinity_host/src/simulation.rs b/crates/infinity_host/src/simulation.rs index b26dabc..c19dc24 100644 --- a/crates/infinity_host/src/simulation.rs +++ b/crates/infinity_host/src/simulation.rs @@ -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, + 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::>(); @@ -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::>(); @@ -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 { 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 { - 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, 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, 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) + })); + } } diff --git a/crates/infinity_host/tests/golden_traces/01_staged_pattern_transition_commit.json b/crates/infinity_host/tests/golden_traces/01_staged_pattern_transition_commit.json new file mode 100644 index 0000000..fe62bf5 --- /dev/null +++ b/crates/infinity_host/tests/golden_traces/01_staged_pattern_transition_commit.json @@ -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 + } + } + } + ] +} diff --git a/crates/infinity_host/tests/golden_traces/02_group_update_parameter_commit.json b/crates/infinity_host/tests/golden_traces/02_group_update_parameter_commit.json new file mode 100644 index 0000000..3dddb29 --- /dev/null +++ b/crates/infinity_host/tests/golden_traces/02_group_update_parameter_commit.json @@ -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 } + ] + } + } + } + ] +} diff --git a/crates/infinity_host/tests/golden_traces/03_preset_recall_during_transition.json b/crates/infinity_host/tests/golden_traces/03_preset_recall_during_transition.json new file mode 100644 index 0000000..9d3ef23 --- /dev/null +++ b/crates/infinity_host/tests/golden_traces/03_preset_recall_during_transition.json @@ -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" + } + } + } + ] +} diff --git a/crates/infinity_host/tests/golden_traces/04_blackout_during_staged_session.json b/crates/infinity_host/tests/golden_traces/04_blackout_during_staged_session.json new file mode 100644 index 0000000..2c3c3db --- /dev/null +++ b/crates/infinity_host/tests/golden_traces/04_blackout_during_staged_session.json @@ -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 } + ] + } + } + } + ] +} diff --git a/crates/infinity_host/tests/golden_traces/05_stateless_rejects_staged_primitive.json b/crates/infinity_host/tests/golden_traces/05_stateless_rejects_staged_primitive.json new file mode 100644 index 0000000..5541947 --- /dev/null +++ b/crates/infinity_host/tests/golden_traces/05_stateless_rejects_staged_primitive.json @@ -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" + } + } + ] +} diff --git a/crates/infinity_host/tests/show_control_v1_golden.rs b/crates/infinity_host/tests/show_control_v1_golden.rs new file mode 100644 index 0000000..f5bc184 --- /dev/null +++ b/crates/infinity_host/tests/show_control_v1_golden.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct GoldenStep { + request: ExternalBridgeRequest, + expect: GoldenExpectation, +} + +#[derive(Debug, Deserialize)] +struct GoldenExpectation { + result_type: String, + #[serde(default)] + summary_contains: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + session: Option, + #[serde(default)] + state: Option, +} + +#[derive(Debug, Deserialize)] +struct ExpectedSession { + #[serde(default)] + pattern_id: Option, + #[serde(default)] + has_group_target: Option, + #[serde(default)] + group_id: Option, + #[serde(default)] + parameter_keys: Vec, + #[serde(default)] + transition_style: Option, + #[serde(default)] + transition_duration_ms: Option, +} + +#[derive(Debug, Deserialize)] +struct ExpectedState { + #[serde(default)] + blackout: Option, + #[serde(default)] + selected_group: Option, + #[serde(default)] + active_pattern_id: Option, + #[serde(default)] + preset_id: Option, + #[serde(default)] + active_scene_group: Option, + #[serde(default)] + transition_style: Option, + #[serde(default)] + transition_duration_ms: Option, + #[serde(default)] + active_transition_present: Option, + #[serde(default)] + event_message_contains: Option, + #[serde(default)] + scalar_parameters: Vec, +} + +#[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 = 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 { + 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::>(); + 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") +} diff --git a/crates/infinity_host_api/src/dto.rs b/crates/infinity_host_api/src/dto.rs index 8211321..bd96aa1 100644 --- a/crates/infinity_host_api/src/dto.rs +++ b/crates/infinity_host_api/src/dto.rs @@ -1,8 +1,8 @@ -use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState}; +use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState}; use infinity_host::{ - CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, - SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind, - TestPatternKind, + CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, OutputBackendMode, + PreviewSource, SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind, + TechnicalSnapshot, TestPatternKind, }; use serde::{Deserialize, Serialize}; @@ -80,9 +80,40 @@ pub struct ApiErrorBody { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiDiscoveryScanRequest { + pub subnet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiDiscoveryScanResponse { + pub api_version: &'static str, + pub subnet: String, + pub scanned_hosts: usize, + pub reachable_hosts: usize, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiDiscoveryResult { + pub ip: String, + pub reachable: bool, + pub detected_type: ApiDiscoveredNodeType, + pub hostname: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiDiscoveredNodeType { + Wled, + Unknown, + NativeNode, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ApiStateSnapshot { pub system: ApiSystemInfo, + pub technical: ApiTechnicalState, pub global: ApiGlobalState, pub engine: ApiEngineState, pub active_scene: ApiActiveScene, @@ -134,6 +165,21 @@ pub struct ApiSystemInfo { pub topology_label: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiTechnicalState { + pub backend_mode: ApiOutputBackendMode, + 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 ApiOutputBackendMode { + PreviewOnly, + DdpWled, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ApiGlobalState { pub blackout: bool, @@ -258,6 +304,7 @@ pub struct ApiPanelStatus { pub node_id: String, pub panel_position: ApiPanelPosition, pub physical_output_name: String, + pub driver_kind: ApiDriverKind, pub driver_reference: String, pub led_count: u16, pub direction: ApiLedDirection, @@ -337,6 +384,18 @@ pub enum ApiColorOrder { Bgr, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiDriverKind { + PendingValidation, + Gpio, + RmtChannel, + I2sLane, + UartPort, + SpiBus, + ExternalDriver, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ApiValidationState { @@ -356,6 +415,30 @@ pub enum ApiParameterValue { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case", tag = "type", content = "payload")] pub enum ApiCommand { + SetOutputBackendMode { + mode: ApiOutputBackendMode, + }, + SetLiveOutputEnabled { + enabled: bool, + }, + SetOutputFps { + output_fps: u16, + }, + SetNodeReservedIp { + node_id: String, + reserved_ip: Option, + }, + UpdatePanelMapping { + node_id: String, + panel_position: ApiPanelPosition, + physical_output_name: String, + driver_kind: ApiDriverKind, + driver_reference: String, + led_count: u16, + direction: ApiLedDirection, + color_order: ApiColorOrder, + enabled: bool, + }, SetBlackout { enabled: bool, }, @@ -390,6 +473,9 @@ pub enum ApiCommand { preset_id: String, overwrite: bool, }, + DeletePreset { + preset_id: String, + }, SaveCreativeSnapshot { snapshot_id: String, label: Option, @@ -546,6 +632,7 @@ impl ApiStateSnapshot { schema_version: snapshot.system.schema_version, topology_label: snapshot.system.topology_label.clone(), }, + technical: map_technical_snapshot(&snapshot.technical), global: ApiGlobalState { blackout: snapshot.global.blackout, master_brightness: snapshot.global.master_brightness, @@ -615,6 +702,7 @@ impl ApiStateSnapshot { node_id: panel.target.node_id.clone(), panel_position: map_panel_position(&panel.target.panel_position), physical_output_name: panel.physical_output_name.clone(), + driver_kind: map_driver_kind(panel.driver_kind.clone()), driver_reference: panel.driver_reference.clone(), led_count: panel.led_count, direction: map_led_direction(panel.direction.clone()), @@ -654,6 +742,43 @@ impl ApiPreviewSnapshot { impl ApiCommandRequest { pub fn into_host_command(self) -> Result { match self.command { + ApiCommand::SetOutputBackendMode { mode } => Ok(HostCommand::SetOutputBackendMode( + map_output_backend_mode(mode), + )), + ApiCommand::SetLiveOutputEnabled { enabled } => { + Ok(HostCommand::SetLiveOutputEnabled(enabled)) + } + ApiCommand::SetOutputFps { output_fps } => Ok(HostCommand::SetOutputFps(output_fps)), + ApiCommand::SetNodeReservedIp { + node_id, + reserved_ip, + } => Ok(HostCommand::SetNodeReservedIp { + node_id, + reserved_ip, + }), + ApiCommand::UpdatePanelMapping { + node_id, + panel_position, + physical_output_name, + driver_kind, + driver_reference, + led_count, + direction, + color_order, + enabled, + } => Ok(HostCommand::UpdatePanelMapping { + target: infinity_host::PanelTarget { + node_id, + panel_position: map_command_panel_position(panel_position), + }, + physical_output_name, + driver_kind: map_command_driver_kind(driver_kind), + driver_reference, + led_count, + direction: map_command_led_direction(direction), + color_order: map_command_color_order(color_order), + enabled, + }), ApiCommand::SetBlackout { enabled } => Ok(HostCommand::SetBlackout(enabled)), ApiCommand::SetMasterBrightness { value } => { Ok(HostCommand::SetMasterBrightness(value)) @@ -691,6 +816,7 @@ impl ApiCommandRequest { preset_id, overwrite, }), + ApiCommand::DeletePreset { preset_id } => Ok(HostCommand::DeletePreset { preset_id }), ApiCommand::SaveCreativeSnapshot { snapshot_id, label, @@ -770,6 +896,71 @@ fn map_color_order(color_order: ColorOrder) -> ApiColorOrder { } } +fn map_command_color_order(color_order: ApiColorOrder) -> ColorOrder { + match color_order { + ApiColorOrder::Rgb => ColorOrder::Rgb, + ApiColorOrder::Rbg => ColorOrder::Rbg, + ApiColorOrder::Grb => ColorOrder::Grb, + ApiColorOrder::Gbr => ColorOrder::Gbr, + ApiColorOrder::Brg => ColorOrder::Brg, + ApiColorOrder::Bgr => ColorOrder::Bgr, + } +} + +fn map_driver_kind(kind: DriverKind) -> ApiDriverKind { + match kind { + DriverKind::PendingValidation => ApiDriverKind::PendingValidation, + DriverKind::Gpio => ApiDriverKind::Gpio, + DriverKind::RmtChannel => ApiDriverKind::RmtChannel, + DriverKind::I2sLane => ApiDriverKind::I2sLane, + DriverKind::UartPort => ApiDriverKind::UartPort, + DriverKind::SpiBus => ApiDriverKind::SpiBus, + DriverKind::ExternalDriver => ApiDriverKind::ExternalDriver, + } +} + +fn map_command_driver_kind(kind: ApiDriverKind) -> DriverKind { + match kind { + ApiDriverKind::PendingValidation => DriverKind::PendingValidation, + ApiDriverKind::Gpio => DriverKind::Gpio, + ApiDriverKind::RmtChannel => DriverKind::RmtChannel, + ApiDriverKind::I2sLane => DriverKind::I2sLane, + ApiDriverKind::UartPort => DriverKind::UartPort, + ApiDriverKind::SpiBus => DriverKind::SpiBus, + ApiDriverKind::ExternalDriver => DriverKind::ExternalDriver, + } +} + +fn map_output_backend_mode(mode: ApiOutputBackendMode) -> OutputBackendMode { + match mode { + ApiOutputBackendMode::PreviewOnly => OutputBackendMode::PreviewOnly, + ApiOutputBackendMode::DdpWled => OutputBackendMode::DdpWled, + } +} + +fn map_output_backend_mode_from_snapshot(mode: OutputBackendMode) -> ApiOutputBackendMode { + match mode { + OutputBackendMode::PreviewOnly => ApiOutputBackendMode::PreviewOnly, + OutputBackendMode::DdpWled => ApiOutputBackendMode::DdpWled, + } +} + +fn map_technical_snapshot(snapshot: &TechnicalSnapshot) -> ApiTechnicalState { + ApiTechnicalState { + backend_mode: map_output_backend_mode_from_snapshot(snapshot.backend_mode), + output_enabled: snapshot.output_enabled, + output_fps: snapshot.output_fps, + live_status: snapshot.live_status.clone(), + } +} + +fn map_command_led_direction(direction: ApiLedDirection) -> LedDirection { + match direction { + ApiLedDirection::Forward => LedDirection::Forward, + ApiLedDirection::Reverse => LedDirection::Reverse, + } +} + fn map_validation_state(state: ValidationState) -> ApiValidationState { match state { ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation, @@ -845,6 +1036,11 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue impl ApiCommand { pub fn kind_label(&self) -> &'static str { match self { + Self::SetOutputBackendMode { .. } => "set_output_backend_mode", + Self::SetLiveOutputEnabled { .. } => "set_live_output_enabled", + Self::SetOutputFps { .. } => "set_output_fps", + Self::SetNodeReservedIp { .. } => "set_node_reserved_ip", + Self::UpdatePanelMapping { .. } => "update_panel_mapping", Self::SetBlackout { .. } => "set_blackout", Self::SetMasterBrightness { .. } => "set_master_brightness", Self::SelectPattern { .. } => "select_pattern", @@ -855,6 +1051,7 @@ impl ApiCommand { Self::SetTransitionStyle { .. } => "set_transition_style", Self::TriggerPanelTest { .. } => "trigger_panel_test", Self::SavePreset { .. } => "save_preset", + Self::DeletePreset { .. } => "delete_preset", Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot", Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot", Self::UpsertGroup { .. } => "upsert_group", @@ -863,6 +1060,40 @@ impl ApiCommand { pub fn summary(&self) -> String { match self { + Self::SetOutputBackendMode { mode } => { + format!( + "output backend mode set to {}", + match mode { + ApiOutputBackendMode::PreviewOnly => "preview_only", + ApiOutputBackendMode::DdpWled => "ddp_wled", + } + ) + } + Self::SetLiveOutputEnabled { enabled } => { + if *enabled { + "live output enabled".to_string() + } else { + "live output disabled".to_string() + } + } + Self::SetOutputFps { output_fps } => format!("output fps set to {output_fps}"), + Self::SetNodeReservedIp { + node_id, + reserved_ip, + } => format!( + "node target updated: {} -> {}", + node_id, + reserved_ip.as_deref().unwrap_or("unassigned") + ), + Self::UpdatePanelMapping { + node_id, + panel_position, + .. + } => format!( + "panel mapping updated: {}:{}", + node_id, + panel_position.label() + ), Self::SetBlackout { enabled } => { if *enabled { "blackout enabled".to_string() @@ -909,6 +1140,7 @@ impl ApiCommand { format!("preset saved: {preset_id}") } } + Self::DeletePreset { preset_id } => format!("preset deleted: {preset_id}"), Self::SaveCreativeSnapshot { snapshot_id, overwrite, diff --git a/crates/infinity_host_api/src/server.rs b/crates/infinity_host_api/src/server.rs index 116f83a..acd64c4 100644 --- a/crates/infinity_host_api/src/server.rs +++ b/crates/infinity_host_api/src/server.rs @@ -1,19 +1,21 @@ use crate::dto::{ - ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse, + ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiDiscoveredNodeType, + ApiDiscoveryResult, ApiDiscoveryScanRequest, ApiDiscoveryScanResponse, ApiErrorResponse, ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse, ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION, }; use crate::websocket::{websocket_accept_value, write_text_frame}; use infinity_host::HostApiPort; +use serde_json::Value; use std::collections::HashMap; use std::io::{self, Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::{ atomic::{AtomicBool, Ordering}, - Arc, + mpsc, Arc, Mutex, }; use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::time::{Duration, Instant}; pub struct HostApiServer { local_addr: SocketAddr, @@ -141,6 +143,12 @@ fn handle_connection(mut stream: TcpStream, service: Arc) -> io Ok(()) => Ok(()), Err(error) => respond_error(&mut stream, error.status, error.code, error.message), }, + ("POST", "/api/v1/discovery/scan") => { + match handle_discovery_scan_post(&mut stream, request) { + Ok(()) => Ok(()), + Err(error) => respond_error(&mut stream, error.status, error.code, error.message), + } + } ("GET", "/") => respond_text( &mut stream, 200, @@ -153,12 +161,30 @@ fn handle_connection(mut stream: TcpStream, service: Arc) -> io "text/html; charset=utf-8", include_str!("../../../web/v1/index.html"), ), + ("GET", "/technical") => respond_text( + &mut stream, + 200, + "text/html; charset=utf-8", + include_str!("../../../web/v1/technical.html"), + ), + ("GET", "/technical.html") => respond_text( + &mut stream, + 200, + "text/html; charset=utf-8", + include_str!("../../../web/v1/technical.html"), + ), ("GET", "/app.js") => respond_text( &mut stream, 200, "application/javascript; charset=utf-8", include_str!("../../../web/v1/app.js"), ), + ("GET", "/technical.js") => respond_text( + &mut stream, + 200, + "application/javascript; charset=utf-8", + include_str!("../../../web/v1/technical.js"), + ), ("GET", "/styles.css") => respond_text( &mut stream, 200, @@ -307,6 +333,275 @@ fn handle_websocket( } } +fn handle_discovery_scan_post( + stream: &mut TcpStream, + request: HttpRequest, +) -> Result<(), ApiRequestError> { + let parsed = + serde_json::from_slice::(&request.body).map_err(|error| { + ApiRequestError { + status: 400, + code: "invalid_request_json".to_string(), + message: format!("discovery request body could not be parsed: {error}"), + } + })?; + let targets = parse_subnet_targets(&parsed.subnet).map_err(|message| ApiRequestError { + status: 400, + code: "invalid_subnet_cidr".to_string(), + message, + })?; + + let started_at = Instant::now(); + let mut results = scan_subnet_targets(&targets); + results.sort_by_key(|result| { + result + .ip + .parse::() + .map(u32::from) + .unwrap_or_default() + }); + let reachable_hosts = results.iter().filter(|result| result.reachable).count(); + + respond_json( + stream, + 200, + &ApiDiscoveryScanResponse { + api_version: API_VERSION, + subnet: parsed.subnet.trim().to_string(), + scanned_hosts: targets.len(), + reachable_hosts, + results, + }, + ) + .map_err(|error| ApiRequestError { + status: 500, + code: "response_write_failed".to_string(), + message: format!( + "discovery response could not be written after {} ms: {error}", + started_at.elapsed().as_millis() + ), + }) +} + +fn parse_subnet_targets(raw_subnet: &str) -> Result, String> { + const MAX_SCAN_HOSTS: u64 = 1024; + + let subnet = raw_subnet.trim(); + let (address, prefix) = subnet + .split_once('/') + .ok_or_else(|| format!("subnet '{subnet}' must be in CIDR form, e.g. 192.168.40.0/24"))?; + let ip = address + .trim() + .parse::() + .map_err(|_| format!("subnet '{subnet}' contains an invalid IPv4 address"))?; + let prefix = prefix + .trim() + .parse::() + .map_err(|_| format!("subnet '{subnet}' contains an invalid CIDR prefix"))?; + if prefix > 32 { + return Err(format!( + "subnet '{subnet}' has prefix {prefix}, expected 0..=32" + )); + } + + let host_span = 1u64 << (32u8.saturating_sub(prefix)); + if host_span > MAX_SCAN_HOSTS { + return Err(format!( + "subnet '{subnet}' spans {host_span} addresses, limit is {MAX_SCAN_HOSTS}" + )); + } + + let ip_u32 = u32::from(ip); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - u32::from(prefix)) + }; + let network = ip_u32 & mask; + let broadcast = network | !mask; + let (start, end) = if prefix >= 31 { + (network, broadcast) + } else { + (network.saturating_add(1), broadcast.saturating_sub(1)) + }; + + if start > end { + return Err(format!("subnet '{subnet}' has no scanable host addresses")); + } + + Ok((start..=end).map(Ipv4Addr::from).collect()) +} + +fn scan_subnet_targets(targets: &[Ipv4Addr]) -> Vec { + if targets.is_empty() { + return Vec::new(); + } + + let worker_count = usize::min(32, targets.len().max(1)); + let (job_sender, job_receiver) = mpsc::channel::(); + let job_receiver = Arc::new(Mutex::new(job_receiver)); + let (result_sender, result_receiver) = mpsc::channel::(); + let mut handles = Vec::with_capacity(worker_count); + + for _ in 0..worker_count { + let receiver = Arc::clone(&job_receiver); + let sender = result_sender.clone(); + handles.push(thread::spawn(move || loop { + let next_job = { + let guard = receiver.lock(); + match guard { + Ok(receiver) => receiver.recv().ok(), + Err(_) => None, + } + }; + let Some(ip) = next_job else { + break; + }; + let _ = sender.send(probe_ip(ip)); + })); + } + drop(result_sender); + + for ip in targets { + let _ = job_sender.send(*ip); + } + drop(job_sender); + + let mut results = Vec::with_capacity(targets.len()); + for _ in 0..targets.len() { + if let Ok(result) = result_receiver.recv() { + results.push(result); + } + } + + for handle in handles { + let _ = handle.join(); + } + results +} + +fn probe_ip(ip: Ipv4Addr) -> ApiDiscoveryResult { + let mut reachable = false; + let mut detected_type = ApiDiscoveredNodeType::Unknown; + let mut hostname = None; + + if let Some(info_probe) = probe_http_endpoint(ip, 80, "/json/info") { + reachable = true; + detected_type = detect_node_type(&info_probe.body, detected_type); + hostname = extract_probe_hostname(&info_probe); + } else if can_connect(ip, 80) { + reachable = true; + } + + if !reachable && can_connect(ip, 81) { + reachable = true; + } + + if detected_type == ApiDiscoveredNodeType::Unknown { + if let Some(node_probe) = probe_http_endpoint(ip, 80, "/api/v1/node/info") { + reachable = true; + detected_type = detect_node_type(&node_probe.body, detected_type); + if hostname.is_none() { + hostname = extract_probe_hostname(&node_probe); + } + } else if let Some(state_probe) = probe_http_endpoint(ip, 80, "/api/v1/state") { + reachable = true; + detected_type = detect_node_type(&state_probe.body, detected_type); + if hostname.is_none() { + hostname = extract_probe_hostname(&state_probe); + } + } + } + + ApiDiscoveryResult { + ip: ip.to_string(), + reachable, + detected_type, + hostname, + } +} + +fn can_connect(ip: Ipv4Addr, port: u16) -> bool { + let address = SocketAddr::new(IpAddr::V4(ip), port); + TcpStream::connect_timeout(&address, Duration::from_millis(120)).is_ok() +} + +#[derive(Debug)] +struct HttpProbe { + headers: HashMap, + body: String, +} + +fn probe_http_endpoint(ip: Ipv4Addr, port: u16, path: &str) -> Option { + let address = SocketAddr::new(IpAddr::V4(ip), port); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(120)).ok()?; + let _ = stream.set_read_timeout(Some(Duration::from_millis(180))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(120))); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {ip}\r\nConnection: close\r\nAccept: application/json\r\n\r\n" + ); + stream.write_all(request.as_bytes()).ok()?; + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).ok()?; + let header_end = find_header_end(&raw)?; + let header_text = String::from_utf8_lossy(&raw[..header_end]); + let headers = header_text + .lines() + .skip(1) + .filter_map(|line| line.split_once(':')) + .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect::>(); + let body = String::from_utf8_lossy(raw.get(header_end + 4..).unwrap_or_default()).to_string(); + Some(HttpProbe { headers, body }) +} + +fn detect_node_type(body: &str, fallback: ApiDiscoveredNodeType) -> ApiDiscoveredNodeType { + let lowered = body.to_ascii_lowercase(); + if lowered.contains("\"wled\"") + || lowered.contains("\"brand\":\"wled\"") + || lowered.contains("\"product\":\"wled\"") + { + return ApiDiscoveredNodeType::Wled; + } + if lowered.contains("\"native_node\"") + || lowered.contains("\"node_kind\":\"native\"") + || lowered.contains("\"infinity_node\"") + { + return ApiDiscoveredNodeType::NativeNode; + } + fallback +} + +fn extract_probe_hostname(probe: &HttpProbe) -> Option { + if let Ok(json) = serde_json::from_str::(&probe.body) { + let name = json + .get("name") + .and_then(Value::as_str) + .or_else(|| { + json.get("info") + .and_then(|value| value.get("name")) + .and_then(Value::as_str) + }) + .or_else(|| json.get("mdns").and_then(Value::as_str)); + if let Some(name) = name { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + probe.headers.get("server").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn send_stream_message( stream: &mut TcpStream, sequence: u64, diff --git a/crates/infinity_host_api/tests/contract.rs b/crates/infinity_host_api/tests/contract.rs index fe49e1c..892336c 100644 --- a/crates/infinity_host_api/tests/contract.rs +++ b/crates/infinity_host_api/tests/contract.rs @@ -37,7 +37,9 @@ struct HttpResponse { fn root_and_web_assets_target_the_versioned_api_contract() { let server = start_server(); let html = send_http_request(server.local_addr(), "GET", "/", None); + let technical_html = send_http_request(server.local_addr(), "GET", "/technical", None); let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None); + let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None); let styles = send_http_request(server.local_addr(), "GET", "/styles.css", None); assert_eq!(html.status_code, 200); @@ -46,19 +48,66 @@ fn root_and_web_assets_target_the_versioned_api_contract() { .get("content-type") .expect("content-type header") .starts_with("text/html")); - assert!(html.body.contains("Preset Capture")); + assert!(html.body.contains("Mapping Settings")); + assert!(html.body.contains("

Preview

")); assert!(html.body.contains("Creative Snapshots")); - assert!(html.body.contains("Event Stream")); + assert!(html.body.contains("Selected Tile")); + assert!(html.body.contains("Utilities")); + assert!(html.body.contains("View & Output")); assert!(html.body.contains("Pending Transition")); assert!(html.body.contains("Trigger Transition")); + assert!(html.body.contains("session-scope-label")); + assert!(html.body.contains("Fade Go")); + assert!(html.body.contains("Status & Eventfeed")); + assert!(html.body.contains("Mapping Settings")); + assert!(!html.body.contains("

Groups

")); + assert!(html.body.contains("work-mode-select")); + assert!(html.body.contains("LEDs Only")); + assert!(!html.body.contains("preview-mode-select")); + + assert_eq!(technical_html.status_code, 200); + assert!(technical_html + .body + .contains("Infinity Vis Mapping Settings")); + assert!(technical_html.body.contains("Backend & Output")); + assert!(technical_html.body.contains("Node / IP Discovery")); + assert!(technical_html.body.contains("Discover / Scan")); + assert!(technical_html.body.contains("Node Targets")); + assert!(technical_html.body.contains("Panel Mapping")); + assert!(technical_html.body.contains("DDP (WLED)")); + assert!(technical_html.body.contains("Preview Only")); + assert!(technical_html.body.contains("Creative Surface")); assert_eq!(app_js.status_code, 200); assert!(app_js.body.contains("/api/v1/state")); assert!(app_js.body.contains("/api/v1/preview")); assert!(app_js.body.contains("save_preset")); + assert!(app_js.body.contains("delete_preset")); assert!(app_js.body.contains("save_creative_snapshot")); assert!(app_js.body.contains("show_control_session_required")); assert!(app_js.body.contains("trigger_transition")); + assert!(app_js.body.contains("trigger_panel_test")); + assert!(app_js.body.contains("commitState")); + assert!(app_js.body.contains("Center Pulse")); + assert!(app_js.body.contains("Checkerd")); + assert!(app_js.body.contains("Wave Line")); + assert!(app_js.body.contains("tile-led")); + assert!(app_js.body.contains("show_event")); + assert!(app_js.body.contains("test_edit")); + assert!(app_js.body.contains("direct_mode_active")); + assert!(!app_js.body.contains("Tile Colors")); + assert!(!app_js.body.contains("Technical")); + + assert_eq!(technical_js.status_code, 200); + assert!(technical_js.body.contains("/api/v1/state")); + assert!(technical_js.body.contains("set_output_backend_mode")); + assert!(technical_js.body.contains("set_live_output_enabled")); + assert!(technical_js.body.contains("set_output_fps")); + assert!(technical_js.body.contains("set_node_reserved_ip")); + assert!(technical_js.body.contains("update_panel_mapping")); + assert!(technical_js.body.contains("/api/v1/discovery/scan")); + assert!(technical_js.body.contains("runDiscoveryScan")); + assert!(technical_js.body.contains("DDP (WLED)")); assert_eq!(styles.status_code, 200); assert!(styles .headers @@ -66,6 +115,9 @@ fn root_and_web_assets_target_the_versioned_api_contract() { .expect("content-type header") .starts_with("text/css")); assert!(styles.body.contains(".preview-grid")); + assert!(styles.body.contains(".tile-led-ring")); + assert!(styles.body.contains(".technical-workspace")); + assert!(styles.body.contains(".technical-table")); server.shutdown(); } @@ -77,9 +129,11 @@ fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() { let mut stream = open_websocket(server.local_addr()); assert_eq!(html.status_code, 200); - assert!(html.body.contains("Infinity Vis")); + assert!(html.body.contains("Mapping Settings")); + assert!(html.body.contains("

Preview

")); assert!(html.body.contains("connection-pill")); assert!(html.body.contains("preview-grid")); + assert!(html.body.contains("workspace-stage")); let first_frame = read_websocket_text_frame(&mut stream); let second_frame = read_websocket_text_frame(&mut stream); @@ -108,10 +162,28 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() { assert_eq!(state_body["api_version"], "v1"); assert!(state_body.get("state").is_some()); assert!(state_body.get("preview").is_none()); + assert_eq!( + state_body["state"]["technical"]["backend_mode"], + "preview_only" + ); + assert_eq!(state_body["state"]["technical"]["output_enabled"], false); + assert_eq!(state_body["state"]["technical"]["output_fps"], 40); assert_eq!( state_body["state"]["nodes"].as_array().map(Vec::len), Some(6) ); + assert!(state_body["state"]["nodes"] + .as_array() + .expect("nodes array") + .iter() + .all(|node| node["connection"] == "offline" + && node["error_status"] == "preview only - live output disabled")); + assert!(state_body["state"]["panels"] + .as_array() + .expect("panels array") + .iter() + .all(|panel| panel["connection"] == "offline" + && panel["driver_kind"] == "pending_validation")); assert_eq!(preview.status_code, 200); assert_eq!(preview_body["api_version"], "v1"); @@ -130,6 +202,110 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() { server.shutdown(); } +#[test] +fn technical_surface_commands_update_backend_node_targets_and_panel_mapping() { + let server = start_server(); + + let responses = [ + send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#, + ), + send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#, + ), + send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_output_fps","payload":{"output_fps":55}}}"#, + ), + send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_node_reserved_ip","payload":{"node_id":"node-01","reserved_ip":"192.168.40.151"}}}"#, + ), + send_command_json( + server.local_addr(), + r#"{"command":{"type":"update_panel_mapping","payload":{"node_id":"node-01","panel_position":"top","physical_output_name":"GPIO 18","driver_kind":"gpio","driver_reference":"GPIO18","led_count":120,"direction":"reverse","color_order":"rgb","enabled":false}}}"#, + ), + ]; + + for response in responses { + assert_eq!(response.status_code, 200); + } + + let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None); + let state_body: Value = serde_json::from_str(&state.body).expect("state json"); + + assert_eq!(state_body["state"]["technical"]["backend_mode"], "ddp_wled"); + assert_eq!(state_body["state"]["technical"]["output_enabled"], true); + assert_eq!(state_body["state"]["technical"]["output_fps"], 55); + assert_eq!( + state_body["state"]["technical"]["live_status"], + "DDP (WLED) armed - 0/6 nodes online" + ); + assert!(state_body["state"]["nodes"] + .as_array() + .expect("nodes array") + .iter() + .any(|node| node["node_id"] == "node-01" + && node["reserved_ip"] == "192.168.40.151" + && node["error_status"] == "ddp (wled) output enabled - no live client connected")); + assert!(state_body["state"]["panels"] + .as_array() + .expect("panels array") + .iter() + .any(|panel| panel["node_id"] == "node-01" + && panel["panel_position"] == "top" + && panel["physical_output_name"] == "GPIO 18" + && panel["driver_kind"] == "gpio" + && panel["driver_reference"] == "GPIO18" + && panel["led_count"] == 120 + && panel["direction"] == "reverse" + && panel["color_order"] == "rgb" + && panel["enabled"] == false + && panel["error_status"] == "output disabled")); + + server.shutdown(); +} + +#[test] +fn discovery_scan_endpoint_returns_structured_results_and_rejects_invalid_subnets() { + let server = start_server(); + + let invalid = send_http_request( + server.local_addr(), + "POST", + "/api/v1/discovery/scan", + Some(r#"{"subnet":"192.168.40.0"}"#), + ); + let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid subnet json"); + assert_eq!(invalid.status_code, 400); + assert_eq!(invalid_body["error"]["code"], "invalid_subnet_cidr"); + + let valid = send_http_request( + server.local_addr(), + "POST", + "/api/v1/discovery/scan", + Some(r#"{"subnet":"192.168.40.0/30"}"#), + ); + let valid_body: Value = serde_json::from_str(&valid.body).expect("valid subnet json"); + assert_eq!(valid.status_code, 200); + assert_eq!(valid_body["api_version"], "v1"); + assert_eq!(valid_body["subnet"], "192.168.40.0/30"); + assert_eq!(valid_body["scanned_hosts"], 2); + assert!(valid_body["results"].is_array()); + if let Some(first) = valid_body["results"] + .as_array() + .and_then(|items| items.first()) + { + assert!(first.get("ip").is_some()); + assert!(first.get("reachable").is_some()); + assert!(first.get("detected_type").is_some()); + } + + server.shutdown(); +} + #[test] fn command_flow_updates_group_parameters_transition_and_blackout() { let server = start_server(); @@ -260,6 +436,47 @@ fn presets_and_creative_snapshots_persist_across_restart() { let _ = std::fs::remove_file(runtime_state_path); } +#[test] +fn runtime_presets_can_be_deleted_but_builtin_presets_are_protected() { + let server = start_server(); + + let save = send_command_json( + server.local_addr(), + r#"{"command":{"type":"save_preset","payload":{"preset_id":"runtime_delete_me","overwrite":false}}}"#, + ); + assert_eq!(save.status_code, 200); + + let delete_runtime = send_command_json( + server.local_addr(), + r#"{"command":{"type":"delete_preset","payload":{"preset_id":"runtime_delete_me"}}}"#, + ); + assert_eq!(delete_runtime.status_code, 200); + + let catalog_after_delete = + send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None); + let catalog_body: Value = + serde_json::from_str(&catalog_after_delete.body).expect("catalog json"); + assert!(!catalog_body["presets"] + .as_array() + .expect("preset array") + .iter() + .any(|preset| preset["preset_id"] == "runtime_delete_me")); + + let delete_builtin = send_command_json( + server.local_addr(), + r#"{"command":{"type":"delete_preset","payload":{"preset_id":"ocean_gradient"}}}"#, + ); + let delete_builtin_body: Value = + serde_json::from_str(&delete_builtin.body).expect("delete builtin json"); + assert_eq!(delete_builtin.status_code, 400); + assert_eq!( + delete_builtin_body["error"]["code"], + "preset_delete_forbidden" + ); + + server.shutdown(); +} + #[test] fn show_control_flows_cover_runtime_group_preset_snapshot_transition_blackout_and_eventfeed() { let server = start_server(); diff --git a/data/runtime_state.json b/data/runtime_state.json new file mode 100644 index 0000000..dac1727 --- /dev/null +++ b/data/runtime_state.json @@ -0,0 +1,87 @@ +{ + "schema_version": 1, + "saved_at_unix_ms": 1776637175917, + "runtime": { + "active_scene": { + "preset_id": null, + "pattern_id": "saw", + "seed": 104, + "palette": [ + "#FF7A00", + "#FFD166" + ], + "parameters": { + "brightness": { + "kind": "scalar", + "value": 1.0 + }, + "center_pulse_mode": { + "kind": "text", + "value": "expand" + }, + "checker_mode": { + "kind": "text", + "value": "classic" + }, + "color_mode": { + "kind": "text", + "value": "#000000" + }, + "direction": { + "kind": "text", + "value": "left_to_right" + }, + "fade": { + "kind": "scalar", + "value": 0.0 + }, + "intensity": { + "kind": "scalar", + "value": 1.0 + }, + "palette": { + "kind": "text", + "value": "Laser Club" + }, + "primary_color": { + "kind": "text", + "value": "#1A5FB4" + }, + "secondary_color": { + "kind": "text", + "value": "#000000" + }, + "speed": { + "kind": "scalar", + "value": 1.05 + }, + "symmetry": { + "kind": "text", + "value": "none" + }, + "tempo_multiplier": { + "kind": "scalar", + "value": 1.0 + } + }, + "target_group": "all_panels", + "blackout": false + }, + "global": { + "blackout": false, + "master_brightness": 1.0, + "transition_duration_ms": 0, + "transition_style": "snap" + }, + "technical": { + "backend_mode": "preview_only", + "output_enabled": false, + "output_fps": 40, + "nodes": [], + "panels": [] + }, + "user_presets": [], + "user_groups": [], + "creative_snapshots": [] + } +} \ No newline at end of file diff --git a/docs/control_ownership.md b/docs/control_ownership.md new file mode 100644 index 0000000..000ca8f --- /dev/null +++ b/docs/control_ownership.md @@ -0,0 +1,92 @@ +# Control Ownership + +## Ziel + +Diese Regeln definieren die konfliktfreie Steuersemantik zwischen allen aktuellen und spaeteren Control-Quellen auf derselben Host-Core-v1-Aussenkante. + +Betroffene Quellen: + +- kreative Web-UI +- technische Desktop-GUI +- generischer externer Control-Adapter +- spaetere grandMA-Anbindung + +## Gemeinsame Grundregel + +Der Host-Core bleibt die einzige mutierende Autoritaet fuer Show-State. + +Keine Quelle darf: + +- intern an Simulation-, Persistenz- oder UI-Zustaende vorbeischreiben +- eigene Sonderpfade fuer Pattern-, Preset-, Group- oder Transition-Logik einziehen +- die LED-Taktung oder Timing-Autoritaet uebernehmen + +## Rollen + +### Web-UI + +- kreative Oberflaeche +- darf direkte Primitive und lokale staged Sessions benutzen +- staged Zustand bleibt lokal, bis `trigger_transition` explizit committed wird + +### technische Desktop-GUI + +- Engineering- und Diagnoseoberflaeche +- bevorzugt direkte Operationen, Beobachtung und Admin-Aktionen +- bekommt keine priorisierte Besitzrolle gegenueber anderen Quellen + +### externer Control-Adapter + +- darf nur auf die eingefrorene v1-Primitive-Semantik abbilden +- staged Sessions muessen pro externer Session sauber getrennt gehalten werden +- Fehlercodes aus dem Host werden unveraendert durchgereicht + +### spaetere grandMA-Anbindung + +- ist spaeter nur eine weitere Quelle auf derselben Bridge-/Primitive-Aussenkante +- bekommt keine Sonderrechte gegenueber Web-UI, GUI oder anderen Adaptern + +## Konfliktregeln + +### Direkte Operationen + +- direkte Primitive mutieren sofort den globalen Host-State +- letzte erfolgreich ausgefuehrte Mutation gewinnt +- dazu gehoeren insbesondere: + - `blackout` + - `recall_preset` + - `recall_creative_snapshot` + - `set_master_brightness` + - `upsert_group` + +### Staged Operationen + +- staged Primitive mutieren den globalen Host-State nicht sofort +- staged Zustand gehoert immer nur zur lokalen Session der jeweiligen Quelle +- staged Sessions duerfen parallel existieren +- staged Inhalt wird erst mit `trigger_transition` global wirksam + +### Commit-Verhalten + +- `trigger_transition` ist der einzige Commit-Punkt fuer staged Pattern-/Parameter-/Transition-Intents +- ein erfolgreicher Commit konsumiert nur die staged Session der commitenden Quelle +- andere offene Sessions bleiben unveraendert bestehen + +### Beobachtung + +- `request_snapshot` und State-Projektionen sind read-only +- Beobachtung erzeugt keinen Ownership-Anspruch auf den Show-State + +## Praktische Auswirkungen + +- es gibt aktuell kein verteiltes Locking zwischen Quellen +- konfliktfreie Zusammenarbeit entsteht ueber: + - identische Primitive-Semantik + - lokale Session-Isolation fuer staged Flows + - last-write-wins fuer direkte globale Mutationen + - explizite Commits statt impliziter Side Effects + +## Persistenz + +- nur global wirksame Host-Mutationen und Persistenz-Kommandos beeinflussen Runtime-State-Dateien +- uncommittete staged Sessions sind absichtlich nicht Teil des globalen Persistenzzustands diff --git a/docs/external_control_bridge.md b/docs/external_control_bridge.md new file mode 100644 index 0000000..bbde744 --- /dev/null +++ b/docs/external_control_bridge.md @@ -0,0 +1,126 @@ +# External Control Bridge + +## Zweck + +Der External-Control-Bridge-Prozess ist eine duenne generische Prozess-Aussenkante fuer die eingefrorene Show-Control-v1-Semantik. + +Er: + +- bildet externe Commands auf die bestehenden Show-Control-Primitives ab +- haelt staged Sessions pro `session_id` +- reicht Host-Fehlercodes unveraendert durch +- fuegt keine neue Geschaeftslogik hinzu + +Implementierung: + +- `crates/infinity_host/src/external_bridge.rs` +- CLI-Einstieg ueber `cargo run -p infinity_host -- external-control-bridge ...` + +## Start + +```bash +. "$HOME/.cargo/env" +cargo run -p infinity_host -- external-control-bridge --config config/project.example.toml --runtime-state data/runtime_state.json +``` + +Der Prozess nutzt `stdin`/`stdout` im JSONL-Format: + +- eine JSON-Nachricht pro Zeile nach `stdin` +- eine JSON-Antwort pro Zeile auf `stdout` + +## Request-Form + +```json +{ + "request_id": "ext-1", + "session_id": "desk-a", + "command": { + "type": "execute_primitive", + "payload": { + "primitive": { + "primitive": "set_pattern", + "payload": { + "pattern_id": "noise" + } + } + } + } +} +``` + +Weitere Bridge-Commands: + +- `execute_primitive` +- `get_state` +- `clear_session` + +Hinweis: + +- `request_snapshot` bleibt ein Show-Control-Primitive und laeuft deshalb ueber `execute_primitive` +- `get_state` liefert die read-only State-Projektion ohne Preview + +## Response-Form + +```json +{ + "semantic_version": "v1", + "request_id": "ext-1", + "session_id": "desk-a", + "result": { + "type": "primitive_buffered", + "payload": { + "summary": "pattern staged: noise" + } + }, + "session": { + "session_id": "desk-a", + "pending": { + "pattern_id": "noise", + "has_group_target": false, + "group_id": null, + "parameters": {}, + "transition_style": null, + "transition_duration_ms": null + } + } +} +``` + +Moegliche Result-Typen: + +- `primitive_buffered` +- `command_accepted` +- `snapshot` +- `state` +- `session_cleared` +- `error` + +## Fehlerverhalten + +Primitive-Fehler bleiben unveraendert: + +- `unknown_group` +- `unknown_preset` +- `unknown_creative_snapshot` +- `group_exists` +- `persist_failed` +- `show_control_session_required` +- `transition_pattern_required` + +Bridge-spezifische Rahmenfehler: + +- `invalid_bridge_request_json` +- `session_id_required` + +## Session-Regeln + +- staged Primitive mit `session_id` werden in einer isolierten Session gepuffert +- staged Primitive ohne `session_id` laufen absichtlich gegen den stateless Port und liefern `show_control_session_required` +- direkte Primitive koennen mit oder ohne Session aufgerufen werden +- `clear_session` verwirft nur die angegebene Session + +## Ownership + +Die Konflikt- und Ownership-Regeln fuer mehrere Control-Quellen stehen in: + +- `docs/control_ownership.md` diff --git a/docs/pattern_matrix_v1.md b/docs/pattern_matrix_v1.md new file mode 100644 index 0000000..9ac3485 --- /dev/null +++ b/docs/pattern_matrix_v1.md @@ -0,0 +1,61 @@ +# Show-Control Pattern Matrix v1 + +Die Web-UI orientiert sich wieder an der alten Python-Bedienung, ohne die neue Host-/API-Architektur zu verlassen. + +## Kanonische Modi + +| Python-Referenz | Host-v1 `pattern_id` | Status | Bemerkung | +| --- | --- | --- | --- | +| Arrow | `arrow` | implementiert | diskrete Chevron-Belegung | +| Breathing | `breathing` | implementiert | globale Atemkurve | +| Center Pulse | `center_pulse` | implementiert | Center-/Outline-Modi ueber `center_pulse_mode` | +| Checkerd | `checker` | implementiert | `classic`, `diagonal`, `checkerd` ueber `checker_mode` | +| Column Gradient | `column_gradient` | implementiert | horizontale Verlaufslogik | +| Row Gradient | `row_gradient` | implementiert | vertikale Verlaufslogik | +| Saw | `saw` | implementiert | quantisierte Sweep-Logik | +| Scan | `scan` | implementiert | Winkel-/Line-/Bands-Scan | +| Scan Dual | `scan_dual` | implementiert | gespiegelt laufende Scanner | +| Snake | `snake` | implementiert | deterministische software-only Snake-Approximation | +| Solid | `solid` | implementiert | statischer Vollfarben-Look | +| Sparkle | `sparkle` | implementiert | randomisierte LED-Aktivierung | +| Stopwatch | `stopwatch` | implementiert | LED-Fuell-/Leerlauf ueber Tile-Perimeter | +| Strobe | `strobe` | implementiert | `global`, `random_pixels`, `random_leds` | +| Sweep | `sweep` | implementiert | gerichteter Color-Wipe | +| Two Dots | `two_dots` | implementiert | zwei gespiegelt laufende Highlights | +| Wave Line | `wave_line` | implementiert | diskrete Wellenlinie ueber das 3x6-Raster | + +## Gemeinsame Parameterbasis + +Die v1-Host-Semantik lehnt sich fuer die Pattern jetzt wieder an die alte Python-Parameterbasis an: + +- gemeinsam: `speed` (Default `0.45`), `brightness` (`1.0`), `fade` (`0.35`), `tempo_multiplier` (`1.0`) +- Farben: `color_mode`, `primary_color`, `secondary_color`, `palette` +- modusspezifisch nach alter Logik: z. B. `direction`, `symmetry`, `checker_mode`, `center_pulse_mode`, `scan_style`, `angle`, `on_width`, `off_width`, `band_thickness`, `flip_horizontal`, `flip_vertical`, `strobe_mode`, `pixel_group_size`, `strobe_duty_cycle`, `randomness` + +## Kompatibilitaets-IDs + +Diese IDs bleiben fuer bestehende Presets, Tests und API-v1-Replays erhalten: + +| Bestehende ID | Laufzeit-Ziel | +| --- | --- | +| `solid_color` | `solid` | +| `gradient` | `column_gradient` | +| `chase` | `sweep` | +| `pulse` | `breathing` | +| `noise` | `sparkle` | +| `walking_pixel` | `scan` | + +Die Kompatibilitaets-IDs behalten ihre bisherigen Parametervertraege, damit bestehende Replays und Presets nicht brechen. + +## Arbeitsmodi in der Web-UI + +- `Test/Edit`: Pattern- und Parameter-Aenderungen wirken sofort direkt gegen den Host. +- `Show/Event`: Pattern- und Parameter-Aenderungen werden lokal gestaged und erst ueber `Go` oder `Fade Go` committed. +- Preview-Modus in der Creative Surface bleibt bewusst nur `LEDs Only`. +- Pattern-Wechsel uebernimmt die bestehende Parameterbasis wie im alten Python-Tool; es werden keine versteckten UI-Defaults pro Modus injiziert. + +## Offline-/Preview-only-Semantik + +- Ohne echte Clients/Nodetelemetrie zeigt der Simulation-Host die Topologie als `preview-only`. +- Node-/Panel-Verbindungen bleiben ehrlich `offline`. +- Es werden keine simulierten Online-/Offline-Events fuer Operatoren erzeugt. diff --git a/docs/show_control_primitives.md b/docs/show_control_primitives.md index a75d721..93886a2 100644 --- a/docs/show_control_primitives.md +++ b/docs/show_control_primitives.md @@ -8,6 +8,8 @@ Diese Primitive-Menge ist die kleine, dauerhafte interne Steuersemantik fuer sof - keine grandMA-spezifische Kopplung - keine zweite Architektur neben Host-Core und API +Der aktuelle Stand gilt faktisch als eingefrorene Show-Control-v1-Semantik. Erweiterungen muessen kuenftig kompatibel zur bestehenden direct-/staged-Trennung, Fehlercode-Menge und Event-Sicht bleiben. + Der Implementierungspfad liegt in `crates/infinity_host/src/external_control.rs`. ## Primitive @@ -111,3 +113,8 @@ Der sehr duenne generische Referenzpfad liegt in `crates/infinity_host/src/exter - `ReferenceShowControlClient::stateful(...)` fuer direkte plus staged Flows - `ReferenceShowControlClient::stateless(...)` zum bewussten Nachweis, dass staged Primitive am nackten Port mit `show_control_session_required` abgewiesen werden - `BufferedShowControlAdapter` und `ShowControlSession` als kleine Buffer-/Commit-Implementierung ohne neue Grundarchitektur + +Ergaenzende Randbedingungen: + +- `docs/external_control_bridge.md` beschreibt die generische Prozess-Aussenkante auf Basis derselben Primitive +- `docs/control_ownership.md` beschreibt die konfliktfreie Koexistenz mehrerer Control-Quellen diff --git a/scripts/launch_software_only_web.sh b/scripts/launch_software_only_web.sh new file mode 100755 index 0000000..a13997c --- /dev/null +++ b/scripts/launch_software_only_web.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="/home/jan/Documents/RFP/Infinity_Vis_Rust" +APP_URL="http://127.0.0.1:9001/" +STATE_URL="http://127.0.0.1:9001/api/v1/state" +RUNTIME_STATE_PATH="data/runtime_state.json" + +open_browser() { + xdg-open "$APP_URL" >/dev/null 2>&1 & +} + +if curl -fsS "$STATE_URL" >/dev/null 2>&1; then + echo "Infinity Vis laeuft bereits auf $APP_URL" + open_browser + exit 0 +fi + +cd "$REPO_DIR" +. "$HOME/.cargo/env" + +echo "Starte infinity_host_api auf $APP_URL" +cargo run -p infinity_host_api -- \ + --config config/project.example.toml \ + --bind 127.0.0.1:9001 \ + --runtime-state "$RUNTIME_STATE_PATH" & +SERVER_PID=$! + +cleanup() { + if kill -0 "$SERVER_PID" >/dev/null 2>&1; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM + +for _attempt in $(seq 1 60); do + if curl -fsS "$STATE_URL" >/dev/null 2>&1; then + echo "Infinity Vis ist bereit." + open_browser + wait "$SERVER_PID" + exit $? + fi + sleep 0.5 +done + +echo "Der lokale API-Server wurde nicht rechtzeitig erreichbar." +wait "$SERVER_PID" diff --git a/web/v1/app.js b/web/v1/app.js index 63fb47b..300ffd1 100644 --- a/web/v1/app.js +++ b/web/v1/app.js @@ -1,4 +1,152 @@ (function () { + const PREVIEW_RENDER_INTERVAL_MS = 90; + const STATE_RENDER_INTERVAL_MS = 180; + const POSITION_ORDER = { top: 0, middle: 1, bottom: 2 }; + const COLOR_PARAM_KEYS = new Set(["color_mode", "palette", "primary_color", "secondary_color"]); + const BRIGHTNESS_PARAM_KEYS = new Set(["brightness"]); + const TEMPO_BPM_MIN = 10; + const TEMPO_BPM_MAX = 300; + const TEMPO_BPM_DEFAULT = 120; + const SPEED_TO_BPM_FACTOR = 60; + const TILE_LED_GEOMETRY = buildTileLedGeometry(); + const OPERATOR_MODES = [ + { mode_id: "arrow", label: "Arrow", pattern_id: "arrow", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "breathing", label: "Breathing", pattern_id: "breathing", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, + { mode_id: "center_pulse", label: "Center Pulse", pattern_id: "center_pulse", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "checker", label: "Checkerd", pattern_id: "checker", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "column_gradient", label: "Column Gradient", pattern_id: "column_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, + { mode_id: "row_gradient", label: "Row Gradient", pattern_id: "row_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, + { mode_id: "saw", label: "Saw", pattern_id: "saw", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, + { mode_id: "scan", label: "Scan", pattern_id: "scan", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "scan_dual", label: "Scan Dual", pattern_id: "scan_dual", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "snake", label: "Snake", pattern_id: "snake", defaults: { speed: 1.0, randomness: 0.35, intensity: 1.0 }, canonical: true }, + { mode_id: "solid", label: "Solid", pattern_id: "solid", defaults: { speed: 0.0, intensity: 1.0 }, canonical: true }, + { mode_id: "sparkle", label: "Sparkle", pattern_id: "sparkle", defaults: { speed: 1.0, strobe_duty_cycle: 0.72, intensity: 0.86 }, canonical: true }, + { mode_id: "stopwatch", label: "Stopwatch", pattern_id: "stopwatch", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "strobe", label: "Strobe", pattern_id: "strobe", defaults: { speed: 1.0, strobe_duty_cycle: 0.5, intensity: 1.0 }, canonical: true }, + { mode_id: "sweep", label: "Sweep", pattern_id: "sweep", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "two_dots", label: "Two Dots", pattern_id: "two_dots", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, + { mode_id: "wave_line", label: "Wave Line", pattern_id: "wave_line", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, + ]; + const OPERATOR_MODE_BY_ID = new Map(OPERATOR_MODES.map((mode) => [mode.mode_id, mode])); + const LEGACY_PARAMETER_CONTRACT = { + color_mode: { + control: "enum", + label: "Color Mode", + default_value: "dual", + options: [ + { value: "dual", label: "Dual" }, + { value: "palette", label: "Palette" }, + { value: "mono", label: "Mono" }, + { value: "complementary", label: "Complementary" }, + { value: "random_colors", label: "Random Colors" }, + { value: "custom_random", label: "Custom Random" }, + ], + }, + palette: { + control: "enum", + label: "Palette", + default_value: "Laser Club", + options: [ + { value: "Laser Club", label: "Laser Club" }, + { value: "Magenta Drive", label: "Magenta Drive" }, + { value: "Warehouse Heat", label: "Warehouse Heat" }, + { value: "UV Riot", label: "UV Riot" }, + { value: "Redline", label: "Redline" }, + { value: "Sodium Haze", label: "Sodium Haze" }, + { value: "Afterhours", label: "Afterhours" }, + { value: "Voltage", label: "Voltage" }, + ], + }, + direction: { + control: "enum", + label: "Direction", + default_value: "left_to_right", + options: [ + { value: "left_to_right", label: "Left to Right" }, + { value: "right_to_left", label: "Right to Left" }, + { value: "top_to_bottom", label: "Top to Bottom" }, + { value: "bottom_to_top", label: "Bottom to Top" }, + { value: "outward", label: "Outward" }, + { value: "inward", label: "Inward" }, + ], + }, + symmetry: { + control: "enum", + label: "Mirror", + default_value: "none", + options: [ + { value: "none", label: "None" }, + { value: "horizontal", label: "Horizontal" }, + { value: "vertical", label: "Vertical" }, + { value: "both", label: "Both" }, + ], + }, + checker_mode: { + control: "enum", + label: "Checker Mode", + default_value: "classic", + options: [ + { value: "classic", label: "Classic" }, + { value: "diagonal", label: "Diagonal Split" }, + { value: "checkerd", label: "Checkerd" }, + ], + }, + scan_style: { + control: "enum", + label: "Scan Style", + default_value: "line", + options: [ + { value: "line", label: "Line" }, + { value: "bands", label: "Bands" }, + ], + }, + strobe_mode: { + control: "enum", + label: "Strobe Mode", + default_value: "global", + options: [ + { value: "global", label: "Global" }, + { value: "random_pixels", label: "Random Pixels" }, + { value: "random_leds", label: "Random LEDs" }, + ], + }, + stopwatch_mode: { + control: "enum", + label: "Stopwatch Mode", + default_value: "sync", + options: [ + { value: "sync", label: "Sync" }, + { value: "random", label: "Random" }, + ], + }, + center_pulse_mode: { + control: "enum", + label: "Pulse Mode", + default_value: "expand", + options: [ + { value: "expand", label: "Expand" }, + { value: "reverse", label: "Reverse" }, + { value: "outline", label: "Outline" }, + { value: "outline_reverse", label: "Outline Reverse" }, + ], + }, + angle: { + control: "enum", + label: "Angle", + default_value: "0", + value_kind: "scalar", + options: [0, 45, 90, 135, 180, 225, 270, 315].map((value) => ({ + value: String(value), + label: `${value}°`, + })), + }, + primary_color: { control: "color", label: "Primary Color", default_value: "#4D7CFF" }, + secondary_color: { control: "color", label: "Secondary Color", default_value: "#0E1630" }, + flip_horizontal: { control: "toggle", label: "Flip Horizontal", default_value: false }, + flip_vertical: { control: "toggle", label: "Flip Vertical", default_value: false }, + }; + const apiState = { stateResponse: null, previewResponse: null, @@ -7,41 +155,83 @@ ws: null, commandTimers: new Map(), controlClient: null, + ui: { + previewMode: "leds", + workMode: "test_edit", + selectedPanelKey: null, + previewNodes: new Map(), + previewSignatures: new Map(), + parameterCards: new Map(), + lastSelectedModeId: null, + patternsSignature: null, + presetsSignature: null, + selectedPresetId: null, + snapshotsSignature: null, + parameterSignature: null, + previewLayoutSignature: null, + viewOutputSignature: null, + stateTimer: null, + previewTimer: null, + lastStateRenderAt: 0, + lastPreviewRenderAt: 0, + stateRenderQueued: false, + previewRenderQueued: false, + eventFilterSignature: null, + }, }; const dom = { projectName: document.getElementById("project-name"), topologyLabel: document.getElementById("topology-label"), connectionPill: document.getElementById("connection-pill"), + editContextLabel: document.getElementById("edit-context-label"), previewUpdated: document.getElementById("preview-updated"), refreshButton: document.getElementById("refresh-button"), + tempoBpmInput: document.getElementById("tempo-bpm-input"), + tempoBpmLabel: document.getElementById("tempo-bpm-label"), + workModeSelect: document.getElementById("work-mode-select"), + previewModeLabel: document.getElementById("preview-mode-label"), controlModePill: document.getElementById("control-mode-pill"), + pendingPanelDescription: document.getElementById("pending-panel-description"), + sessionScopeLabel: document.getElementById("session-scope-label"), pendingCommitPill: document.getElementById("pending-commit-pill"), + pendingCompactLabel: document.getElementById("pending-compact-label"), pendingSessionSummary: document.getElementById("pending-session-summary"), primitiveErrorBanner: document.getElementById("primitive-error-banner"), triggerTransitionButton: document.getElementById("trigger-transition-button"), clearStagedButton: document.getElementById("clear-staged-button"), + goButton: document.getElementById("go-button"), + fadeGoButton: document.getElementById("fade-go-button"), + utilityGoButton: document.getElementById("utility-go-button"), + utilityFadeGoButton: document.getElementById("utility-fade-go-button"), patternSelect: document.getElementById("pattern-select"), - transitionSlider: document.getElementById("transition-slider"), - transitionValue: document.getElementById("transition-value"), + transitionSecondsInput: document.getElementById("transition-seconds-input"), + transitionSecondsLabel: document.getElementById("transition-seconds-label"), transitionStyleSelect: document.getElementById("transition-style-select"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), + utilityBlackoutButton: document.getElementById("utility-blackout-button"), presetList: document.getElementById("preset-list"), presetIdInput: document.getElementById("preset-id-input"), presetOverwriteInput: document.getElementById("preset-overwrite-input"), savePresetButton: document.getElementById("save-preset-button"), - groupFilterInput: document.getElementById("group-filter-input"), - groupList: document.getElementById("group-list"), + loadPresetButton: document.getElementById("load-preset-button"), + deletePresetButton: document.getElementById("delete-preset-button"), snapshotIdInput: document.getElementById("snapshot-id-input"), snapshotLabelInput: document.getElementById("snapshot-label-input"), snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"), saveSnapshotButton: document.getElementById("save-snapshot-button"), snapshotList: document.getElementById("snapshot-list"), - sceneParams: document.getElementById("scene-params"), + motionParams: document.getElementById("motion-params"), + colorParams: document.getElementById("color-params"), + brightnessParams: document.getElementById("brightness-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), + selectedTileCard: document.getElementById("selected-tile-card"), + whiteTestButton: document.getElementById("white-test-button"), + livePatternButton: document.getElementById("live-pattern-button"), + viewOutputList: document.getElementById("view-output-list"), snapshotJson: document.getElementById("snapshot-json"), eventKindFilter: document.getElementById("event-kind-filter"), eventSearchFilter: document.getElementById("event-search-filter"), @@ -51,55 +241,47 @@ function init() { apiState.controlClient = createShowControlClient(); bindControls(); + syncPresetActionButtons(); refreshAll(); connectStream(); } function bindControls() { dom.refreshButton.addEventListener("click", () => refreshAll()); - - dom.patternSelect.addEventListener("change", async (event) => { - await handlePrimitive( - { - primitive: "set_pattern", - payload: { pattern_id: event.target.value }, - }, - { rerenderState: true } - ); + dom.workModeSelect.addEventListener("change", (event) => { + setWorkMode(event.target.value); }); - dom.transitionSlider.addEventListener("input", async (event) => { - const value = Number(event.target.value); - dom.transitionValue.textContent = `${value} ms`; - await handlePrimitive( - { - primitive: "set_transition_style", - payload: { - style: dom.transitionStyleSelect.value, - duration_ms: value, - }, - }, - { announceBuffered: false } - ); + dom.patternSelect.addEventListener("change", async (event) => { + await applyPatternSelection(event.target.value); + }); + + dom.tempoBpmInput.addEventListener("change", async (event) => { + await applyTempoBpm(Number(event.target.value)); + }); + + dom.tempoBpmInput.addEventListener("input", (event) => { + const bpm = normalizeTempoBpm(Number(event.target.value)); + dom.tempoBpmLabel.textContent = `${bpm} BPM`; + }); + + dom.transitionSecondsInput.addEventListener("change", async (event) => { + await applyTransitionSettings(dom.transitionStyleSelect.value, Number(event.target.value), true); + }); + + dom.transitionSecondsInput.addEventListener("input", (event) => { + const seconds = normalizeTransitionSeconds(Number(event.target.value)); + dom.transitionSecondsLabel.textContent = `${seconds.toFixed(1)} s`; }); dom.transitionStyleSelect.addEventListener("change", async (event) => { - await handlePrimitive( - { - primitive: "set_transition_style", - payload: { - style: event.target.value, - duration_ms: Number(dom.transitionSlider.value), - }, - }, - { rerenderState: true } - ); + await applyTransitionSettings(event.target.value, Number(dom.transitionSecondsInput.value), true); }); dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; - debounceCommand("brightness", async () => { + debounceCommand("master_brightness", async () => { await handlePrimitive({ primitive: "set_master_brightness", payload: { value }, @@ -107,17 +289,14 @@ }); }); - dom.blackoutButton.addEventListener("click", async () => { - const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false); - await handlePrimitive({ - primitive: "blackout", - payload: { enabled }, - }); - }); + dom.blackoutButton.addEventListener("click", async () => toggleBlackout()); + dom.utilityBlackoutButton.addEventListener("click", async () => toggleBlackout()); - dom.triggerTransitionButton.addEventListener("click", async () => { - await handlePrimitive({ primitive: "trigger_transition" }, { rerenderState: true }); - }); + dom.goButton.addEventListener("click", async () => commitTransition({ cut: true })); + dom.utilityGoButton.addEventListener("click", async () => commitTransition({ cut: true })); + dom.fadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); + dom.utilityFadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); + dom.triggerTransitionButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.clearStagedButton.addEventListener("click", () => { apiState.controlClient.clearPending(); @@ -127,11 +306,11 @@ code: "staged_transition_cleared", message: "Staged transition buffer cleared.", }); - renderAll(); + renderLocalUi(); }); dom.savePresetButton.addEventListener("click", async () => { - const presetId = dom.presetIdInput.value.trim(); + const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), @@ -152,6 +331,53 @@ }); } catch (error) { handleClientError(error, "save_preset"); + renderLocalUi(); + } + }); + + dom.loadPresetButton.addEventListener("click", async () => { + const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; + if (!presetId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "preset_selection_required", + message: "Select a preset or provide a preset ID before loading.", + }); + return; + } + + await handlePrimitive({ + primitive: "recall_preset", + payload: { preset_id: presetId }, + }); + }); + + dom.deletePresetButton.addEventListener("click", async () => { + const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; + if (!presetId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "preset_selection_required", + message: "Select a preset or provide a preset ID before deleting.", + }); + return; + } + + try { + await sendCommand({ + type: "delete_preset", + payload: { + preset_id: presetId, + }, + }); + if (apiState.ui.selectedPresetId === presetId) { + apiState.ui.selectedPresetId = null; + } + } catch (error) { + handleClientError(error, "delete_preset"); + renderLocalUi(); } }); @@ -178,38 +404,72 @@ }); } catch (error) { handleClientError(error, "save_creative_snapshot"); + renderLocalUi(); } }); - dom.groupFilterInput.addEventListener("input", () => renderGroups(apiState.stateResponse?.state?.global)); - dom.eventKindFilter.addEventListener("change", () => renderEvents()); - dom.eventSearchFilter.addEventListener("input", () => renderEvents()); + dom.eventKindFilter.addEventListener("change", () => renderEvents(true)); + dom.eventSearchFilter.addEventListener("input", () => renderEvents(true)); + dom.presetIdInput.addEventListener("input", () => { + syncPresetActionButtons(); + }); + + dom.whiteTestButton.addEventListener("click", async () => { + const selected = selectedPanel(); + if (!selected) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "panel_selection_required", + message: "Select a tile before triggering a white test.", + }); + return; + } + + try { + await sendCommand( + { + type: "trigger_panel_test", + payload: { + node_id: selected.node_id, + panel_position: selected.panel_position, + pattern: "walking_pixel_106", + }, + }, + { refresh: false } + ); + } catch (error) { + handleClientError(error, "trigger_panel_test"); + } + }); + + dom.livePatternButton.addEventListener("click", () => { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "live_pattern_notice", + message: "Live Pattern follows the host preview automatically once any temporary panel test expires.", + }); + }); } async function refreshAll() { - setConnectionState("connecting", "loading"); + setConnectionState("warning", "syncing"); try { - const [stateResponse, previewResponse, catalog] = await Promise.all([ + const responses = await Promise.all([ fetchJson("/api/v1/state"), fetchJson("/api/v1/preview"), fetchJson("/api/v1/catalog"), ]); - - apiState.stateResponse = stateResponse; - apiState.previewResponse = previewResponse; - apiState.catalog = catalog; - - renderAll(); - setConnectionState("online", "HTTP sync"); + apiState.stateResponse = responses[0]; + apiState.previewResponse = responses[1]; + apiState.catalog = responses[2]; + renderAll(true); + setConnectionState("live", "stream ready"); } catch (error) { - console.error(error); - setConnectionState("offline", "snapshot fetch failed"); - pushEvent({ - at: new Date().toLocaleTimeString(), - kind: "error", - code: error.code || "http_refresh_failed", - message: `HTTP refresh failed: ${error.message}`, - }); + setConnectionState("alert", "sync failed"); + handleClientError(error, "http_refresh_failed"); + renderLocalUi(); } } @@ -220,12 +480,12 @@ apiState.ws = socket; socket.addEventListener("open", () => { - setConnectionState("online", "stream connected"); + setConnectionState("live", "stream connected"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "stream_connected", - message: "WebSocket stream connected", + message: "WebSocket stream connected.", }); }); @@ -235,12 +495,12 @@ }); socket.addEventListener("close", () => { - setConnectionState("offline", "stream disconnected"); + setConnectionState("warning", "reconnecting"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "stream_reconnect", - message: "WebSocket stream closed, retrying", + message: "WebSocket stream closed, retrying.", }); window.setTimeout(connectStream, 1500); }); @@ -262,7 +522,7 @@ generated_at_millis: envelope.generated_at_millis, state: message.payload, }; - renderState(); + scheduleStateRender(); return; } @@ -272,8 +532,7 @@ generated_at_millis: envelope.generated_at_millis, preview: message.payload, }; - renderPreview(); - renderSnapshotJson(); + schedulePreviewRender(); return; } @@ -287,8 +546,41 @@ } } - async function handlePrimitive(primitive, options = {}) { - const { announceBuffered = true, rerenderState = false } = options; + function scheduleStateRender() { + if (apiState.ui.stateRenderQueued) { + return; + } + apiState.ui.stateRenderQueued = true; + const now = window.performance.now(); + const waitMs = Math.max(0, STATE_RENDER_INTERVAL_MS - (now - apiState.ui.lastStateRenderAt)); + window.setTimeout(() => { + apiState.ui.stateRenderQueued = false; + apiState.ui.lastStateRenderAt = window.performance.now(); + renderState(false); + }, waitMs); + } + + function schedulePreviewRender() { + if (apiState.ui.previewRenderQueued) { + return; + } + apiState.ui.previewRenderQueued = true; + const now = window.performance.now(); + const waitMs = Math.max(0, PREVIEW_RENDER_INTERVAL_MS - (now - apiState.ui.lastPreviewRenderAt)); + window.setTimeout(() => { + apiState.ui.previewRenderQueued = false; + apiState.ui.lastPreviewRenderAt = window.performance.now(); + renderPreview(false); + renderSelectedTile(); + renderSnapshotJson(); + }, waitMs); + } + + async function handlePrimitive(primitive, options) { + const settings = options || {}; + const announceBuffered = settings.announceBuffered !== false; + const rerenderState = settings.rerenderState === true; + try { const outcome = await apiState.controlClient.applyPrimitive(primitive); if (outcome.kind === "buffered" && announceBuffered) { @@ -308,28 +600,75 @@ } if (rerenderState) { - renderState(); + renderState(true); } else { - renderPendingSession(); - renderSnapshotJson(); + renderLocalUi(); } return outcome; } catch (error) { handleClientError(error, primitive.primitive); - renderPendingSession(); - renderSnapshotJson(); + renderLocalUi(); return null; } } - async function sendCommand(command, options = {}) { - const { announce = true, refresh = true } = options; + async function commitTransition(options) { + const config = options || {}; + const client = apiState.controlClient; + if (apiState.ui.workMode !== "show_event") { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "direct_mode_active", + message: "Test/Edit mode applies changes immediately. Go and Fade Go are only used in Show/Event mode.", + }); + return; + } + if (!client.hasPending()) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "no_pending_transition", + message: "Stage a pattern or parameter change before committing.", + }); + return; + } + + if (config.cut) { + await handlePrimitive( + { + primitive: "set_transition_style", + payload: { + style: "snap", + duration_ms: 0, + }, + }, + { announceBuffered: false, rerenderState: false } + ); + } + await handlePrimitive({ primitive: "trigger_transition" }, { rerenderState: true }); + } + + async function toggleBlackout() { + const enabled = !(apiState.stateResponse && apiState.stateResponse.state + ? apiState.stateResponse.state.global.blackout + : false); + await handlePrimitive({ + primitive: "blackout", + payload: { enabled: enabled }, + }); + } + + async function sendCommand(command, options) { + const settings = options || {}; + const announce = settings.announce !== false; + const refresh = settings.refresh !== false; const response = await fetchJson("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: `web-${Date.now()}`, - command, + command: command, }), }); @@ -368,13 +707,13 @@ let payload = null; try { payload = body ? JSON.parse(body) : null; - } catch (error) { + } catch (_error) { throw createClientError("invalid_json", `Invalid JSON from ${url}`); } if (!response.ok) { - const code = payload?.error?.code || "request_failed"; - const message = payload?.error?.message || payload?.error || response.statusText; + const code = payload && payload.error ? payload.error.code : "request_failed"; + const message = payload && payload.error ? payload.error.message : response.statusText; throw createClientError(code, message); } return payload; @@ -383,12 +722,15 @@ function createShowControlClient() { const urlMode = new URLSearchParams(window.location.search).get("show_control_mode"); const mode = urlMode === "stateless" ? "stateless" : "stateful"; - const client = { - mode, + return { + mode: mode, + sessionLabel: mode === "stateful" ? "local browser session" : "stateless direct port", + commitState: "idle", lastError: null, pending: createEmptyPendingState(), clearPending() { this.pending = createEmptyPendingState(); + this.commitState = "idle"; this.lastError = null; }, hasPending() { @@ -402,6 +744,7 @@ this.pending.hasGroupTarget = true; this.pending.groupId = groupId; this.lastError = null; + this.commitState = "staged"; return { kind: "buffered", summary: `group target staged: ${groupId || "all_panels"}`, @@ -412,6 +755,11 @@ try { const outcome = await applyPrimitiveWithClient(this, primitive); this.lastError = null; + if (outcome.kind === "buffered") { + this.commitState = "staged"; + } else if (primitive.primitive !== "trigger_transition") { + this.commitState = this.hasPending() ? "staged" : "idle"; + } return outcome; } catch (error) { this.lastError = { @@ -422,7 +770,6 @@ } }, }; - return client; } async function applyPrimitiveWithClient(client, primitive) { @@ -430,6 +777,7 @@ case "blackout": return { kind: "direct", + summary: primitive.payload.enabled ? "blackout enabled" : "blackout released", response: await sendCommand({ type: "set_blackout", payload: primitive.payload, @@ -438,6 +786,7 @@ case "recall_preset": return { kind: "direct", + summary: `preset recalled: ${primitive.payload.preset_id}`, response: await sendCommand({ type: "recall_preset", payload: primitive.payload, @@ -446,6 +795,7 @@ case "recall_creative_snapshot": return { kind: "direct", + summary: `creative snapshot recalled: ${primitive.payload.snapshot_id}`, response: await sendCommand({ type: "recall_creative_snapshot", payload: primitive.payload, @@ -454,6 +804,7 @@ case "set_master_brightness": return { kind: "direct", + summary: `master brightness set to ${Math.round(primitive.payload.value * 100)}%`, response: await sendCommand({ type: "set_master_brightness", payload: primitive.payload, @@ -462,6 +813,7 @@ case "upsert_group": return { kind: "direct", + summary: `group ${primitive.payload.overwrite ? "updated" : "saved"}: ${primitive.payload.group_id}`, response: await sendCommand({ type: "upsert_group", payload: primitive.payload, @@ -491,11 +843,13 @@ ); } client.pending.hasGroupTarget = true; - client.pending.groupId = primitive.payload.group_id ?? null; + client.pending.groupId = primitive.payload.group_id == null ? null : primitive.payload.group_id; client.pending.parameters[primitive.payload.key] = primitive.payload.value; return { kind: "buffered", - summary: `group parameter staged: ${primitive.payload.key} for ${primitive.payload.group_id || "all_panels"}`, + summary: + `group parameter staged: ${primitive.payload.key} for ` + + `${primitive.payload.group_id || "all_panels"}`, }; case "set_transition_style": ensureStatefulSession(client); @@ -516,6 +870,8 @@ ); } + client.commitState = "committing"; + if (client.pending.hasGroupTarget) { await sendCommand( { @@ -543,7 +899,6 @@ { announce: false, refresh: false } ); } - await sendCommand( { type: "select_pattern", @@ -553,11 +908,12 @@ ); const parameterEntries = Object.entries(client.pending.parameters); - for (const [key, value] of parameterEntries) { + for (let index = 0; index < parameterEntries.length; index += 1) { + const entry = parameterEntries[index]; await sendCommand( { type: "set_scene_parameter", - payload: { key, value }, + payload: { key: entry[0], value: entry[1] }, }, { announce: false, refresh: false } ); @@ -568,10 +924,11 @@ : `transition triggered: ${client.pending.patternId}`; client.clearPending(); + client.commitState = "committed"; await refreshAll(); return { kind: "command", - summary, + summary: summary, }; default: throw createClientError("unknown_primitive", `unknown primitive '${primitive.primitive}'`); @@ -615,6 +972,7 @@ } function handleClientError(error, fallbackCode) { + apiState.controlClient.commitState = "error"; pushEvent({ at: new Date().toLocaleTimeString(), kind: "error", @@ -623,480 +981,908 @@ }); } - function renderAll() { - renderState(); - renderPreview(); - renderPendingSession(); - renderEvents(); + function renderAll(force) { + renderState(force === true); + renderPreview(force === true); + renderEvents(true); } - function renderState() { - const state = apiState.stateResponse?.state; + function renderLocalUi() { + const state = apiState.stateResponse ? apiState.stateResponse.state : null; + renderTopBar(state); + renderPendingSession(); + renderPatternSelection(state); + renderParameterSections(state ? state.active_scene : null, state ? state.global : null, false); + renderSelectedTile(); + renderViewOutput(state, false); + renderSnapshotJson(); + } + + function renderState(force) { + const state = apiState.stateResponse ? apiState.stateResponse.state : null; if (!state) { renderPendingSession(); return; } - const global = state.global; - const scene = state.active_scene; - const pending = apiState.controlClient.pending; - const displayedPatternId = pending.patternId || global.selected_pattern; - const displayedTransitionStyle = pending.transitionStyle || global.transition_style; - const displayedTransitionDuration = - pending.transitionDurationMs !== null - ? pending.transitionDurationMs - : global.transition_duration_ms; - - dom.projectName.textContent = state.system.project_name; - dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; - - dom.patternSelect.innerHTML = ""; - (apiState.catalog?.patterns || []).forEach((pattern) => { - const option = document.createElement("option"); - option.value = pattern.pattern_id; - option.textContent = `${pattern.display_name} (${pattern.pattern_id})`; - option.selected = pattern.pattern_id === displayedPatternId; - dom.patternSelect.appendChild(option); - }); - dom.patternSelect.value = displayedPatternId; - - dom.transitionSlider.value = String(displayedTransitionDuration); - dom.transitionValue.textContent = `${displayedTransitionDuration} ms`; - dom.transitionStyleSelect.value = displayedTransitionStyle; - dom.brightnessSlider.value = String(global.master_brightness); - dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; - dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; - dom.blackoutButton.classList.toggle("is-active", global.blackout); - - renderPresets(scene); - renderGroups(global); - renderCreativeSnapshots(); - renderSceneParameters(scene, global); + reconcileSelection(); + renderTopBar(state); + renderPatternSelection(state); + renderPresets(state, force); + renderCreativeSnapshots(force); + renderParameterSections(state.active_scene, state.global, force); renderSummaryCards(state); + renderSelectedTile(); + renderViewOutput(state, force); renderPendingSession(); renderSnapshotJson(); } - function renderPendingSession() { - const client = apiState.controlClient; - const pending = client.pending; - - dom.controlModePill.textContent = client.mode; - dom.controlModePill.className = - client.mode === "stateful" ? "pill pill-online" : "pill pill-warning"; - - dom.pendingCommitPill.textContent = client.hasPending() ? "staged" : "idle"; - dom.pendingCommitPill.className = client.hasPending() ? "pill pill-warning" : "pill pill-offline"; - - const cards = []; - if (pending.patternId) { - cards.push(renderPendingCard("Pattern", pending.patternId)); - } - if (pending.hasGroupTarget) { - cards.push(renderPendingCard("Target Group", pending.groupId || "all_panels")); - } - if (pending.transitionStyle || pending.transitionDurationMs !== null) { - const detail = [ - pending.transitionStyle || "inherit", - pending.transitionDurationMs !== null - ? `${pending.transitionDurationMs} ms` - : "duration unchanged", - ].join(" / "); - cards.push(renderPendingCard("Transition", detail)); - } - const parameterKeys = Object.keys(pending.parameters); - if (parameterKeys.length) { - cards.push(renderPendingCard("Parameters", `${parameterKeys.length} staged: ${parameterKeys.join(", ")}`)); + function renderTopBar(state) { + if (!state) { + return; } + const global = state.global; + const scene = state.active_scene; + const pending = apiState.controlClient.pending; + const displayedTransitionDurationMs = + pending.transitionDurationMs !== null + ? pending.transitionDurationMs + : global.transition_duration_ms; + const displayedTransitionStyle = pending.transitionStyle || global.transition_style; + const displayedTempoBpm = displayedTempoFromState(state, pending); + const displayedTransitionSeconds = durationMsToSeconds(displayedTransitionDurationMs); - dom.pendingSessionSummary.innerHTML = cards.length - ? cards.join("") - : '
No staged transition yet. Stage pattern, group target, parameters or transition config, then commit explicitly.
'; + if (dom.projectName) { + dom.projectName.textContent = state.system.project_name; + } + if (dom.topologyLabel) { + dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; + } + if (document.activeElement !== dom.tempoBpmInput) { + dom.tempoBpmInput.value = String(displayedTempoBpm); + } + dom.tempoBpmLabel.textContent = `${displayedTempoBpm} BPM`; + if (document.activeElement !== dom.transitionSecondsInput) { + dom.transitionSecondsInput.value = displayedTransitionSeconds.toFixed(1); + } + dom.transitionSecondsLabel.textContent = `${displayedTransitionSeconds.toFixed(1)} s`; + if (document.activeElement !== dom.transitionStyleSelect) { + dom.transitionStyleSelect.value = displayedTransitionStyle; + } + if (document.activeElement !== dom.brightnessSlider) { + dom.brightnessSlider.value = String(global.master_brightness); + } + dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; + if (dom.previewModeLabel) { + dom.previewModeLabel.textContent = "LEDs Only"; + } + dom.workModeSelect.value = apiState.ui.workMode; - if (client.lastError) { - dom.primitiveErrorBanner.classList.remove("hidden"); - dom.primitiveErrorBanner.innerHTML = ` - ${escapeHtml(client.lastError.code)} - ${escapeHtml(client.lastError.message)} - `; + const hasPending = apiState.controlClient.hasPending(); + if (apiState.ui.workMode === "show_event" && hasPending) { + if (dom.editContextLabel) { + dom.editContextLabel.textContent = + `Edit: Next (${operatorModeLabelForPatternId(pending.patternId || scene.pattern_id)})`; + } + } else if (apiState.ui.workMode === "show_event") { + if (dom.editContextLabel) { + dom.editContextLabel.textContent = + `Edit: Live (${scene.preset_id || operatorModeLabelForPatternId(scene.pattern_id)})`; + } } else { - dom.primitiveErrorBanner.classList.add("hidden"); - dom.primitiveErrorBanner.innerHTML = ""; + if (dom.editContextLabel) { + dom.editContextLabel.textContent = + `Edit: Live (${operatorModeLabelForPatternId(scene.pattern_id)})`; + } } - dom.triggerTransitionButton.classList.toggle("staged", client.hasPending()); - dom.clearStagedButton.disabled = !client.hasPending(); + if (dom.controlModePill) { + dom.controlModePill.textContent = apiState.ui.workMode === "show_event" ? "Show/Event" : "Test/Edit"; + dom.controlModePill.className = + apiState.ui.workMode === "show_event" + ? "status-chip status-chip-warning" + : "status-chip status-chip-live"; + } + + const blackoutLabel = global.blackout ? "Blackout Active" : "Blackout"; + dom.blackoutButton.textContent = blackoutLabel; + dom.utilityBlackoutButton.textContent = blackoutLabel; + dom.blackoutButton.classList.toggle("is-active", global.blackout); + dom.utilityBlackoutButton.classList.toggle("is-active", global.blackout); } - function renderPendingCard(label, detail) { - return ` -
- ${escapeHtml(label)} - ${escapeHtml(detail)} -
- `; + function renderPatternSelection(state) { + if (!state) { + return; + } + const modes = availableOperatorModes(); + const signature = modes.map((mode) => `${mode.mode_id}:${mode.pattern_id}`).join("|"); + + if (signature !== apiState.ui.patternsSignature) { + apiState.ui.patternsSignature = signature; + dom.patternSelect.innerHTML = ""; + modes.forEach((mode) => { + const option = document.createElement("option"); + option.value = mode.mode_id; + option.textContent = mode.label; + dom.patternSelect.appendChild(option); + }); + } + + const selectedModeId = displayedOperatorModeId(state, modes); + if (document.activeElement !== dom.patternSelect) { + dom.patternSelect.value = selectedModeId; + } } - function renderPresets(scene) { - dom.presetList.innerHTML = ""; - const presets = apiState.catalog?.presets || []; - if (!presets.length) { - dom.presetList.innerHTML = '
No presets available.
'; + function renderParameterSections(scene, global, force) { + if (!scene || !global) { + dom.motionParams.innerHTML = ""; + dom.colorParams.innerHTML = ""; + dom.brightnessParams.innerHTML = ""; + apiState.ui.parameterCards.clear(); return; } - presets.forEach((preset) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "preset-button"; - button.classList.toggle("active", scene.preset_id === preset.preset_id); - button.innerHTML = ` - ${preset.preset_id} -
${preset.pattern_id} / ${preset.transition_style} / ${preset.source}
- `; - button.addEventListener("click", async () => { - await handlePrimitive({ - primitive: "recall_preset", - payload: { preset_id: preset.preset_id }, + if ( + !force && + (dom.motionParams.contains(document.activeElement) || + dom.colorParams.contains(document.activeElement) || + dom.brightnessParams.contains(document.activeElement)) + ) { + return; + } + + const parameters = scene.parameters || []; + const signature = parameters + .map((parameter) => `${parameter.key}:${parameter.kind}`) + .join("|"); + + if (signature !== apiState.ui.parameterSignature) { + apiState.ui.parameterSignature = signature; + apiState.ui.parameterCards.clear(); + dom.motionParams.innerHTML = ""; + dom.colorParams.innerHTML = ""; + dom.brightnessParams.innerHTML = ""; + + parameters.forEach((parameter) => { + const card = createParameterCard(parameter, global); + apiState.ui.parameterCards.set(parameter.key, card); + parameterContainer(parameter).appendChild(card.root); + }); + } + + parameters.forEach((parameter) => { + const card = apiState.ui.parameterCards.get(parameter.key); + if (card) { + updateParameterCard(card, parameter, global); + } + }); + } + + function parameterContainer(parameter) { + if (COLOR_PARAM_KEYS.has(parameter.key)) { + return dom.colorParams; + } + if (BRIGHTNESS_PARAM_KEYS.has(parameter.key)) { + return dom.brightnessParams; + } + return dom.motionParams; + } + + function createParameterCard(parameter, global) { + const card = document.createElement("div"); + card.className = "parameter-card"; + card.dataset.key = parameter.key; + const contract = controlContractFor(parameter.key); + const controlKind = contract ? contract.control : inferControlKind(parameter); + + const header = document.createElement("div"); + header.className = "parameter-header"; + const title = document.createElement("strong"); + title.textContent = contract && contract.label ? contract.label : parameter.label; + const key = document.createElement("span"); + key.className = "parameter-key"; + key.textContent = parameter.key; + header.appendChild(title); + header.appendChild(key); + card.appendChild(header); + + const readout = document.createElement("div"); + readout.className = "parameter-readout"; + + let primaryInput = null; + let secondaryInput = null; + + if (controlKind === "scalar") { + primaryInput = document.createElement("input"); + primaryInput.type = "range"; + primaryInput.min = String(parameter.min_scalar == null ? 0 : parameter.min_scalar); + primaryInput.max = String(parameter.max_scalar == null ? 1 : parameter.max_scalar); + primaryInput.step = String(parameter.step == null ? 0.01 : parameter.step); + primaryInput.addEventListener("input", async (event) => { + const value = Number(event.target.value); + readout.textContent = value.toFixed(2); + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: "scalar", + value: value, }); }); - dom.presetList.appendChild(button); - }); + card.appendChild(primaryInput); + card.appendChild(readout); + } else if (controlKind === "toggle") { + primaryInput = document.createElement("input"); + primaryInput.type = "checkbox"; + primaryInput.addEventListener("change", async (event) => { + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: "toggle", + value: event.target.checked, + }); + }); + card.appendChild(primaryInput); + card.appendChild(readout); + } else if (controlKind === "color") { + const row = document.createElement("div"); + row.className = "color-input-row"; + primaryInput = document.createElement("input"); + primaryInput.type = "color"; + secondaryInput = document.createElement("input"); + secondaryInput.type = "text"; + primaryInput.addEventListener("input", async (event) => { + secondaryInput.value = event.target.value.toUpperCase(); + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: "text", + value: event.target.value.toUpperCase(), + }); + }); + secondaryInput.addEventListener("change", async (event) => { + const normalized = normalizeColorHex(event.target.value); + secondaryInput.value = normalized; + primaryInput.value = normalized; + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: "text", + value: normalized, + }); + }); + row.appendChild(primaryInput); + row.appendChild(secondaryInput); + card.appendChild(row); + card.appendChild(readout); + } else if (controlKind === "enum") { + primaryInput = document.createElement("select"); + const options = contract && Array.isArray(contract.options) ? contract.options : []; + options.forEach((option) => { + const node = document.createElement("option"); + node.value = option.value; + node.textContent = option.label; + primaryInput.appendChild(node); + }); + primaryInput.addEventListener("change", async (event) => { + const optionValue = event.target.value; + const payloadValue = + contract && contract.value_kind === "scalar" + ? { kind: "scalar", value: Number(optionValue) } + : { kind: "text", value: optionValue }; + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: payloadValue.kind, + value: payloadValue.value, + }); + }); + card.appendChild(primaryInput); + card.appendChild(readout); + } else { + primaryInput = document.createElement("input"); + primaryInput.type = "text"; + primaryInput.addEventListener("change", async (event) => { + await applySceneParameterChange(global.selected_group, parameter.key, { + kind: "text", + value: event.target.value, + }); + }); + card.appendChild(primaryInput); + card.appendChild(readout); + } + + return { + root: card, + input: primaryInput, + auxiliary: secondaryInput, + readout: readout, + kind: parameter.kind, + controlKind: controlKind, + contract: contract, + }; } - function renderGroups(global) { - dom.groupList.innerHTML = ""; - if (!global) { - return; - } - const filterValue = dom.groupFilterInput.value.trim().toLowerCase(); - const stagedGroupId = apiState.controlClient.pending.hasGroupTarget - ? apiState.controlClient.pending.groupId - : undefined; - const groups = (apiState.catalog?.groups || []).filter((group) => { - if (!filterValue) { - return true; - } - return ( - group.group_id.toLowerCase().includes(filterValue) || - (group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue)) - ); - }); - - const allButton = document.createElement("button"); - allButton.type = "button"; - allButton.className = "group-button"; - allButton.classList.toggle( - "active", - stagedGroupId !== undefined ? stagedGroupId === null : !global.selected_group + function updateParameterCard(card, parameter) { + const hasStagedValue = Object.prototype.hasOwnProperty.call( + apiState.controlClient.pending.parameters, + parameter.key ); - allButton.classList.toggle("staged", stagedGroupId === null); - allButton.innerHTML = - "all_panels
target group for next commit
"; - allButton.addEventListener("click", () => { - try { - const outcome = apiState.controlClient.stageGroupTarget(null); - pushEvent({ - at: new Date().toLocaleTimeString(), - kind: "info", - code: "stage_group_target", - message: outcome.summary, - }); - renderGroups(global); - renderPendingSession(); - renderSnapshotJson(); - } catch (error) { - apiState.controlClient.lastError = { - code: error.code || "stage_group_target_failed", - message: error.message, - }; - handleClientError(error, "stage_group_target"); - renderPendingSession(); - } - }); - dom.groupList.appendChild(allButton); + const stagedValue = hasStagedValue + ? apiState.controlClient.pending.parameters[parameter.key] + : null; + const displayValue = hasStagedValue ? stagedValue : parameter.value; + card.root.classList.toggle("is-staged", hasStagedValue); - if (!groups.length) { - const empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = "No groups match the current filter."; - dom.groupList.appendChild(empty); + if (card.controlKind === "scalar") { + const fallback = parameterDefaultScalar(parameter, card.contract); + const value = parameterScalarValue(displayValue, fallback); + if (document.activeElement !== card.input) { + card.input.value = String(value); + } + card.readout.textContent = value.toFixed(2); return; } - groups.forEach((group) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "group-button"; - button.classList.toggle( - "active", - stagedGroupId !== undefined - ? group.group_id === stagedGroupId - : group.group_id === global.selected_group + if (card.controlKind === "toggle") { + const fallback = parameterDefaultToggle(card.contract); + const value = parameterToggleValue(displayValue, fallback); + if (document.activeElement !== card.input) { + card.input.checked = value; + } + card.readout.textContent = card.input.checked ? "On" : "Off"; + return; + } + + if (card.controlKind === "color") { + const normalized = normalizeColorHex( + parameterTextValue(displayValue, parameterDefaultText(card.contract, "#000000")) ); - button.classList.toggle("staged", group.group_id === stagedGroupId); - button.innerHTML = ` - ${group.group_id} -
${group.member_count} members / ${group.source}
- `; - button.addEventListener("click", () => { - try { - const outcome = apiState.controlClient.stageGroupTarget(group.group_id); - pushEvent({ - at: new Date().toLocaleTimeString(), - kind: "info", - code: "stage_group_target", - message: outcome.summary, - }); - renderGroups(global); - renderPendingSession(); - renderSnapshotJson(); - } catch (error) { - apiState.controlClient.lastError = { - code: error.code || "stage_group_target_failed", - message: error.message, - }; - handleClientError(error, "stage_group_target"); - renderPendingSession(); - } - }); - dom.groupList.appendChild(button); - }); + if (document.activeElement !== card.input) { + card.input.value = normalized; + } + if (document.activeElement !== card.auxiliary) { + card.auxiliary.value = normalized; + } + card.readout.textContent = normalized; + return; + } + + if (card.controlKind === "enum") { + const options = card.contract && Array.isArray(card.contract.options) + ? card.contract.options + : []; + const fallbackValue = parameterDefaultText(card.contract, options[0] ? options[0].value : ""); + const value = card.contract && card.contract.value_kind === "scalar" + ? String(parameterScalarValue(displayValue, Number(fallbackValue))) + : parameterTextValue(displayValue, fallbackValue); + if (document.activeElement !== card.input) { + card.input.value = value; + } + card.readout.textContent = optionLabelForValue(card.contract, value) || value; + return; + } + + const textValue = parameterTextValue(displayValue, parameterDefaultText(card.contract, "")); + if (card.auxiliary) { + const normalized = normalizeColorHex(textValue); + if (document.activeElement !== card.input) { + card.input.value = normalized; + } + if (document.activeElement !== card.auxiliary) { + card.auxiliary.value = normalized; + } + card.readout.textContent = normalized; + return; + } + + if (document.activeElement !== card.input) { + card.input.value = textValue; + } + card.readout.textContent = textValue || "Text"; } - function renderCreativeSnapshots() { + function controlContractFor(key) { + return LEGACY_PARAMETER_CONTRACT[key] || null; + } + + function inferControlKind(parameter) { + if (parameter.kind === "scalar") { + return "scalar"; + } + if (parameter.kind === "toggle") { + return "toggle"; + } + if (looksLikeColorParameter(parameter)) { + return "color"; + } + return "text"; + } + + function optionLabelForValue(contract, value) { + if (!contract || !Array.isArray(contract.options)) { + return null; + } + const found = contract.options.find((option) => option.value === value); + return found ? found.label : null; + } + + function parameterScalarValue(parameterValue, fallback) { + if (parameterValue && typeof parameterValue.value === "number" && Number.isFinite(parameterValue.value)) { + return parameterValue.value; + } + return fallback; + } + + function parameterToggleValue(parameterValue, fallback) { + if (parameterValue && typeof parameterValue.value === "boolean") { + return parameterValue.value; + } + return fallback; + } + + function parameterTextValue(parameterValue, fallback) { + if (parameterValue && parameterValue.value != null) { + return String(parameterValue.value); + } + return fallback; + } + + function parameterDefaultScalar(parameter, contract) { + if (contract && typeof contract.default_value === "number") { + return contract.default_value; + } + if ( + parameter.default_value && + typeof parameter.default_value.value === "number" && + Number.isFinite(parameter.default_value.value) + ) { + return parameter.default_value.value; + } + if (parameter.min_scalar != null) { + return Number(parameter.min_scalar); + } + return 0; + } + + function parameterDefaultToggle(contract) { + return contract ? Boolean(contract.default_value) : false; + } + + function parameterDefaultText(contract, fallback) { + if (contract && contract.default_value != null) { + return String(contract.default_value); + } + return fallback; + } + + function renderPresets(state, force) { + const presets = apiState.catalog ? apiState.catalog.presets || [] : []; + const signature = presets.map((preset) => preset.preset_id).join("|"); + if ( + apiState.ui.selectedPresetId && + !presets.some((preset) => preset.preset_id === apiState.ui.selectedPresetId) + ) { + apiState.ui.selectedPresetId = null; + } + if (force || signature !== apiState.ui.presetsSignature) { + apiState.ui.presetsSignature = signature; + dom.presetList.innerHTML = ""; + if (!presets.length) { + dom.presetList.innerHTML = '
No presets available.
'; + } else { + presets.forEach((preset) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "list-item"; + button.dataset.presetId = preset.preset_id; + button.innerHTML = + `${escapeHtml(preset.preset_id)}` + + `
` + + `${escapeHtml(operatorModeLabelForPatternId(preset.pattern_id))}` + + `${escapeHtml(preset.transition_style)}` + + `${escapeHtml(preset.target_group || "all_panels")}` + + `
`; + button.addEventListener("click", () => { + apiState.ui.selectedPresetId = preset.preset_id; + dom.presetIdInput.value = preset.preset_id; + renderPresets(state, true); + }); + dom.presetList.appendChild(button); + }); + } + } + + Array.from(dom.presetList.querySelectorAll("[data-preset-id]")).forEach((node) => { + const presetId = node.dataset.presetId; + const isSelected = apiState.ui.selectedPresetId === presetId; + const isLive = state.active_scene.preset_id === presetId; + node.classList.toggle("active", isSelected || isLive); + node.classList.toggle("staged", isLive); + }); + + syncPresetActionButtons(); + } + + function syncPresetActionButtons() { + const hasPresetTarget = Boolean(apiState.ui.selectedPresetId || dom.presetIdInput.value.trim()); + dom.loadPresetButton.disabled = !hasPresetTarget; + dom.deletePresetButton.disabled = !hasPresetTarget; + } + + function renderCreativeSnapshots(force) { + const snapshots = apiState.catalog ? apiState.catalog.creative_snapshots || [] : []; + const signature = snapshots.map((snapshot) => snapshot.snapshot_id).join("|"); + if (!force && signature === apiState.ui.snapshotsSignature) { + return; + } + apiState.ui.snapshotsSignature = signature; dom.snapshotList.innerHTML = ""; - const snapshots = apiState.catalog?.creative_snapshots || []; if (!snapshots.length) { - dom.snapshotList.innerHTML = - '
No creative snapshots saved yet.
'; + dom.snapshotList.innerHTML = '
No creative snapshots saved yet.
'; return; } snapshots.forEach((snapshot) => { - const card = document.createElement("article"); - card.className = "snapshot-card"; - card.innerHTML = ` -
-
- ${snapshot.label || snapshot.snapshot_id} -
${snapshot.snapshot_id}
-
- -
-
- ${snapshot.pattern_id} - ${snapshot.transition_style} - ${snapshot.transition_duration_ms} ms - ${snapshot.target_group || "all_panels"} -
- `; - card.querySelector("button").addEventListener("click", async () => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "list-item"; + button.innerHTML = + `${escapeHtml(snapshot.label || snapshot.snapshot_id)}` + + `
` + + `${escapeHtml(snapshot.snapshot_id)}` + + `${escapeHtml(operatorModeLabelForPatternId(snapshot.pattern_id))}` + + `${snapshot.transition_duration_ms} ms` + + `
`; + button.addEventListener("click", async () => { await handlePrimitive({ primitive: "recall_creative_snapshot", payload: { snapshot_id: snapshot.snapshot_id }, }); }); - dom.snapshotList.appendChild(card); + dom.snapshotList.appendChild(button); }); } - function renderSceneParameters(scene, global) { - dom.sceneParams.innerHTML = ""; - const parameters = scene.parameters || []; - if (!parameters.length) { - dom.sceneParams.innerHTML = - '
This pattern has no exposed scene parameters.
'; - return; - } - - parameters.forEach((parameter) => { - const stagedValue = apiState.controlClient.pending.parameters[parameter.key]; - const displayValue = stagedValue || parameter.value; - const card = document.createElement("div"); - card.className = "parameter-card"; - card.classList.toggle("staged", Boolean(stagedValue)); - - if (parameter.kind === "scalar") { - const currentValue = Number(displayValue.value || 0); - card.innerHTML = ` - - `; - const slider = card.querySelector("input"); - const readout = card.querySelector("span:last-of-type"); - slider.addEventListener("input", async (event) => { - const value = Number(event.target.value); - readout.textContent = value.toFixed(2); - await handlePrimitive( - { - primitive: "set_group_parameter", - payload: { - group_id: apiState.controlClient.effectiveGroupId(global.selected_group), - key: parameter.key, - value: { kind: "scalar", value }, - }, - }, - { announceBuffered: false } - ); - }); - } else if (parameter.kind === "toggle") { - const checked = Boolean(displayValue.value); - card.innerHTML = ` - - `; - const checkbox = card.querySelector("input"); - checkbox.addEventListener("change", async (event) => { - await handlePrimitive( - { - primitive: "set_group_parameter", - payload: { - group_id: apiState.controlClient.effectiveGroupId(global.selected_group), - key: parameter.key, - value: { kind: "toggle", value: event.target.checked }, - }, - }, - { announceBuffered: false } - ); - }); - } else { - const currentValue = displayValue.value || ""; - card.innerHTML = ` - - `; - const input = card.querySelector("input"); - input.addEventListener("change", async (event) => { - await handlePrimitive( - { - primitive: "set_group_parameter", - payload: { - group_id: apiState.controlClient.effectiveGroupId(global.selected_group), - key: parameter.key, - value: { kind: "text", value: event.target.value }, - }, - }, - { announceBuffered: false } - ); - }); - } - - dom.sceneParams.appendChild(card); - }); - } - - function renderPreview() { - const preview = apiState.previewResponse?.preview; - dom.previewGrid.innerHTML = ""; - if (!preview?.panels?.length) { + function renderPreview(force) { + const panels = renderablePanels(); + if (!panels.length) { dom.previewGrid.innerHTML = '
Preview stream is waiting for panel snapshots.
'; + apiState.ui.previewNodes.clear(); + apiState.ui.previewSignatures.clear(); return; } + reconcileSelection(panels); dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`; - const panels = [...preview.panels].sort(comparePreviewPanels); + dom.previewGrid.className = `preview-grid preview-grid-mode-${apiState.ui.previewMode}`; + + const columnCount = uniqueNodeIds(panels).length; + dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(112px, 1fr))`; + + const layoutSignature = panels.map((panel) => panel.key).join("|"); + if (force || layoutSignature !== apiState.ui.previewLayoutSignature) { + apiState.ui.previewLayoutSignature = layoutSignature; + buildPreviewTiles(panels); + } + + panels.forEach((panel) => patchPreviewTile(panel)); + renderPreviewSelection(); + } + + function buildPreviewTiles(panels) { + apiState.ui.previewNodes.clear(); + apiState.ui.previewSignatures.clear(); + dom.previewGrid.innerHTML = ""; + panels.forEach((panel) => { - const card = document.createElement("article"); - card.className = "preview-card"; - card.style.setProperty("--preview-color", panel.representative_color_hex); - card.innerHTML = ` -
-
-

${panel.node_id}

-
${panel.panel_position} / ${panel.source}
-
- ${panel.energy_percent}% -
-
-
-
- ${panel.sample_led_hex - .map( - (hex) => - `` - ) - .join("")} -
- `; - dom.previewGrid.appendChild(card); + const tile = document.createElement("button"); + tile.type = "button"; + tile.className = "preview-tile"; + tile.dataset.key = panel.key; + tile.style.gridColumn = String(panel.col); + tile.style.gridRow = String(panel.row); + + const previewShell = document.createElement("div"); + previewShell.className = "tile-preview-shell"; + + const ledRing = document.createElement("div"); + ledRing.className = "tile-led-ring"; + const leds = TILE_LED_GEOMETRY.map((ledSpec) => { + const led = document.createElement("span"); + led.className = `tile-led tile-led-${ledSpec.side}`; + led.style.left = `${(ledSpec.x * 100).toFixed(3)}%`; + led.style.top = `${(ledSpec.y * 100).toFixed(3)}%`; + ledRing.appendChild(led); + return led; + }); + previewShell.appendChild(ledRing); + + const overlay = document.createElement("div"); + overlay.className = "tile-overlay"; + + const label = document.createElement("div"); + label.className = "tile-label"; + + const caption = document.createElement("div"); + caption.className = "tile-caption"; + + const meta = document.createElement("div"); + meta.className = "tile-meta"; + const metaLeft = document.createElement("span"); + const metaRight = document.createElement("span"); + meta.appendChild(metaLeft); + meta.appendChild(metaRight); + + overlay.appendChild(label); + overlay.appendChild(caption); + overlay.appendChild(meta); + + tile.appendChild(previewShell); + tile.appendChild(overlay); + + tile.addEventListener("click", () => { + apiState.ui.selectedPanelKey = panel.key; + renderPreviewSelection(); + renderSelectedTile(); + }); + + dom.previewGrid.appendChild(tile); + apiState.ui.previewNodes.set(panel.key, { + root: tile, + previewShell: previewShell, + label: label, + caption: caption, + metaLeft: metaLeft, + metaRight: metaRight, + leds: leds, + }); }); } + function patchPreviewTile(panel) { + const nodes = apiState.ui.previewNodes.get(panel.key); + if (!nodes) { + return; + } + + const sampleSignature = panel.sample_led_hex.join(","); + const signature = [ + panel.display_id, + panel.display_caption, + panel.representative_color_hex, + panel.energy_percent, + panel.source, + sampleSignature, + panel.connection, + ].join("|"); + + if (signature !== apiState.ui.previewSignatures.get(panel.key)) { + apiState.ui.previewSignatures.set(panel.key, signature); + const ledColors = buildPreviewLedColors(panel); + nodes.root.style.setProperty("--tile-color", panel.representative_color_hex); + nodes.root.style.setProperty("--tile-glow", panel.representative_color_hex); + nodes.root.style.setProperty("--led-opacity", previewLedOpacity(panel.energy_percent)); + nodes.label.textContent = panel.display_id; + nodes.caption.textContent = panel.display_caption; + nodes.metaLeft.textContent = `${panel.panel_position} / ${panel.source}`; + nodes.metaRight.textContent = `${panel.energy_percent}%`; + nodes.leds.forEach((led, index) => { + const color = ledColors[index] || panel.representative_color_hex; + led.style.backgroundColor = color; + led.style.boxShadow = `0 0 5px ${color}`; + }); + } + + nodes.root.classList.toggle("is-panel-test", panel.source === "panel_test"); + nodes.root.classList.toggle("is-blackout", panel.source === "blackout" || panel.energy_percent === 0); + nodes.root.title = `${panel.node_id} / ${panel.panel_position} / ${panel.source}`; + } + + function renderPreviewSelection() { + apiState.ui.previewNodes.forEach((nodes, key) => { + nodes.root.classList.toggle("is-selected", key === apiState.ui.selectedPanelKey); + }); + } + + function renderSelectedTile() { + const panel = selectedPanel(); + if (!panel) { + dom.selectedTileCard.className = "selected-tile-card empty-state"; + dom.selectedTileCard.textContent = "Click a tile in the preview."; + dom.whiteTestButton.disabled = true; + dom.livePatternButton.disabled = true; + return; + } + + dom.selectedTileCard.className = "selected-tile-card"; + dom.selectedTileCard.innerHTML = + `
${escapeHtml(panel.display_id)}
` + + `
${escapeHtml(panel.display_caption)}
` + + '
' + + infoRow("Node", panel.node_id) + + infoRow("Position", panel.panel_position) + + infoRow("Source", panel.source) + + infoRow("Energy", `${panel.energy_percent}%`) + + infoRow("LEDs", panel.led_count ? String(panel.led_count) : "n/a") + + infoRow("Output", panel.physical_output_name || "n/a") + + infoRow("Driver", panel.driver_reference || "n/a") + + infoRow("Connection", panel.connection || "unknown") + + infoRow("Validation", panel.validation_state || "n/a") + + "
"; + dom.whiteTestButton.disabled = false; + dom.livePatternButton.disabled = false; + dom.livePatternButton.textContent = panel.source === "panel_test" ? "Live Pattern Auto" : "Live Pattern"; + } + function renderSummaryCards(state) { + if (!dom.summaryCards) { + return; + } const scene = state.active_scene; const global = state.global; const engine = state.engine; const nodeStats = summarizeNodes(state.nodes || []); - const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length; - const cards = [ { - label: "Active Pattern", - value: scene.pattern_id, - detail: scene.preset_id ? `Preset ${scene.preset_id}` : "live scene", + label: "Pattern", + value: operatorModeLabelForPatternId(scene.pattern_id), + detail: scene.preset_id ? `Preset ${scene.preset_id}` : "Live scene", }, { - label: "Group Target", + label: "Target", value: scene.target_group || "all_panels", - detail: `${(apiState.catalog?.groups || []).length} groups available`, + detail: apiState.controlClient.pending.patternId ? "Pending edit buffer armed" : "No staged pattern", }, { label: "Transition", value: `${global.transition_style} / ${global.transition_duration_ms} ms`, detail: engine.active_transition - ? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%` - : "idle", - }, - { - label: "Brightness", - value: `${Math.round(global.master_brightness * 100)}%`, - detail: global.blackout ? "blackout active" : "output live", - }, - { - label: "Engine", - value: `${engine.frame_hz} fps target`, - detail: `${engine.logic_hz} hz logic / frame ${engine.frame_index}`, + ? `${engine.active_transition.to_pattern_id} ${Math.round(engine.active_transition.progress * 100)}%` + : "Idle", }, { label: "Nodes", value: `${nodeStats.online}/${state.nodes.length} online`, detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, }, - { - label: "Creative Snapshots", - value: `${creativeSnapshotCount}`, - detail: `${(apiState.catalog?.presets || []).length} presets in library`, - }, ]; dom.summaryCards.innerHTML = cards - .map( - (card) => ` -
- ${card.value} - ${card.label} -
${card.detail}
-
- ` - ) + .map((card) => { + return ( + '
' + + `${escapeHtml(card.value)}` + + `${escapeHtml(card.label)}` + + `
${escapeHtml(card.detail)}
` + + "
" + ); + }) + .join(""); + } + + function renderPendingSession() { + const client = apiState.controlClient; + const pending = client.pending; + const stagedMode = apiState.ui.workMode === "show_event"; + + dom.sessionScopeLabel.textContent = stagedMode ? client.sessionLabel : "immediate local apply"; + dom.pendingPanelDescription.textContent = stagedMode + ? "Stage direct edits locally and commit them consciously." + : "Pattern and parameter changes apply immediately in local test/edit mode."; + + let commitLabel = "idle"; + let commitClass = "status-chip status-chip-idle"; + if (!stagedMode) { + commitLabel = "direct"; + commitClass = "status-chip status-chip-live"; + } else if (client.commitState === "committing") { + commitLabel = "committing"; + commitClass = "status-chip status-chip-warning"; + } else if (client.commitState === "committed") { + commitLabel = "committed"; + commitClass = "status-chip status-chip-success"; + } else if (client.commitState === "error") { + commitLabel = "error"; + commitClass = "status-chip status-chip-alert"; + } else if (client.hasPending()) { + commitLabel = "staged"; + commitClass = "status-chip status-chip-warning"; + } + dom.pendingCommitPill.textContent = commitLabel; + dom.pendingCommitPill.className = commitClass; + dom.pendingCompactLabel.textContent = stagedMode && client.hasPending() + ? `${Object.keys(pending.parameters).length + (pending.patternId ? 1 : 0)} edits` + : (stagedMode ? "empty" : "live"); + + const cards = []; + if (stagedMode && pending.patternId) { + cards.push(renderPendingCard("Pattern", operatorModeLabelForPatternId(pending.patternId))); + } + if (stagedMode && pending.hasGroupTarget) { + cards.push(renderPendingCard("Target Group", pending.groupId || "all_panels")); + } + if (stagedMode && (pending.transitionStyle || pending.transitionDurationMs !== null)) { + const detail = [ + pending.transitionStyle || "inherit", + pending.transitionDurationMs !== null ? `${pending.transitionDurationMs} ms` : "duration unchanged", + ].join(" / "); + cards.push(renderPendingCard("Transition", detail)); + } + const parameterKeys = stagedMode ? Object.keys(pending.parameters) : []; + if (parameterKeys.length) { + cards.push(renderPendingCard("Parameters", parameterKeys.join(", "))); + } + + dom.pendingSessionSummary.innerHTML = cards.length + ? cards.join("") + : (stagedMode + ? '
No staged transition yet. Pattern, look, color and motion edits stay local until commit.
' + : '
Local test/edit mode is live. Pattern, look, color and motion changes apply immediately without Go or Fade Go.
'); + + if (client.lastError) { + dom.primitiveErrorBanner.classList.remove("hidden"); + dom.primitiveErrorBanner.innerHTML = + `${escapeHtml(client.lastError.code)}` + + `${escapeHtml(client.lastError.message)}`; + } else { + dom.primitiveErrorBanner.classList.add("hidden"); + dom.primitiveErrorBanner.innerHTML = ""; + } + + dom.triggerTransitionButton.disabled = !stagedMode || client.commitState === "committing" || !client.hasPending(); + dom.clearStagedButton.disabled = !stagedMode || !client.hasPending(); + dom.goButton.disabled = !stagedMode; + dom.utilityGoButton.disabled = !stagedMode; + dom.fadeGoButton.disabled = !stagedMode; + dom.utilityFadeGoButton.disabled = !stagedMode; + } + + function renderPendingCard(label, detail) { + return ( + '
' + + `${escapeHtml(label)}` + + `${escapeHtml(detail)}` + + "
" + ); + } + + function renderViewOutput(state, force) { + if (!state) { + return; + } + const selected = selectedPanel(); + const nodeStats = summarizeNodes(state.nodes || []); + const signature = [ + apiState.ui.previewMode, + state.engine.frame_hz, + state.engine.preview_hz, + state.global.blackout, + state.global.master_brightness, + nodeStats.online, + nodeStats.degraded, + nodeStats.offline, + selected ? selected.key : "none", + ].join("|"); + + if (!force && signature === apiState.ui.viewOutputSignature) { + return; + } + apiState.ui.viewOutputSignature = signature; + + const rows = [ + { label: "Preview", value: previewModeLabel(apiState.ui.previewMode) }, + { label: "Render FPS", value: `${state.engine.frame_hz} fps` }, + { label: "Preview FPS", value: `${state.engine.preview_hz} fps` }, + { label: "Logic Rate", value: `${state.engine.logic_hz} hz` }, + { label: "Output", value: state.global.blackout ? "Blackout Active" : "Output Enabled" }, + { label: "Master", value: `${Math.round(state.global.master_brightness * 100)}%` }, + { label: "Nodes", value: `${nodeStats.online} online / ${state.nodes.length} total` }, + { label: "Selected", value: selected ? selected.display_id : "No tile selected" }, + ]; + + dom.viewOutputList.innerHTML = rows + .map((row) => { + return ( + '
' + + `
${escapeHtml(row.label)}
` + + `
${escapeHtml(row.value)}
` + + "
" + ); + }) .join(""); } @@ -1106,13 +1892,13 @@ function buildComposedSnapshot() { return { - api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1", + api_version: apiState.stateResponse ? apiState.stateResponse.api_version : "v1", generated_at_millis: - apiState.previewResponse?.generated_at_millis || - apiState.stateResponse?.generated_at_millis || + (apiState.previewResponse && apiState.previewResponse.generated_at_millis) || + (apiState.stateResponse && apiState.stateResponse.generated_at_millis) || 0, - state: apiState.stateResponse?.state || null, - preview: apiState.previewResponse?.preview || null, + state: apiState.stateResponse ? apiState.stateResponse.state : null, + preview: apiState.previewResponse ? apiState.previewResponse.preview : null, catalog: apiState.catalog || null, show_control_client: { mode: apiState.controlClient.mode, @@ -1126,21 +1912,28 @@ apiState.events.unshift({ kind: entry.kind || "info", code: entry.code || null, - ...entry, + at: entry.at, + message: entry.message, }); - apiState.events = apiState.events.slice(0, 50); - renderEvents(); + apiState.events = apiState.events.slice(0, 60); + renderEvents(false); } - function renderEvents() { + function renderEvents(force) { const kindFilter = dom.eventKindFilter.value; const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase(); + const signature = `${kindFilter}|${searchFilter}|${apiState.events.length}|${apiState.events[0] ? apiState.events[0].message : ""}`; + if (!force && signature === apiState.ui.eventFilterSignature) { + return; + } + apiState.ui.eventFilterSignature = signature; + const filtered = apiState.events.filter((entry) => { const kindMatches = kindFilter === "all" || entry.kind === kindFilter; const searchMatches = !searchFilter || - (entry.message || "").toLowerCase().includes(searchFilter) || - (entry.code || "").toLowerCase().includes(searchFilter); + entry.message.toLowerCase().includes(searchFilter) || + String(entry.code || "").toLowerCase().includes(searchFilter); return kindMatches && searchMatches; }); @@ -1150,40 +1943,109 @@ } dom.eventList.innerHTML = filtered - .map( - (entry) => ` -
-
${entry.at}
- ${entry.code ? `${entry.code}` : ""} - ${entry.message} -
- ` - ) + .map((entry) => { + return ( + `
` + + `
${escapeHtml(entry.at)}
` + + (entry.code ? `${escapeHtml(entry.code)}` : "") + + `${escapeHtml(entry.message)}` + + "
" + ); + }) .join(""); } function setConnectionState(kind, message) { + if (!dom.connectionPill) { + return; + } dom.connectionPill.textContent = message; - dom.connectionPill.className = - kind === "online" - ? "pill pill-online" - : kind === "warning" - ? "pill pill-warning" - : "pill pill-offline"; + if (kind === "live") { + dom.connectionPill.className = "status-chip status-chip-live"; + } else if (kind === "alert") { + dom.connectionPill.className = "status-chip status-chip-alert"; + } else { + dom.connectionPill.className = "status-chip status-chip-warning"; + } } - function summarizeNodes(nodes) { - return nodes.reduce( - (summary, node) => { - summary[node.connection] += 1; - return summary; - }, - { online: 0, degraded: 0, offline: 0 } - ); + function renderablePanels() { + const previewPanels = apiState.previewResponse && apiState.previewResponse.preview + ? apiState.previewResponse.preview.panels || [] + : []; + const statePanels = apiState.stateResponse && apiState.stateResponse.state + ? apiState.stateResponse.state.panels || [] + : []; + const stateMap = new Map(); + statePanels.forEach((panel) => { + stateMap.set(panelKey(panel.node_id, panel.panel_position), panel); + }); + + const sorted = previewPanels.slice().sort(comparePreviewPanels); + const nodeIds = uniqueNodeIds(sorted); + + return sorted.map((panel) => { + const statePanel = stateMap.get(panelKey(panel.node_id, panel.panel_position)); + const row = (POSITION_ORDER[panel.panel_position] || 0) + 1; + const col = nodeIds.indexOf(panel.node_id) + 1; + const displayId = `r${row}c${col}`; + return { + key: panelKey(panel.node_id, panel.panel_position), + row: row, + col: col, + display_id: displayId, + display_caption: `R${row} C${col}`, + node_id: panel.node_id, + panel_position: panel.panel_position, + representative_color_hex: panel.representative_color_hex, + sample_led_hex: panel.sample_led_hex || [], + energy_percent: panel.energy_percent, + source: panel.source, + led_count: statePanel ? statePanel.led_count : null, + physical_output_name: statePanel ? statePanel.physical_output_name : null, + driver_reference: statePanel ? statePanel.driver_reference : null, + connection: statePanel ? statePanel.connection : null, + validation_state: statePanel ? statePanel.validation_state : null, + }; + }); + } + + function reconcileSelection(panels) { + const currentPanels = panels || renderablePanels(); + if (!currentPanels.length) { + apiState.ui.selectedPanelKey = null; + return; + } + const exists = currentPanels.some((panel) => panel.key === apiState.ui.selectedPanelKey); + if (!exists) { + apiState.ui.selectedPanelKey = currentPanels[0].key; + } + } + + function selectedPanel() { + const panels = renderablePanels(); + reconcileSelection(panels); + return panels.find((panel) => panel.key === apiState.ui.selectedPanelKey) || null; + } + + function panelKey(nodeId, panelPosition) { + return `${nodeId}:${panelPosition}`; + } + + function uniqueNodeIds(panels) { + const seen = new Set(); + const nodeIds = []; + panels.forEach((panel) => { + if (!seen.has(panel.node_id)) { + seen.add(panel.node_id); + nodeIds.push(panel.node_id); + } + }); + return nodeIds; } function comparePreviewPanels(left, right) { - const leftNode = left.node_id.localeCompare(right.node_id); + const leftNode = left.node_id.localeCompare(right.node_id, undefined, { numeric: true }); if (leftNode !== 0) { return leftNode; } @@ -1191,13 +2053,442 @@ } function panelPositionRank(position) { - if (position === "top") { - return 0; + return POSITION_ORDER[position] || 0; + } + + function looksLikeColorParameter(parameter) { + const value = parameter.value ? parameter.value.value : ""; + return parameter.kind === "text" && (parameter.key.indexOf("color") >= 0 || isHexColorString(value)); + } + + function isHexColorString(value) { + return typeof value === "string" && /^#?[0-9a-f]{6}$/i.test(value.trim()); + } + + function normalizeColorHex(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) { + return "#000000"; } - if (position === "middle") { - return 1; + const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + return /^#[0-9a-f]{6}$/i.test(withHash) ? withHash.toUpperCase() : "#000000"; + } + + function infoRow(label, value) { + return ( + '
' + + `
${escapeHtml(label)}
` + + `
${escapeHtml(value)}
` + + "
" + ); + } + + function summarizeNodes(nodes) { + return nodes.reduce( + (summary, node) => { + if (node.connection === "degraded") { + summary.degraded += 1; + } else if (node.connection === "offline") { + summary.offline += 1; + } else { + summary.online += 1; + } + return summary; + }, + { online: 0, degraded: 0, offline: 0 } + ); + } + + function previewModeLabel(mode) { + return mode === "leds" ? "LEDs Only" : "LEDs Only"; + } + + function normalizeTempoBpm(value) { + if (!Number.isFinite(value)) { + return TEMPO_BPM_DEFAULT; } - return 2; + return Math.round(Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, value))); + } + + function speedToTempoBpm(speedValue) { + if (!Number.isFinite(speedValue)) { + return TEMPO_BPM_DEFAULT; + } + return normalizeTempoBpm(speedValue * SPEED_TO_BPM_FACTOR); + } + + function tempoBpmToSpeed(bpmValue) { + return normalizeTempoBpm(bpmValue) / SPEED_TO_BPM_FACTOR; + } + + function normalizeTransitionSeconds(value) { + if (!Number.isFinite(value)) { + return 2.0; + } + return Math.min(30, Math.max(0.1, value)); + } + + function durationMsToSeconds(durationMs) { + return normalizeTransitionSeconds(Number(durationMs) / 1000); + } + + function secondsToDurationMs(seconds) { + return Math.round(normalizeTransitionSeconds(seconds) * 1000); + } + + function displayedTempoFromState(state, pending) { + if ( + pending && + Object.prototype.hasOwnProperty.call(pending.parameters, "speed") && + pending.parameters.speed + ) { + return speedToTempoBpm(parameterScalarValue(pending.parameters.speed, TEMPO_BPM_DEFAULT / SPEED_TO_BPM_FACTOR)); + } + + const speedParameter = (state.active_scene.parameters || []).find( + (parameter) => parameter.key === "speed" + ); + if (speedParameter) { + return speedToTempoBpm(parameterScalarValue(speedParameter.value, TEMPO_BPM_DEFAULT / SPEED_TO_BPM_FACTOR)); + } + return TEMPO_BPM_DEFAULT; + } + + function setWorkMode(mode) { + const nextMode = mode === "show_event" ? "show_event" : "test_edit"; + if (apiState.ui.workMode === nextMode) { + renderLocalUi(); + return; + } + + if (nextMode === "test_edit" && apiState.controlClient.hasPending()) { + apiState.controlClient.clearPending(); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "staged_transition_cleared", + message: "Staged transition buffer cleared while switching to Test/Edit mode.", + }); + } + + apiState.ui.workMode = nextMode; + renderLocalUi(); + } + + async function applyPatternSelection(modeId) { + const mode = OPERATOR_MODE_BY_ID.get(modeId); + if (!mode) { + return; + } + + apiState.ui.lastSelectedModeId = modeId; + if (apiState.ui.workMode === "show_event") { + const patternOutcome = await handlePrimitive( + { + primitive: "set_pattern", + payload: { pattern_id: mode.pattern_id }, + }, + { announceBuffered: false, rerenderState: false } + ); + if (!patternOutcome) { + return; + } + } else { + try { + await sendCommand( + { + type: "select_pattern", + payload: { pattern_id: mode.pattern_id }, + }, + { announce: false, refresh: false } + ); + + await refreshAll(); + } catch (error) { + handleClientError(error, "select_pattern"); + renderLocalUi(); + return; + } + } + + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "set_pattern", + message: + apiState.ui.workMode === "show_event" + ? `mode staged: ${mode.label}` + : `mode applied: ${mode.label}`, + }); + renderLocalUi(); + } + + async function applyTransitionSettings(style, durationSeconds, announceBuffered) { + const durationMs = secondsToDurationMs(durationSeconds); + if (apiState.ui.workMode === "show_event") { + await handlePrimitive( + { + primitive: "set_transition_style", + payload: { + style: style, + duration_ms: durationMs, + }, + }, + { announceBuffered: announceBuffered, rerenderState: false } + ); + return; + } + + try { + await sendCommand( + { + type: "set_transition_duration_ms", + payload: { duration_ms: durationMs }, + }, + { announce: false, refresh: false } + ); + await sendCommand( + { + type: "set_transition_style", + payload: { style: style }, + }, + { announce: false, refresh: false } + ); + await refreshAll(); + } catch (error) { + handleClientError(error, "set_transition_style"); + renderLocalUi(); + } + } + + async function applyTempoBpm(rawBpmValue) { + const bpm = normalizeTempoBpm(rawBpmValue); + const speed = tempoBpmToSpeed(bpm); + dom.tempoBpmInput.value = String(bpm); + dom.tempoBpmLabel.textContent = `${bpm} BPM`; + + if (apiState.ui.workMode === "show_event") { + const outcome = await handlePrimitive( + { + primitive: "set_group_parameter", + payload: { + group_id: null, + key: "speed", + value: { + kind: "scalar", + value: speed, + }, + }, + }, + { announceBuffered: false, rerenderState: false } + ); + if (outcome) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "set_tempo_bpm", + message: `tempo staged: ${bpm} BPM`, + }); + } + return; + } + + try { + await sendCommand( + { + type: "set_scene_parameter", + payload: { + key: "speed", + value: { + kind: "scalar", + value: speed, + }, + }, + }, + { announce: false, refresh: false } + ); + await refreshAll(); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "set_tempo_bpm", + message: `tempo applied: ${bpm} BPM`, + }); + } catch (error) { + handleClientError(error, "set_tempo_bpm"); + renderLocalUi(); + } + } + + async function applySceneParameterChange(globalGroupId, key, value) { + if (apiState.ui.workMode === "show_event") { + return handlePrimitive( + { + primitive: "set_group_parameter", + payload: { + group_id: apiState.controlClient.effectiveGroupId(globalGroupId), + key: key, + value: value, + }, + }, + { announceBuffered: false, rerenderState: false } + ); + } + + try { + await sendCommand( + { + type: "set_scene_parameter", + payload: { + key: key, + value: value, + }, + }, + { announce: false, refresh: false } + ); + await refreshAll(); + return { kind: "command" }; + } catch (error) { + handleClientError(error, "set_scene_parameter"); + renderLocalUi(); + return null; + } + } + + function availableOperatorModes() { + const patterns = apiState.catalog ? apiState.catalog.patterns || [] : []; + const availablePatternIds = new Set(patterns.map((pattern) => pattern.pattern_id)); + return OPERATOR_MODES.filter((mode) => availablePatternIds.has(mode.pattern_id)); + } + + function displayedOperatorModeId(state, modes) { + const activePatternId = canonicalUiPatternId( + apiState.controlClient.pending.patternId || state.global.selected_pattern + ); + const lastSelectedMode = apiState.ui.lastSelectedModeId + ? OPERATOR_MODE_BY_ID.get(apiState.ui.lastSelectedModeId) + : null; + if (lastSelectedMode && activePatternId === lastSelectedMode.pattern_id) { + return lastSelectedMode.mode_id; + } + + const canonicalMode = modes.find((mode) => mode.pattern_id === activePatternId && mode.canonical) || + modes.find((mode) => mode.pattern_id === activePatternId) || + modes[0]; + return canonicalMode ? canonicalMode.mode_id : ""; + } + + function operatorModeLabelForPatternId(patternId) { + const normalizedPatternId = canonicalUiPatternId(patternId); + const canonicalMode = OPERATOR_MODES.find((mode) => mode.pattern_id === normalizedPatternId && mode.canonical) || + OPERATOR_MODES.find((mode) => mode.pattern_id === normalizedPatternId); + return canonicalMode ? canonicalMode.label : patternId; + } + + function canonicalUiPatternId(patternId) { + switch (patternId) { + case "solid_color": + return "solid"; + case "gradient": + return "column_gradient"; + case "pulse": + return "breathing"; + case "walking_pixel": + return "scan"; + case "noise": + return "sparkle"; + case "chase": + return "sweep"; + default: + return patternId; + } + } + + function buildTileLedGeometry() { + return [] + .concat(perimeterSide("left", 27, 0.02, 0.02, 0.02, 0.98)) + .concat(perimeterSide("bottom", 27, 0.02, 0.98, 0.98, 0.98)) + .concat(perimeterSide("right", 27, 0.98, 0.98, 0.98, 0.02)) + .concat(perimeterSide("top", 25, 0.98, 0.02, 0.02, 0.02)); + } + + function perimeterSide(side, count, startX, startY, endX, endY) { + const positions = []; + for (let index = 0; index < count; index += 1) { + const factor = count === 1 ? 0 : index / (count - 1); + positions.push({ + side: side, + x: lerp(startX, endX, factor), + y: lerp(startY, endY, factor), + }); + } + return positions; + } + + function lerp(start, end, factor) { + return start + (end - start) * factor; + } + + function buildPreviewLedColors(panel) { + if (!panel.energy_percent) { + return TILE_LED_GEOMETRY.map(() => "#070B10"); + } + + const sampleColors = (panel.sample_led_hex || []) + .map((hex) => normalizeColorHex(hex)) + .filter((hex) => isHexColorString(hex)); + const palette = sampleColors.length + ? sampleColors + : [normalizeColorHex(panel.representative_color_hex)]; + + if (palette.length === 1) { + return TILE_LED_GEOMETRY.map(() => palette[0]); + } + + return TILE_LED_GEOMETRY.map((_led, index) => { + const factor = TILE_LED_GEOMETRY.length === 1 ? 0 : index / (TILE_LED_GEOMETRY.length - 1); + return interpolatePalette(palette, factor); + }); + } + + function interpolatePalette(palette, factor) { + const scaled = factor * (palette.length - 1); + const lowerIndex = Math.floor(scaled); + const upperIndex = Math.min(palette.length - 1, Math.ceil(scaled)); + if (lowerIndex === upperIndex) { + return palette[lowerIndex]; + } + return mixHexColors(palette[lowerIndex], palette[upperIndex], scaled - lowerIndex); + } + + function mixHexColors(leftHex, rightHex, factor) { + const left = parseHexColor(leftHex); + const right = parseHexColor(rightHex); + return rgbToHex( + Math.round(lerp(left.r, right.r, factor)), + Math.round(lerp(left.g, right.g, factor)), + Math.round(lerp(left.b, right.b, factor)) + ); + } + + function parseHexColor(hex) { + const normalized = normalizeColorHex(hex).slice(1); + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; + } + + function rgbToHex(r, g, b) { + return `#${[r, g, b].map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase()}`; + } + + function previewLedOpacity(energyPercent) { + if (!energyPercent) { + return "0.18"; + } + return String(Math.min(1, 0.42 + energyPercent / 160)); } function escapeHtml(value) { diff --git a/web/v1/index.html b/web/v1/index.html index 20d5ce9..36b8f48 100644 --- a/web/v1/index.html +++ b/web/v1/index.html @@ -3,219 +3,276 @@ - Infinity Vis Creative Console + Infinity Vis Control -
-
-
-

Infinity Vis / Creative Surface

-

Loading project...

-

- Shared host API bootstrap in progress. -

-
-
-
- API stream - connecting -
-
- Preview refresh - waiting for data -
- + + + + + + + Edit: Live + +
-
-
-
-

Global Look

-

Pattern, preset, group and transition control against the shared host API.

-
- -
-
+ +
+
+
+
+ +
+
+
+

Status & Eventfeed

+

Live status messages stay hot without rebuilding the whole workbench.

+
+
+ connecting + Test/Edit +
+
+ +
+ + +
+
+
+ + +
+ -
-
-

Pending Transition

-

Stage primitives locally and commit them with one explicit trigger.

+
+
+

Presets

+

Quick recall for curated looks plus compact save controls.

-
-
-
- Control mode - stateful -
-
- Commit state - idle -
-
-
- -
- - -
-
-
- -
-
-

Presets

-

Recall look snapshots without leaving the creative console.

-
-
-
- -
-
-

Preset Capture

-

Store or overwrite the current scene as a reusable preset through the same API.

-
-
- -
+ -
-
-

Groups

-

Focus looks on a subset while keeping the core scene model shared.

+
+
+

Selected Tile

+

Stable tile focus for operator actions and diagnostics.

- -
-
+
+ Click a tile in the preview. +
+
+ + +
+ -
-
-

Creative Snapshots

-

Capture exploratory variants without replacing curated presets.

+
+
+

Utilities

+

Fast operator actions aligned with the old desk workflow.

-
- - -
+ +
+
+

Creative Snapshots

+

Capture and recall exploratory variants without changing architecture.

+
+
+ + + -
-
-
+
+ -
-
-

Scene Parameters

-

Rendered from the active scene schema, not hardcoded per frontend.

+
+
+

View & Output

+

Read-only state and output context for live operation.

-
-
- - -
-
-

Preview

-

Live panel previews from the host snapshot and stream feed.

-
-
-
- -
-
-

Snapshot

-

Operator-friendly scene state with a raw API view underneath.

-
-
-

-        
- -
-
-

Event Stream

-

Recent notices from the websocket feed.

-
-
- - -
-
-
+ +
+
+ Raw Snapshot +

+            
+ +
diff --git a/web/v1/styles.css b/web/v1/styles.css index 8d18bc3..52a8b84 100644 --- a/web/v1/styles.css +++ b/web/v1/styles.css @@ -1,21 +1,29 @@ :root { - --bg: #f3ede2; - --bg-secondary: #efe2cd; - --surface: rgba(255, 251, 244, 0.82); - --surface-strong: rgba(255, 248, 238, 0.94); - --line: rgba(56, 63, 61, 0.12); - --text: #1f2424; - --muted: #596463; - --accent: #ea6a36; - --accent-strong: #c34d1c; - --accent-cool: #198c8f; - --danger: #bc2f2f; - --shadow: 0 24px 60px rgba(91, 63, 38, 0.12); - --radius-xl: 28px; - --radius-lg: 22px; - --radius-md: 16px; - --radius-sm: 12px; - --font-sans: "Segoe UI Variable", "Aptos", "Trebuchet MS", sans-serif; + color-scheme: dark; + --bg: #1e1e1e; + --bg-elevated: #252526; + --bg-elevated-2: #2d2d30; + --bg-stage: #0d1016; + --bg-stage-2: #11151d; + --line: #3c3c3c; + --line-soft: #2f2f33; + --text: #cccccc; + --text-strong: #f3f6fb; + --muted: #8f99a5; + --accent: #007acc; + --accent-strong: #094771; + --accent-soft: rgba(0, 122, 204, 0.18); + --warning: #d6a04d; + --danger: #c63b1e; + --danger-soft: rgba(198, 59, 30, 0.18); + --success: #1f8b63; + --success-soft: rgba(31, 139, 99, 0.18); + --shadow: 0 18px 48px rgba(0, 0, 0, 0.34); + --tile-shadow: 0 14px 34px rgba(0, 0, 0, 0.38); + --radius-sm: 3px; + --radius-md: 4px; + --radius-lg: 6px; + --font-ui: "Segoe UI Variable Text", "Segoe UI", "Bahnschrift", sans-serif; } * { @@ -26,298 +34,13 @@ html, body { margin: 0; min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); } body { - background: - radial-gradient(circle at 10% 15%, rgba(234, 106, 54, 0.22), transparent 28%), - radial-gradient(circle at 88% 12%, rgba(25, 140, 143, 0.16), transparent 24%), - radial-gradient(circle at 84% 78%, rgba(239, 202, 130, 0.24), transparent 22%), - linear-gradient(160deg, var(--bg) 0%, var(--bg-secondary) 52%, #f6f2ea 100%); - color: var(--text); - font-family: var(--font-sans); -} - -body::before, -body::after { - content: ""; - position: fixed; - inset: auto; - pointer-events: none; - border-radius: 999px; - filter: blur(32px); - opacity: 0.55; -} - -body::before { - width: 280px; - height: 280px; - top: 18%; - right: -80px; - background: rgba(234, 106, 54, 0.16); -} - -body::after { - width: 320px; - height: 320px; - bottom: -120px; - left: -60px; - background: rgba(25, 140, 143, 0.12); -} - -.page-shell { - width: min(1440px, calc(100vw - 32px)); - margin: 0 auto; - padding: 28px 0 40px; -} - -.hero, -.panel { - background: var(--surface); - border: 1px solid var(--line); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); -} - -.hero { - display: grid; - grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr); - gap: 24px; - padding: 28px; - border-radius: var(--radius-xl); - animation: rise-in 520ms ease-out; -} - -.hero-copy h1, -.section-heading h2, -.subsection-heading h3 { - margin: 0; - letter-spacing: -0.03em; -} - -.hero-copy h1 { - font-size: clamp(2rem, 3vw, 3.6rem); - line-height: 0.95; -} - -.eyebrow { - margin: 0 0 10px; - color: var(--accent-strong); - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.18em; - text-transform: uppercase; -} - -.hero-subtitle, -.section-heading p, -.subsection-heading p, -.field span, -.event-meta, -.status-label, -.preview-meta, -.pill-subtext { - color: var(--muted); -} - -.hero-status { - display: grid; - gap: 14px; - align-content: start; -} - -.status-card { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 14px 16px; - background: var(--surface-strong); - border-radius: var(--radius-md); - border: 1px solid rgba(56, 63, 61, 0.08); -} - -.pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 98px; - padding: 8px 12px; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.pill-online { - background: rgba(25, 140, 143, 0.14); - color: #0f6c6d; -} - -.pill-offline { - background: rgba(188, 47, 47, 0.14); - color: var(--danger); -} - -.pill-warning { - background: rgba(234, 106, 54, 0.14); - color: var(--accent-strong); -} - -.layout { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); - gap: 20px; - margin-top: 22px; -} - -.panel { - border-radius: var(--radius-lg); - padding: 22px; -} - -.controls-panel, -.summary-panel { - grid-column: span 1; -} - -.preview-panel, -.event-panel { - grid-column: span 1; -} - -.section-heading { - display: flex; - justify-content: space-between; - align-items: end; - gap: 18px; - margin-bottom: 18px; -} - -.section-heading p, -.subsection-heading p { - margin: 0; - max-width: 34rem; -} - -.control-grid, -.capture-grid, -.parameter-grid, -.summary-cards { - display: grid; - gap: 14px; -} - -.control-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.capture-grid { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - align-items: end; -} - -.parameter-grid, -.summary-cards { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); -} - -.field, -.parameter-card, -.summary-card, -.preview-card, -.event-item { - background: var(--surface-strong); - border: 1px solid rgba(56, 63, 61, 0.08); - border-radius: var(--radius-md); -} - -.field { - display: grid; - gap: 10px; - padding: 14px; -} - -.field strong { - color: var(--accent-strong); - font-size: 1rem; -} - -.subsection { - margin-top: 24px; -} - -.session-panel { - display: grid; - gap: 14px; -} - -.session-status-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; -} - -.pending-session-summary { - display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); -} - -.pending-card { - padding: 14px; - border-radius: var(--radius-md); - background: rgba(255, 249, 241, 0.9); - border: 1px solid rgba(56, 63, 61, 0.08); -} - -.pending-card strong { - display: block; - margin-bottom: 6px; - color: var(--accent-strong); -} - -.pending-card span { - color: var(--muted); - font-size: 0.9rem; -} - -.session-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.primitive-error-banner { - padding: 14px; - border-radius: var(--radius-md); - background: rgba(188, 47, 47, 0.1); - border: 1px solid rgba(188, 47, 47, 0.16); - color: var(--danger); -} - -.primitive-error-banner strong { - display: block; - margin-bottom: 4px; -} - -.hidden { - display: none !important; -} - -.subsection-heading { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: baseline; - margin-bottom: 12px; -} - -.pill-row { - display: flex; - flex-wrap: wrap; - gap: 10px; + overflow: hidden; } button, @@ -328,45 +51,35 @@ textarea { } button, -select { - border: 1px solid transparent; +select, +input[type="text"], +input[type="number"] { border-radius: var(--radius-sm); } button { cursor: pointer; transition: - transform 140ms ease, - box-shadow 140ms ease, - background-color 140ms ease, - border-color 140ms ease; -} - -button:hover { - transform: translateY(-1px); -} - -button:active { - transform: translateY(0); + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease; } button:disabled { + opacity: 0.55; cursor: not-allowed; - opacity: 0.58; - transform: none; } select, -input[type="text"] { +input[type="text"], +input[type="number"] { width: 100%; - padding: 12px 14px; - background: #fffdfa; - border: 1px solid rgba(56, 63, 61, 0.14); + min-height: 34px; + padding: 7px 10px; + background: #1f1f1f; color: var(--text); -} - -.filter-input { - margin-bottom: 12px; + border: 1px solid var(--line); } input[type="range"] { @@ -374,299 +87,1015 @@ input[type="range"] { accent-color: var(--accent); } -.inline-checkbox { - align-content: start; +.app-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 34%), + var(--bg); } -.inline-checkbox input[type="checkbox"] { - width: 20px; - height: 20px; - margin: 4px 0 0; - accent-color: var(--accent-cool); +.topbar { + display: grid; + grid-template-columns: minmax(210px, 0.95fr) minmax(320px, 1.1fr) minmax(520px, 1.5fr); + gap: 12px; + align-items: center; + padding: 8px 12px; + background: var(--bg-elevated-2); + border-bottom: 1px solid var(--line); } -.ghost-button, -.preset-button, -.group-button { - padding: 11px 14px; - background: #fff9f1; - border-color: rgba(56, 63, 61, 0.12); +.topbar-creative { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 6px 10px; +} + +.topbar-creative .topbar-actions { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.topbar-creative .toolbar-control, +.topbar-creative .toolbar-group, +.topbar-creative .toolbar-button { + min-height: 30px; + padding: 4px 8px; +} + +.topbar-creative .toolbar-control-fade { + min-width: 222px; +} + +.topbar-brand, +.topbar-strip, +.topbar-actions, +.dock-section, +.stage-panel { + min-width: 0; +} + +.brand-title { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-strong); +} + +.brand-project { + margin-top: 2px; + font-size: 0.88rem; + color: var(--muted); +} + +.topbar-strip, +.topbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.toolbar-group, +.toolbar-control { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + background: #1f1f1f; + border: 1px solid var(--line); +} + +.toolbar-control-inline { + white-space: nowrap; +} + +.toolbar-control-fade { + min-width: 250px; +} + +#tempo-bpm-input, +#transition-seconds-input { + width: 82px; + min-width: 82px; +} + +.toolbar-label { + font-size: 0.72rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 100px; + min-height: 24px; + padding: 4px 10px; + font-size: 0.77rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; +} + +.status-chip-live { + background: var(--accent-soft); + border-color: rgba(0, 122, 204, 0.45); + color: #9fd8ff; +} + +.status-chip-warning { + background: rgba(214, 160, 77, 0.16); + border-color: rgba(214, 160, 77, 0.42); + color: #ebc88b; +} + +.status-chip-idle { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); + color: var(--muted); +} + +.status-chip-edit { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.1); color: var(--text); } -.preset-button.active, -.group-button.active { - background: linear-gradient(135deg, rgba(234, 106, 54, 0.16), rgba(25, 140, 143, 0.16)); - border-color: rgba(234, 106, 54, 0.35); +.status-chip-alert { + background: var(--danger-soft); + border-color: rgba(198, 59, 30, 0.45); + color: #ffb09e; } -.preset-button.staged, -.group-button.staged, -.ghost-button.staged { - background: linear-gradient(135deg, rgba(25, 140, 143, 0.16), rgba(234, 106, 54, 0.1)); - border-color: rgba(25, 140, 143, 0.35); - box-shadow: inset 0 0 0 1px rgba(25, 140, 143, 0.08); +.status-chip-success { + background: var(--success-soft); + border-color: rgba(31, 139, 99, 0.42); + color: #94efca; } -.danger-button { - padding: 12px 16px; - background: rgba(188, 47, 47, 0.1); - color: var(--danger); - border-color: rgba(188, 47, 47, 0.18); +.toolbar-button { + min-height: 34px; + padding: 7px 14px; + background: var(--bg-elevated-2); + color: var(--text); + border: 1px solid var(--line); } -.danger-button.is-active { +a.toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.toolbar-button:hover:not(:disabled) { + border-color: var(--accent); +} + +.toolbar-button-primary { + background: var(--accent-strong); + border-color: var(--accent); + color: var(--text-strong); +} + +.toolbar-button-alert { + background: rgba(198, 59, 30, 0.1); + border-color: rgba(198, 59, 30, 0.4); + color: #ffb09e; +} + +.toolbar-button-alert.is-active { background: var(--danger); - color: #fff8f5; - box-shadow: 0 16px 30px rgba(188, 47, 47, 0.24); + color: var(--text-strong); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset; +} + +.toolbar-button-ghost { + background: #1f1f1f; +} + +.workspace { + display: grid; + grid-template-columns: 270px minmax(0, 1fr) 310px; + gap: 8px; + padding: 8px; + min-height: 0; +} + +.workspace-rail, +.workspace-stage { + min-height: 0; +} + +.workspace-rail { + display: grid; + gap: 8px; + align-content: start; + overflow: auto; + padding-right: 2px; +} + +.workspace-stage { + display: grid; + grid-template-rows: minmax(0, 1fr) 220px; + gap: 8px; + min-width: 0; +} + +.stage-panel, +.dock-section { + background: var(--bg-elevated); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.dock-section { + padding: 8px; +} + +.stage-panel { + display: grid; + gap: 10px; + padding: 8px; + min-height: 0; +} + +.stage-panel-preview { + grid-template-rows: auto minmax(0, 1fr); +} + +.stage-panel-events { + grid-template-rows: auto auto minmax(0, 1fr); +} + +.dock-header, +.stage-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.dock-header h2, +.stage-header h2 { + margin: 0; + font-size: 0.96rem; + font-weight: 700; + color: var(--text-strong); +} + +.dock-header p, +.stage-header p, +.preview-meta, +.panel-meta, +.info-detail, +.event-meta { + margin: 3px 0 0; + font-size: 0.78rem; + color: var(--muted); +} + +.stage-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.stage-meta-label { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.control-field, +.compact-form, +.compact-form-two, +.parameter-card label { + display: grid; + gap: 8px; +} + +.control-field span, +.parameter-card span { + font-size: 0.78rem; + color: var(--muted); +} + +.control-field strong, +.mini-status strong, +.summary-card strong { + color: var(--text-strong); +} + +.parameter-stack, +.list-stack, +.button-stack, +.event-list, +.pending-session-summary, +.summary-cards, +.info-list { + display: grid; + gap: 8px; +} + +.button-stack, +.button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.button-row > * { + flex: 1 1 0; +} + +.compact-form { + margin-top: 10px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.compact-form-presets { + grid-template-columns: minmax(0, 1fr) auto; +} + +.compact-form-two { + margin-top: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; +} + +.inline-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 6px; + color: var(--muted); +} + +.pending-status-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mini-status { + padding: 8px 10px; + background: #1f1f1f; + border: 1px solid var(--line); +} + +.preview-stage { + min-height: 0; + padding: 10px 10px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 14%), + linear-gradient(180deg, var(--bg-stage) 0%, var(--bg-stage-2) 100%); + border: 1px solid #20252f; + overflow: auto; } .preview-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; + gap: 10px; + min-height: 100%; + align-content: center; } -.preview-card { - padding: 14px; +.preview-tile { position: relative; + display: block; + min-height: 168px; + padding: 8px; + background: linear-gradient(180deg, #1b1b1b 0%, #111318 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text-strong); + box-shadow: var(--tile-shadow); overflow: hidden; - animation: rise-in 380ms ease-out; } -.preview-card::before { +.preview-tile::after { content: ""; position: absolute; - inset: 0 auto 0 0; - width: 8px; - background: var(--preview-color, #999999); + inset: auto -15% -40% auto; + width: 62%; + height: 62%; + background: radial-gradient(circle, var(--tile-glow, #4d7cff), transparent 72%); + pointer-events: none; + opacity: 0.16; } -.preview-card-header { +.preview-tile:hover { + border-color: rgba(0, 122, 204, 0.35); +} + +.preview-tile.is-selected { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-color: rgba(0, 122, 204, 0.7); +} + +.preview-tile.is-panel-test { + outline-color: #ffffff; +} + +.preview-tile.is-blackout::after { + opacity: 0.12; +} + +.tile-preview-shell { + position: relative; + height: 100%; + min-height: 148px; + border-radius: 4px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 16%), + linear-gradient(180deg, #060912 0%, #080d18 100%); + border: 1px solid rgba(255, 255, 255, 0.03); +} + +.tile-led-ring { + position: absolute; + inset: 0; +} + +.tile-led { + position: absolute; + width: 4px; + height: 4px; + margin-left: -2px; + margin-top: -2px; + border-radius: 999px; + background: var(--tile-color, #4d7cff); + opacity: var(--led-opacity, 0.85); +} + +.tile-overlay { + position: absolute; + inset: 10px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 14px 10px 12px; + pointer-events: none; +} + +.tile-label { + font-size: clamp(1.15rem, 1vw + 0.8rem, 2rem); + font-weight: 700; + line-height: 1; + color: rgba(237, 243, 255, 0.94); +} + +.tile-caption { + margin-top: auto; + font-size: 1rem; + color: rgba(214, 224, 238, 0.76); +} + +.toolbar-link { + white-space: nowrap; +} + +.status-chip-row { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.technical-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 30%), + var(--bg); +} + +.technical-workspace { + display: grid; + grid-template-columns: minmax(360px, 0.78fr) minmax(0, 1.42fr); + gap: 8px; + padding: 8px; + min-height: 0; +} + +.technical-stack { + display: grid; + gap: 8px; + min-height: 0; + align-content: start; + overflow: auto; + padding-right: 2px; +} + +.technical-stack-wide { + align-content: stretch; +} + +.technical-section { + min-width: 0; +} + +.technical-events-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.technical-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.summary-card-active { + border-color: rgba(0, 122, 204, 0.34); +} + +.technical-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.technical-field { + display: grid; + gap: 6px; +} + +.technical-field-wide { + grid-column: 1 / -1; +} + +.technical-checkbox { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 34px; + padding: 0 10px; + background: #1f1f1f; + border: 1px solid var(--line); + color: var(--text); +} + +.technical-note, +.status-banner { + min-height: 34px; + padding: 8px 10px; + background: #1f1f1f; + border: 1px solid var(--line); + color: var(--text); +} + +.status-banner.info { + border-color: rgba(0, 122, 204, 0.3); + color: #b7def8; +} + +.status-banner.success { + border-color: rgba(31, 139, 99, 0.35); + color: #9ae7c7; +} + +.status-banner.warning { + border-color: rgba(214, 160, 77, 0.35); + color: #edcb92; +} + +.status-banner.error { + border-color: rgba(198, 59, 30, 0.4); + color: #ffb09e; +} + +.technical-table-wrap { + overflow: auto; + border: 1px solid var(--line); + background: #1f1f1f; +} + +.technical-table-wrap-grow { + min-height: 0; +} + +.technical-table { + width: 100%; + border-collapse: collapse; + font-size: 0.81rem; +} + +.technical-table th, +.technical-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--line-soft); + text-align: left; + vertical-align: top; +} + +.technical-table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-elevated-2); + color: var(--text-strong); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.technical-table td input, +.technical-table td select { + min-width: 92px; +} + +.table-muted { + font-size: 0.72rem; + color: var(--muted); +} + +.technical-table-dense td { + white-space: nowrap; +} + +.table-cell-stack { + display: grid; + gap: 6px; +} + +.table-action-cell { + min-width: 96px; +} + +.table-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + color: var(--text); +} + +.technical-event-list { + min-height: 0; + overflow: auto; +} + +@media (max-width: 1260px) { + .technical-workspace { + grid-template-columns: 1fr; + } + + .technical-stack { + overflow: visible; + padding-right: 0; + } +} + +.tile-meta { display: flex; justify-content: space-between; gap: 10px; - align-items: baseline; + align-items: end; + font-size: 0.74rem; + color: rgba(214, 224, 238, 0.64); + text-transform: uppercase; + letter-spacing: 0.05em; } -.preview-card h3 { - margin: 0; - font-size: 1rem; +.preview-grid-mode-technical .preview-tile { + background: linear-gradient(180deg, #16181d 0%, #111317 100%); } -.preview-meta { - margin-top: 2px; - font-size: 0.86rem; +.preview-grid-mode-technical .tile-preview-shell { + background: + linear-gradient(180deg, rgba(29, 61, 104, 0.18), transparent 18%), + linear-gradient(180deg, #050811 0%, #0a101a 100%); } -.preview-swatch { - height: 56px; - margin-top: 14px; - border-radius: var(--radius-sm); - background: var(--preview-color, #999999); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); +.preview-grid-mode-leds .preview-tile { + background: linear-gradient(180deg, #121317 0%, #0f1014 100%); } -.energy-bar { - height: 8px; - margin-top: 12px; - border-radius: 999px; - background: rgba(31, 36, 36, 0.08); - overflow: hidden; +.preview-grid-mode-leds .tile-preview-shell { + background: #05070c; + border-color: rgba(255, 255, 255, 0.02); } -.energy-bar > span { - display: block; - height: 100%; - width: var(--energy-width, 0%); - background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84)); +.preview-grid-mode-leds .tile-caption, +.preview-grid-mode-leds .tile-meta { + color: #dbe7f5; } -.sample-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; +.preview-grid-mode-leds .tile-label { + color: rgba(237, 243, 255, 0.76); } -.sample-dot { - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--sample-color, #999999); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.48); +.preview-grid-mode-leds .tile-led { + width: 4.6px; + height: 4.6px; + margin-left: -2.3px; + margin-top: -2.3px; +} + +.preview-grid-mode-tile .tile-preview-shell { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 22%), + linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(8, 12, 18, 0.94)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); +} + +.summary-cards { + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.parameter-card, .summary-card, -.event-item { - padding: 14px; -} - -.parameter-card label { - display: grid; - gap: 10px; -} - -.parameter-card strong, -.summary-card strong { - display: block; - margin-bottom: 4px; - font-size: 1.04rem; +.parameter-card, +.list-item, +.event-item, +.selected-tile-card, +.info-row { + padding: 10px; + background: #1f1f1f; + border: 1px solid var(--line); } .summary-card span { + display: block; + margin-top: 2px; + font-size: 0.74rem; color: var(--muted); - font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; } -.snapshot-json { - margin: 18px 0 0; - max-height: 360px; - overflow: auto; - padding: 18px; - border-radius: var(--radius-md); - background: #1d2222; - color: #e8eceb; - font-size: 0.86rem; - line-height: 1.5; -} - -.event-list { +.list-item { display: grid; - gap: 12px; + gap: 6px; + text-align: left; +} + +.list-item strong { + color: var(--text-strong); +} + +.list-item.active { + border-color: rgba(0, 122, 204, 0.7); + background: rgba(9, 71, 113, 0.32); +} + +.list-item.staged { + border-color: rgba(214, 160, 77, 0.5); +} + +.list-item-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + font-size: 0.76rem; + color: var(--muted); +} + +.meta-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 2px 7px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.72rem; +} + +.parameter-card { + display: grid; + gap: 8px; +} + +.parameter-card.is-staged { + border-color: rgba(214, 160, 77, 0.48); +} + +.parameter-header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: baseline; +} + +.parameter-key { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.parameter-readout { + font-size: 0.8rem; + color: var(--text-strong); +} + +.color-input-row { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.color-input-row input[type="color"] { + width: 44px; + height: 34px; + padding: 0; + border: 1px solid var(--line); + background: #1f1f1f; +} + +.selected-tile-card { + display: grid; + gap: 8px; +} + +.selected-tile-title { + font-size: 1.08rem; + font-weight: 700; + color: var(--text-strong); +} + +.selected-tile-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.info-row { + display: grid; + gap: 4px; + padding: 8px 10px; +} + +.info-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.info-value { + color: var(--text-strong); + word-break: break-word; +} + +.pending-session-summary { + min-height: 48px; +} + +.pending-card { + padding: 10px; + background: rgba(214, 160, 77, 0.08); + border: 1px solid rgba(214, 160, 77, 0.2); +} + +.pending-card strong { + display: block; + margin-bottom: 3px; + color: #ebc88b; +} + +.primitive-error-banner { + padding: 10px; + background: var(--danger-soft); + border: 1px solid rgba(198, 59, 30, 0.4); + color: #ffb09e; +} + +.primitive-error-banner strong { + display: block; + margin-bottom: 4px; } .event-filter-bar { display: grid; - grid-template-columns: 180px minmax(0, 1fr); - gap: 12px; - margin-bottom: 14px; + grid-template-columns: 170px minmax(0, 1fr); + gap: 8px; +} + +.event-list { + overflow: auto; + padding-right: 2px; } .event-item { display: grid; - gap: 8px; + gap: 6px; } -.event-item.event-info strong { - color: var(--accent-strong); +.event-item strong { + color: var(--text-strong); } -.event-item.event-warning strong { - color: #a7631c; +.event-info { + border-left: 3px solid var(--accent); } -.event-item.event-error strong { - color: var(--danger); +.event-warning { + border-left: 3px solid var(--warning); +} + +.event-error { + border-left: 3px solid var(--danger); } .event-code { display: inline-flex; width: fit-content; - padding: 4px 8px; - border-radius: 999px; - background: rgba(31, 36, 36, 0.08); + padding: 2px 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.info-list { + grid-template-columns: 1fr; +} + +.raw-snapshot summary { + cursor: pointer; color: var(--muted); font-size: 0.78rem; - letter-spacing: 0.05em; - text-transform: uppercase; } -.snapshot-list { - display: grid; - gap: 12px; - margin-top: 14px; +.snapshot-json { + margin: 8px 0 0; + max-height: 260px; + overflow: auto; + padding: 10px; + background: #111317; + border: 1px solid #20252f; + color: #dde7f5; + font-size: 0.76rem; + line-height: 1.45; } -.snapshot-card { - display: grid; - gap: 8px; - padding: 14px; - border-radius: var(--radius-md); - background: var(--surface-strong); - border: 1px solid rgba(56, 63, 61, 0.08); -} - -.snapshot-card-header { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: start; -} - -.snapshot-card-header strong { - display: block; - margin-bottom: 2px; -} - -.snapshot-meta-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.meta-chip { - display: inline-flex; - padding: 5px 8px; - border-radius: 999px; - background: rgba(31, 36, 36, 0.08); - color: var(--muted); - font-size: 0.82rem; -} - -.event-item strong { - color: var(--accent-strong); +.filter-input { + margin: 0; } .empty-state { - padding: 18px; - border-radius: var(--radius-md); - background: rgba(255, 255, 255, 0.42); + padding: 12px; + border: 1px dashed var(--line); color: var(--muted); - border: 1px dashed rgba(56, 63, 61, 0.16); } -@keyframes rise-in { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } +.hidden { + display: none !important; } -@media (max-width: 1080px) { - .layout, - .hero, - .control-grid, - .event-filter-bar { +@media (max-width: 1460px) { + .topbar { grid-template-columns: 1fr; } - .section-heading, - .subsection-heading { - align-items: start; - flex-direction: column; - } -} - -@media (max-width: 720px) { - .page-shell { - width: min(100vw - 18px, 100%); - padding-top: 18px; + .workspace { + grid-template-columns: 248px minmax(0, 1fr) 280px; } - .hero, - .panel { - padding: 18px; - } - - .preview-grid, - .parameter-grid, .summary-cards { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + body { + overflow: auto; + } + + .app-shell { + height: auto; + min-height: 100vh; + } + + .workspace { + grid-template-columns: 1fr; + } + + .workspace-stage { + grid-template-rows: minmax(480px, auto) auto; + } + + .workspace-rail, + .workspace-stage { + overflow: visible; } } diff --git a/web/v1/technical.html b/web/v1/technical.html new file mode 100644 index 0000000..fdb584a --- /dev/null +++ b/web/v1/technical.html @@ -0,0 +1,206 @@ + + + + + + Infinity Vis Mapping Settings + + + +
+
+
+
Infinity Vis Mapping Settings
+
Loading technical surface...
+
+ +
+
+ Backend + loading +
+
+ Output + loading +
+
+ Nodes + 0/0 online +
+
+ +
+ Creative Surface + +
+
+ +
+
+
+
+
+

Backend & Output

+

Preview Only and DDP (WLED) stay explicit and honest.

+
+
+ +
+ +
+ + + + + + +
+ Live Status +
Waiting for state...
+
+ +
+ Semantics +
+ Preview Only means no live output. DDP (WLED) is armed only when explicitly enabled. +
+
+
+ +
+ +
+ +
+ +
+
+
+

Node / IP Discovery

+

Scan subnet ranges and assign discovered IPs explicitly to node slots.

+
+
+ +
+ +
+ Discovery + +
+
+
+ No scan executed yet. +
+ +
+ + + + + + + + + + + + +
IPReachableTypeHostname / mDNSAssign to Node SlotApply
+
+
+ +
+
+
+

Node Targets

+

Reserved IPs and honest connection status per node.

+
+
+ +
+ + + + + + + + + + + + +
NodeDisplayReserved IPStatusLive NoteApply
+
+
+
+ +
+
+
+
+

Panel Mapping

+

Real output slots with backend-facing routing details.

+
+
+ +
+ + + + + + + + + + + + + + + + + + +
NodePanelOutputDriver KindGPIO / ChannelLEDsDirectionColorEnabledValidationStatusApply
+
+
+ +
+
+
+

Recent Events

+

Live backend and mapping changes without fake node traffic.

+
+
+
+
+
+
+
+ + + + diff --git a/web/v1/technical.js b/web/v1/technical.js new file mode 100644 index 0000000..24278dd --- /dev/null +++ b/web/v1/technical.js @@ -0,0 +1,951 @@ +const POLL_INTERVAL_MS = 1500; + +const DRIVER_KIND_OPTIONS = [ + { value: "pending_validation", label: "Pending Validation" }, + { value: "gpio", label: "GPIO" }, + { value: "rmt_channel", label: "RMT Channel" }, + { value: "i2s_lane", label: "I2S Lane" }, + { value: "uart_port", label: "UART Port" }, + { value: "spi_bus", label: "SPI Bus" }, + { value: "external_driver", label: "External Driver" }, +]; + +const DIRECTION_OPTIONS = [ + { value: "forward", label: "Forward" }, + { value: "reverse", label: "Reverse" }, +]; + +const COLOR_ORDER_OPTIONS = [ + { value: "rgb", label: "RGB" }, + { value: "rbg", label: "RBG" }, + { value: "grb", label: "GRB" }, + { value: "gbr", label: "GBR" }, + { value: "brg", label: "BRG" }, + { value: "bgr", label: "BGR" }, +]; + +const appState = { + snapshot: null, + outputDraft: null, + outputDirty: false, + nodeDrafts: new Map(), + panelDrafts: new Map(), + discovery: { + subnet: "192.168.40.0/24", + scanning: false, + scannedHosts: 0, + reachableHosts: 0, + results: [], + assignmentDrafts: new Map(), + }, + feedback: { level: "info", message: "" }, + pollHandle: null, +}; + +const elements = { + projectName: document.getElementById("technical-project-name"), + backendPill: document.getElementById("technical-backend-pill"), + outputPill: document.getElementById("technical-output-pill"), + nodesPill: document.getElementById("technical-nodes-pill"), + summaryGrid: document.getElementById("technical-summary-grid"), + backendModeSelect: document.getElementById("backend-mode-select"), + outputEnabledInput: document.getElementById("output-enabled-input"), + outputFpsInput: document.getElementById("output-fps-input"), + liveStatus: document.getElementById("technical-live-status"), + backendSemantics: document.getElementById("technical-backend-semantics"), + feedbackBanner: document.getElementById("technical-feedback-banner"), + nodeTableBody: document.getElementById("node-table-body"), + panelTableBody: document.getElementById("panel-table-body"), + eventList: document.getElementById("technical-event-list"), + refreshButton: document.getElementById("technical-refresh-button"), + saveOutputSettingsButton: document.getElementById("save-output-settings-button"), + discoverySubnetInput: document.getElementById("discovery-subnet-input"), + discoveryScanButton: document.getElementById("discovery-scan-button"), + discoverySummary: document.getElementById("discovery-summary"), + discoveryTableBody: document.getElementById("discovery-table-body"), +}; + +function init() { + bindEvents(); + void loadState(); + appState.pollHandle = window.setInterval(() => { + void loadState(); + }, POLL_INTERVAL_MS); +} + +function bindEvents() { + elements.refreshButton.addEventListener("click", () => { + void loadState(); + }); + + elements.backendModeSelect.addEventListener("change", () => { + ensureOutputDraft(); + appState.outputDraft.backend_mode = elements.backendModeSelect.value; + appState.outputDirty = true; + renderOutputControls(); + }); + + elements.outputEnabledInput.addEventListener("change", () => { + ensureOutputDraft(); + appState.outputDraft.output_enabled = elements.outputEnabledInput.checked; + appState.outputDirty = true; + renderOutputControls(); + }); + + elements.outputFpsInput.addEventListener("input", () => { + ensureOutputDraft(); + appState.outputDraft.output_fps = parseInteger(elements.outputFpsInput.value, 40); + appState.outputDirty = true; + renderOutputControls(); + }); + + elements.saveOutputSettingsButton.addEventListener("click", () => { + void saveOutputSettings(); + }); + + elements.nodeTableBody.addEventListener("input", (event) => { + const row = event.target.closest("[data-node-id]"); + if (!row) { + return; + } + const nodeId = row.dataset.nodeId; + const draft = ensureNodeDraft(nodeId); + draft.reserved_ip = row.querySelector("[data-node-reserved-ip]").value.trim(); + draft.dirty = true; + updateNodeRowState(row, draft); + }); + + elements.nodeTableBody.addEventListener("click", (event) => { + const button = event.target.closest("[data-save-node]"); + if (!button) { + return; + } + void saveNode(button.dataset.saveNode); + }); + + elements.panelTableBody.addEventListener("input", handlePanelDraftInput); + elements.panelTableBody.addEventListener("change", handlePanelDraftInput); + elements.panelTableBody.addEventListener("click", (event) => { + const button = event.target.closest("[data-save-panel]"); + if (!button) { + return; + } + void savePanel(button.dataset.savePanel); + }); + + elements.discoveryScanButton.addEventListener("click", () => { + void runDiscoveryScan(); + }); + + elements.discoverySubnetInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void runDiscoveryScan(); + } + }); + + elements.discoveryTableBody.addEventListener("change", (event) => { + const row = event.target.closest("[data-discovery-ip]"); + if (!row) { + return; + } + const select = row.querySelector("[data-discovery-assignment]"); + const ip = row.dataset.discoveryIp; + appState.discovery.assignmentDrafts.set(ip, select.value); + renderDiscoveryTable(); + }); + + elements.discoveryTableBody.addEventListener("click", (event) => { + const button = event.target.closest("[data-apply-discovery]"); + if (!button) { + return; + } + void applyDiscoveryAssignment(button.dataset.applyDiscovery); + }); +} + +async function loadState() { + try { + const response = await fetch("/api/v1/state", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`state request failed with ${response.status}`); + } + const payload = await response.json(); + appState.snapshot = payload.state; + syncDraftsFromState(payload.state); + render(); + } catch (error) { + setFeedback("error", `Technical state could not be loaded: ${error.message}`); + render(); + } +} + +function syncDraftsFromState(snapshot) { + elements.projectName.textContent = `${snapshot.system.project_name} | ${snapshot.system.topology_label}`; + + if (!appState.outputDirty) { + appState.outputDraft = { + backend_mode: snapshot.technical.backend_mode, + output_enabled: snapshot.technical.output_enabled, + output_fps: snapshot.technical.output_fps, + }; + } + + for (const node of snapshot.nodes) { + const existing = appState.nodeDrafts.get(node.node_id); + if (!existing || !existing.dirty) { + appState.nodeDrafts.set(node.node_id, { + reserved_ip: node.reserved_ip ?? "", + dirty: false, + }); + } + } + + for (const panel of snapshot.panels) { + const key = panelKey(panel.node_id, panel.panel_position); + const existing = appState.panelDrafts.get(key); + if (!existing || !existing.dirty) { + appState.panelDrafts.set(key, { + physical_output_name: panel.physical_output_name, + driver_kind: panel.driver_kind, + driver_reference: panel.driver_reference, + led_count: panel.led_count, + direction: panel.direction, + color_order: panel.color_order, + enabled: panel.enabled, + dirty: false, + }); + } + } + + for (const result of appState.discovery.results) { + if (!appState.discovery.assignmentDrafts.has(result.ip)) { + appState.discovery.assignmentDrafts.set(result.ip, assignedNodeForIp(result.ip) || ""); + } + } +} + +function render() { + renderTopbar(); + renderSummaryCards(); + renderFeedback(); + renderEvents(); + renderDiscoverySummary(); + + if (!isEditingOutputControls()) { + renderOutputControls(); + } + if (!isEditingInside(elements.nodeTableBody)) { + renderNodeTable(); + } + if (!isEditingInside(elements.panelTableBody)) { + renderPanelTable(); + } + if (!isEditingInside(elements.discoveryTableBody)) { + renderDiscoveryTable(); + } +} + +function renderTopbar() { + const snapshot = appState.snapshot; + if (!snapshot) { + setChip(elements.backendPill, "loading", "warning"); + setChip(elements.outputPill, "loading", "warning"); + setChip(elements.nodesPill, "0/0 online", "warning"); + return; + } + + const onlineCount = snapshot.nodes.filter((node) => node.connection === "online").length; + setChip( + elements.backendPill, + backendModeLabel(snapshot.technical.backend_mode), + snapshot.technical.backend_mode === "preview_only" ? "idle" : "live" + ); + setChip( + elements.outputPill, + snapshot.technical.output_enabled ? "enabled" : "disabled", + snapshot.technical.output_enabled ? "success" : "warning" + ); + setChip( + elements.nodesPill, + `${onlineCount}/${snapshot.nodes.length} online`, + onlineCount > 0 ? "success" : "warning" + ); +} + +function renderSummaryCards() { + if (!appState.snapshot) { + elements.summaryGrid.innerHTML = ""; + return; + } + const { technical, nodes, panels } = appState.snapshot; + const onlineCount = nodes.filter((node) => node.connection === "online").length; + const enabledOutputs = panels.filter((panel) => panel.enabled).length; + const liveOutputs = panels.filter( + (panel) => panel.enabled && panel.connection === "online" + ).length; + + elements.summaryGrid.innerHTML = [ + summaryCard("Backend", backendModeLabel(technical.backend_mode), technical.backend_mode), + summaryCard( + "Live Status", + technical.live_status, + technical.output_enabled ? "active" : "idle" + ), + summaryCard( + "Nodes", + `${onlineCount}/${nodes.length} online`, + onlineCount > 0 ? "active" : "idle" + ), + summaryCard( + "Outputs", + `${liveOutputs}/${enabledOutputs} enabled outputs live`, + enabledOutputs > 0 ? "active" : "idle" + ), + ].join(""); +} + +function renderOutputControls() { + if (!appState.snapshot) { + return; + } + ensureOutputDraft(); + elements.backendModeSelect.value = appState.outputDraft.backend_mode; + elements.outputEnabledInput.checked = Boolean(appState.outputDraft.output_enabled); + elements.outputFpsInput.value = String(appState.outputDraft.output_fps); + elements.liveStatus.textContent = appState.snapshot.technical.live_status; + elements.liveStatus.className = `status-banner ${bannerLevelForTechnical(appState.snapshot.technical)}`; + elements.backendSemantics.textContent = backendSemanticsText(appState.outputDraft); + elements.saveOutputSettingsButton.disabled = !appState.outputDirty; +} + +function renderNodeTable() { + if (!appState.snapshot) { + elements.nodeTableBody.innerHTML = ""; + return; + } + + elements.nodeTableBody.innerHTML = appState.snapshot.nodes + .map((node) => { + const draft = ensureNodeDraft(node.node_id); + return ` + + ${escapeHtml(node.node_id)} + ${escapeHtml(node.display_name)} + + + + ${connectionBadge(node.connection)} + +
+ ${escapeHtml(node.error_status ?? "no active error")} +
+ + + + + + `; + }) + .join(""); +} + +function renderPanelTable() { + if (!appState.snapshot) { + elements.panelTableBody.innerHTML = ""; + return; + } + + elements.panelTableBody.innerHTML = appState.snapshot.panels + .map((panel) => { + const key = panelKey(panel.node_id, panel.panel_position); + const draft = ensurePanelDraft(key, panel); + return ` + + ${escapeHtml(panel.node_id)} + ${escapeHtml(panel.panel_position)} + + + + + + + + + + + + + + + + + + + + + + ${escapeHtml(panel.validation_state)} + +
+ ${connectionBadge(panel.connection)} + ${escapeHtml(panel.error_status ?? "no active error")} +
+ + + + + + `; + }) + .join(""); +} + +function renderDiscoverySummary() { + const discovery = appState.discovery; + elements.discoverySubnetInput.value = discovery.subnet; + + if (discovery.scanning) { + elements.discoveryScanButton.disabled = true; + elements.discoveryScanButton.textContent = "Scanning..."; + elements.discoverySummary.textContent = `Scanning subnet ${discovery.subnet}...`; + return; + } + + elements.discoveryScanButton.disabled = false; + elements.discoveryScanButton.textContent = "Discover / Scan"; + if (!discovery.results.length) { + elements.discoverySummary.textContent = "No scan executed yet."; + return; + } + + elements.discoverySummary.textContent = + `Scanned ${discovery.scannedHosts} hosts in ${discovery.subnet}, ` + + `${discovery.reachableHosts} reachable. No automatic assignments are applied.`; +} + +function renderDiscoveryTable() { + if (!appState.discovery.results.length) { + elements.discoveryTableBody.innerHTML = + 'Run a subnet scan to list discoverable IP targets.'; + return; + } + + elements.discoveryTableBody.innerHTML = appState.discovery.results + .map((result) => { + const assignedNode = assignedNodeForIp(result.ip); + const selectedNode = appState.discovery.assignmentDrafts.get(result.ip) || assignedNode || ""; + const changed = selectedNode && selectedNode !== assignedNode; + const canApply = Boolean(selectedNode) && !appState.discovery.scanning; + + return ` + + ${escapeHtml(result.ip)} + ${result.reachable ? connectionBadge("online") : connectionBadge("offline")} + ${escapeHtml(discoveredTypeLabel(result.detected_type))} + ${escapeHtml(result.hostname || "n/a")} + +
+ + + ${assignedNode ? `currently ${escapeHtml(assignedNode)}` : "currently unassigned"} + +
+ + + + + + `; + }) + .join(""); +} + +function renderEvents() { + if (!appState.snapshot) { + elements.eventList.innerHTML = ""; + return; + } + + elements.eventList.innerHTML = appState.snapshot.recent_events + .map( + (event) => ` +
+
+ ${escapeHtml(event.kind)} + ${escapeHtml(event.code ?? "event")} | ${escapeHtml(String(event.at_millis))} ms +
+
${escapeHtml(event.message)}
+
+ ` + ) + .join(""); +} + +function renderFeedback() { + const { message, level } = appState.feedback; + if (!message) { + elements.feedbackBanner.classList.add("hidden"); + elements.feedbackBanner.textContent = ""; + return; + } + elements.feedbackBanner.className = `status-banner ${level}`; + elements.feedbackBanner.textContent = message; +} + +async function runDiscoveryScan() { + const subnet = elements.discoverySubnetInput.value.trim(); + if (!subnet) { + setFeedback("warning", "Subnet is required, e.g. 192.168.40.0/24."); + renderFeedback(); + return; + } + + appState.discovery.subnet = subnet; + appState.discovery.scanning = true; + renderDiscoverySummary(); + renderDiscoveryTable(); + + try { + const response = await fetch("/api/v1/discovery/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subnet }), + }); + const body = await response.json(); + if (!response.ok) { + setFeedback( + "error", + `${body.error?.code ?? "discovery_scan_failed"}: ${body.error?.message ?? "request failed"}` + ); + return; + } + + appState.discovery.subnet = body.subnet || subnet; + appState.discovery.scannedHosts = body.scanned_hosts || 0; + appState.discovery.reachableHosts = body.reachable_hosts || 0; + appState.discovery.results = Array.isArray(body.results) ? body.results : []; + appState.discovery.assignmentDrafts.clear(); + for (const result of appState.discovery.results) { + appState.discovery.assignmentDrafts.set(result.ip, assignedNodeForIp(result.ip) || ""); + } + + setFeedback( + "success", + `Discovery finished: ${appState.discovery.reachableHosts}/${appState.discovery.scannedHosts} reachable.` + ); + } catch (error) { + setFeedback("error", `Discovery scan failed: ${error.message}`); + } finally { + appState.discovery.scanning = false; + renderDiscoverySummary(); + renderDiscoveryTable(); + renderFeedback(); + } +} + +async function applyDiscoveryAssignment(ip) { + if (!appState.snapshot) { + return; + } + const selectedNode = appState.discovery.assignmentDrafts.get(ip) || ""; + if (!selectedNode) { + setFeedback("warning", `Choose a node slot for ${ip} before applying.`); + renderFeedback(); + return; + } + const response = await sendCommand("set_node_reserved_ip", { + node_id: selectedNode, + reserved_ip: ip, + }); + if (!response.ok) { + return; + } + setFeedback("success", `Assigned ${ip} to ${selectedNode}.`); + await loadState(); + renderDiscoveryTable(); +} + +async function saveOutputSettings() { + if (!appState.snapshot) { + return; + } + ensureOutputDraft(); + const current = appState.snapshot.technical; + const draft = appState.outputDraft; + + const commands = []; + if (draft.backend_mode !== current.backend_mode) { + commands.push({ + type: "set_output_backend_mode", + payload: { mode: draft.backend_mode }, + }); + } + if (Boolean(draft.output_enabled) !== Boolean(current.output_enabled)) { + commands.push({ + type: "set_live_output_enabled", + payload: { enabled: Boolean(draft.output_enabled) }, + }); + } + if (Number(draft.output_fps) !== Number(current.output_fps)) { + commands.push({ + type: "set_output_fps", + payload: { output_fps: parseInteger(draft.output_fps, current.output_fps) }, + }); + } + + if (commands.length === 0) { + setFeedback("info", "No backend/output changes to apply."); + renderFeedback(); + return; + } + + for (const command of commands) { + const response = await sendCommand(command.type, command.payload); + if (!response.ok) { + return; + } + } + + appState.outputDirty = false; + setFeedback("success", "Backend/output settings applied."); + await loadState(); +} + +async function saveNode(nodeId) { + if (!appState.snapshot) { + return; + } + const draft = ensureNodeDraft(nodeId); + const payload = { + node_id: nodeId, + reserved_ip: draft.reserved_ip || null, + }; + const response = await sendCommand("set_node_reserved_ip", payload); + if (!response.ok) { + return; + } + draft.dirty = false; + setFeedback("success", `Node target updated for ${nodeId}.`); + await loadState(); +} + +async function savePanel(key) { + if (!appState.snapshot) { + return; + } + const [nodeId, panelPosition] = key.split(":"); + const draft = appState.panelDrafts.get(key); + if (!draft) { + return; + } + const payload = { + node_id: nodeId, + panel_position: panelPosition, + physical_output_name: draft.physical_output_name.trim(), + driver_kind: draft.driver_kind, + driver_reference: draft.driver_reference.trim(), + led_count: parseInteger(draft.led_count, 106), + direction: draft.direction, + color_order: draft.color_order, + enabled: Boolean(draft.enabled), + }; + const response = await sendCommand("update_panel_mapping", payload); + if (!response.ok) { + return; + } + draft.dirty = false; + setFeedback("success", `Panel mapping updated for ${nodeId}:${panelPosition}.`); + await loadState(); +} + +async function sendCommand(type, payload) { + try { + const response = await fetch("/api/v1/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command: { type, payload } }), + }); + const body = await response.json(); + if (!response.ok) { + setFeedback( + "error", + `${body.error?.code ?? "command_failed"}: ${body.error?.message ?? "request failed"}` + ); + renderFeedback(); + return { ok: false, body }; + } + setFeedback("success", body.summary || `${type} accepted`); + renderFeedback(); + return { ok: true, body }; + } catch (error) { + setFeedback("error", `Command ${type} failed: ${error.message}`); + renderFeedback(); + return { ok: false, body: null }; + } +} + +function handlePanelDraftInput(event) { + const row = event.target.closest("[data-panel-key]"); + if (!row) { + return; + } + const key = row.dataset.panelKey; + const draft = appState.panelDrafts.get(key); + if (!draft) { + return; + } + const field = event.target.dataset.field; + if (!field) { + return; + } + draft[field] = event.target.type === "checkbox" ? event.target.checked : event.target.value; + if (field === "led_count") { + draft.led_count = parseInteger(draft.led_count, 106); + } + draft.dirty = true; + updatePanelRowState(row, draft); +} + +function updateNodeRowState(row, draft) { + const button = row.querySelector("[data-save-node]"); + if (button) { + button.disabled = !draft.dirty; + } +} + +function updatePanelRowState(row, draft) { + const button = row.querySelector("[data-save-panel]"); + if (button) { + button.disabled = !draft.dirty; + } + const toggleLabel = row.querySelector(".table-checkbox span"); + if (toggleLabel) { + toggleLabel.textContent = draft.enabled ? "on" : "off"; + } +} + +function ensureOutputDraft() { + if (!appState.outputDraft) { + appState.outputDraft = { + backend_mode: "preview_only", + output_enabled: false, + output_fps: 40, + }; + } +} + +function ensureNodeDraft(nodeId) { + const existing = appState.nodeDrafts.get(nodeId); + if (existing) { + return existing; + } + const draft = { reserved_ip: "", dirty: false }; + appState.nodeDrafts.set(nodeId, draft); + return draft; +} + +function ensurePanelDraft(key, panel = null) { + const existing = appState.panelDrafts.get(key); + if (existing) { + return existing; + } + const draft = { + physical_output_name: panel?.physical_output_name || "", + driver_kind: panel?.driver_kind || "pending_validation", + driver_reference: panel?.driver_reference || "", + led_count: panel?.led_count || 106, + direction: panel?.direction || "forward", + color_order: panel?.color_order || "grb", + enabled: Boolean(panel?.enabled), + dirty: false, + }; + appState.panelDrafts.set(key, draft); + return draft; +} + +function assignedNodeForIp(ip) { + if (!appState.snapshot || !ip) { + return null; + } + const match = appState.snapshot.nodes.find((node) => node.reserved_ip === ip); + return match ? match.node_id : null; +} + +function renderNodeOptions(selectedNodeId) { + const nodes = appState.snapshot ? appState.snapshot.nodes : []; + return nodes + .map((node) => { + const selected = node.node_id === selectedNodeId ? "selected" : ""; + return ``; + }) + .join(""); +} + +function discoveredTypeLabel(type) { + switch (type) { + case "wled": + return "WLED"; + case "native_node": + return "native node"; + default: + return "unknown"; + } +} + +function setFeedback(level, message) { + appState.feedback = { level, message }; +} + +function setChip(element, text, tone) { + element.className = `status-chip ${chipClassForTone(tone)}`; + element.textContent = text; +} + +function chipClassForTone(tone) { + switch (tone) { + case "success": + return "status-chip-success"; + case "live": + return "status-chip-live"; + case "idle": + return "status-chip-idle"; + case "alert": + return "status-chip-alert"; + default: + return "status-chip-warning"; + } +} + +function eventKindChipClass(kind) { + switch (kind) { + case "info": + return "status-chip-live"; + case "warning": + return "status-chip-warning"; + case "error": + return "status-chip-alert"; + default: + return "status-chip-idle"; + } +} + +function connectionBadge(connection) { + const tone = + connection === "online" ? "success" : connection === "degraded" ? "warning" : "idle"; + return `${escapeHtml(connection)}`; +} + +function bannerLevelForTechnical(technical) { + if (technical.backend_mode === "preview_only") { + return "info"; + } + return technical.output_enabled ? "success" : "warning"; +} + +function backendModeLabel(mode) { + return mode === "ddp_wled" ? "DDP (WLED)" : "Preview Only"; +} + +function backendSemanticsText(draft) { + if (draft.backend_mode === "preview_only") { + return "Preview Only keeps the renderer local and sends no live output."; + } + if (!draft.output_enabled) { + return "DDP (WLED) is selected, but live output stays disabled until explicitly armed."; + } + return "DDP (WLED) is armed for live output. Node status is shown below without simulation."; +} + +function summaryCard(label, value, tone) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; +} + +function renderOptions(options, selectedValue) { + return options + .map( + (option) => ` + + ` + ) + .join(""); +} + +function panelKey(nodeId, panelPosition) { + return `${nodeId}:${panelPosition}`; +} + +function isEditingInside(container) { + const activeElement = document.activeElement; + return Boolean(activeElement && container.contains(activeElement)); +} + +function isEditingOutputControls() { + const activeElement = document.activeElement; + return Boolean( + activeElement && + (activeElement === elements.backendModeSelect || + activeElement === elements.outputEnabledInput || + activeElement === elements.outputFpsInput) + ); +} + +function parseInteger(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +init();