Stabilize control surface and external bridge v1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
||||
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const HOST_API_VERSION: u16 = 1;
|
||||
@@ -9,6 +9,7 @@ pub struct HostSnapshot {
|
||||
pub backend_label: String,
|
||||
pub generated_at_millis: u64,
|
||||
pub system: SystemSnapshot,
|
||||
pub technical: TechnicalSnapshot,
|
||||
pub global: GlobalControlSnapshot,
|
||||
pub engine: EngineSnapshot,
|
||||
pub catalog: CatalogSnapshot,
|
||||
@@ -27,6 +28,21 @@ pub struct SystemSnapshot {
|
||||
pub topology_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TechnicalSnapshot {
|
||||
pub backend_mode: OutputBackendMode,
|
||||
pub output_enabled: bool,
|
||||
pub output_fps: u16,
|
||||
pub live_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputBackendMode {
|
||||
PreviewOnly,
|
||||
DdpWled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GlobalControlSnapshot {
|
||||
pub blackout: bool,
|
||||
@@ -204,6 +220,7 @@ pub struct NodeSnapshot {
|
||||
pub struct PanelSnapshot {
|
||||
pub target: PanelTarget,
|
||||
pub physical_output_name: String,
|
||||
pub driver_kind: DriverKind,
|
||||
pub driver_reference: String,
|
||||
pub led_count: u16,
|
||||
pub direction: LedDirection,
|
||||
@@ -248,6 +265,23 @@ pub struct PanelTarget {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
|
||||
pub enum HostCommand {
|
||||
SetOutputBackendMode(OutputBackendMode),
|
||||
SetLiveOutputEnabled(bool),
|
||||
SetOutputFps(u16),
|
||||
SetNodeReservedIp {
|
||||
node_id: String,
|
||||
reserved_ip: Option<String>,
|
||||
},
|
||||
UpdatePanelMapping {
|
||||
target: PanelTarget,
|
||||
physical_output_name: String,
|
||||
driver_kind: DriverKind,
|
||||
driver_reference: String,
|
||||
led_count: u16,
|
||||
direction: LedDirection,
|
||||
color_order: ColorOrder,
|
||||
enabled: bool,
|
||||
},
|
||||
SetBlackout(bool),
|
||||
SetMasterBrightness(f32),
|
||||
SelectPattern(String),
|
||||
@@ -271,6 +305,9 @@ pub enum HostCommand {
|
||||
preset_id: String,
|
||||
overwrite: bool,
|
||||
},
|
||||
DeletePreset {
|
||||
preset_id: String,
|
||||
},
|
||||
SaveCreativeSnapshot {
|
||||
snapshot_id: String,
|
||||
label: Option<String>,
|
||||
@@ -334,6 +371,22 @@ impl SceneTransitionStyle {
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputBackendMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::PreviewOnly => "preview_only",
|
||||
Self::DdpWled => "ddp_wled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::PreviewOnly => "Preview Only",
|
||||
Self::DdpWled => "DDP (WLED)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CatalogSource {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -369,6 +422,13 @@ impl SceneParameterValue {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Text(value) => Some(value.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_toggle(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Toggle(value) => Some(*value),
|
||||
|
||||
483
crates/infinity_host/src/external_bridge.rs
Normal file
483
crates/infinity_host/src/external_bridge.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use crate::{
|
||||
ActiveSceneSnapshot, BufferedShowControlAdapter, EngineSnapshot, ExternalShowControlAdapter,
|
||||
ExternalShowControlPort, GlobalControlSnapshot, HostApiPort, HostCommandError, HostSnapshot,
|
||||
NodeSnapshot, ShowControlPendingState, ShowControlPrimitive, ShowControlPrimitiveOutcome,
|
||||
StatusEvent, SystemSnapshot, SHOW_CONTROL_V1_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{self, BufRead, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExternalBridgeRequest {
|
||||
#[serde(default)]
|
||||
pub request_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
pub command: ExternalBridgeCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
|
||||
pub enum ExternalBridgeCommand {
|
||||
ExecutePrimitive { primitive: ShowControlPrimitive },
|
||||
GetState,
|
||||
ClearSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExternalBridgeResponse {
|
||||
pub semantic_version: String,
|
||||
pub request_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub result: ExternalBridgeResult,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session: Option<ExternalBridgeSessionView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
|
||||
pub enum ExternalBridgeResult {
|
||||
PrimitiveBuffered {
|
||||
summary: String,
|
||||
},
|
||||
CommandAccepted {
|
||||
generated_at_millis: u64,
|
||||
summary: String,
|
||||
},
|
||||
Snapshot {
|
||||
snapshot: HostSnapshot,
|
||||
},
|
||||
State {
|
||||
state: ExternalBridgeStateView,
|
||||
},
|
||||
SessionCleared {
|
||||
had_session: bool,
|
||||
},
|
||||
Error {
|
||||
error: ExternalBridgeError,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExternalBridgeError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExternalBridgeSessionView {
|
||||
pub session_id: String,
|
||||
pub pending: ShowControlPendingState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExternalBridgeStateView {
|
||||
pub generated_at_millis: u64,
|
||||
pub system: SystemSnapshot,
|
||||
pub global: GlobalControlSnapshot,
|
||||
pub engine: EngineSnapshot,
|
||||
pub active_scene: ActiveSceneSnapshot,
|
||||
pub nodes: Vec<NodeSnapshot>,
|
||||
pub panels: Vec<crate::PanelSnapshot>,
|
||||
pub recent_events: Vec<StatusEvent>,
|
||||
}
|
||||
|
||||
pub struct ExternalControlBridge {
|
||||
service: Arc<dyn HostApiPort>,
|
||||
sessions: BTreeMap<String, BufferedShowControlAdapter>,
|
||||
}
|
||||
|
||||
impl ExternalControlBridge {
|
||||
pub fn new(service: Arc<dyn HostApiPort>) -> Self {
|
||||
Self {
|
||||
service,
|
||||
sessions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_request(&mut self, request: ExternalBridgeRequest) -> ExternalBridgeResponse {
|
||||
match self.try_handle_request(request.clone()) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let session_view = self.session_view(request.session_id.as_deref());
|
||||
ExternalBridgeResponse::error(
|
||||
request.request_id,
|
||||
request.session_id,
|
||||
error.into(),
|
||||
session_view,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_jsonl<R: BufRead, W: Write>(
|
||||
&mut self,
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
) -> io::Result<()> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let read = reader.read_line(&mut line)?;
|
||||
if read == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match serde_json::from_str::<ExternalBridgeRequest>(&line) {
|
||||
Ok(request) => self.handle_request(request),
|
||||
Err(error) => ExternalBridgeResponse::error(
|
||||
None,
|
||||
None,
|
||||
ExternalBridgeError::new(
|
||||
"invalid_bridge_request_json",
|
||||
format!("bridge request JSON could not be parsed: {error}"),
|
||||
),
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let payload = serde_json::to_string(&response)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
writer.write_all(payload.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn try_handle_request(
|
||||
&mut self,
|
||||
request: ExternalBridgeRequest,
|
||||
) -> Result<ExternalBridgeResponse, HostCommandError> {
|
||||
let request_id = request.request_id.clone();
|
||||
let session_id = request.session_id.clone();
|
||||
match request.command {
|
||||
ExternalBridgeCommand::ExecutePrimitive { primitive } => {
|
||||
let outcome = if let Some(session_id) = session_id.as_deref() {
|
||||
self.sessions
|
||||
.entry(session_id.to_string())
|
||||
.or_default()
|
||||
.apply_primitive(self.service.as_ref(), primitive)?
|
||||
} else {
|
||||
self.service.execute_primitive(primitive)?
|
||||
};
|
||||
let result = match outcome {
|
||||
ShowControlPrimitiveOutcome::Buffered { summary } => {
|
||||
ExternalBridgeResult::PrimitiveBuffered { summary }
|
||||
}
|
||||
ShowControlPrimitiveOutcome::Command(outcome) => {
|
||||
ExternalBridgeResult::CommandAccepted {
|
||||
generated_at_millis: outcome.generated_at_millis,
|
||||
summary: outcome.summary,
|
||||
}
|
||||
}
|
||||
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
|
||||
ExternalBridgeResult::Snapshot { snapshot }
|
||||
}
|
||||
};
|
||||
Ok(ExternalBridgeResponse::success(
|
||||
request_id,
|
||||
session_id.clone(),
|
||||
result,
|
||||
self.session_view(session_id.as_deref()),
|
||||
))
|
||||
}
|
||||
ExternalBridgeCommand::GetState => {
|
||||
let snapshot = self.service.snapshot();
|
||||
Ok(ExternalBridgeResponse::success(
|
||||
request_id,
|
||||
session_id.clone(),
|
||||
ExternalBridgeResult::State {
|
||||
state: ExternalBridgeStateView::from_snapshot(&snapshot),
|
||||
},
|
||||
self.session_view(session_id.as_deref()),
|
||||
))
|
||||
}
|
||||
ExternalBridgeCommand::ClearSession => {
|
||||
let Some(session_id) = session_id else {
|
||||
return Err(HostCommandError::new(
|
||||
"session_id_required",
|
||||
"clear_session requires a session_id",
|
||||
));
|
||||
};
|
||||
let had_session = self.sessions.remove(&session_id).is_some();
|
||||
Ok(ExternalBridgeResponse::success(
|
||||
request_id,
|
||||
Some(session_id),
|
||||
ExternalBridgeResult::SessionCleared { had_session },
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session_view(&self, session_id: Option<&str>) -> Option<ExternalBridgeSessionView> {
|
||||
let session_id = session_id?;
|
||||
let adapter = self.sessions.get(session_id)?;
|
||||
Some(ExternalBridgeSessionView {
|
||||
session_id: session_id.to_string(),
|
||||
pending: adapter.session().pending_state(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalBridgeResponse {
|
||||
fn success(
|
||||
request_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
result: ExternalBridgeResult,
|
||||
session: Option<ExternalBridgeSessionView>,
|
||||
) -> Self {
|
||||
Self {
|
||||
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
|
||||
request_id,
|
||||
session_id,
|
||||
result,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(
|
||||
request_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
error: ExternalBridgeError,
|
||||
session: Option<ExternalBridgeSessionView>,
|
||||
) -> Self {
|
||||
Self {
|
||||
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
|
||||
request_id,
|
||||
session_id,
|
||||
result: ExternalBridgeResult::Error { error },
|
||||
session,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalBridgeError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HostCommandError> for ExternalBridgeError {
|
||||
fn from(value: HostCommandError) -> Self {
|
||||
Self {
|
||||
code: value.code,
|
||||
message: value.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalBridgeStateView {
|
||||
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
|
||||
Self {
|
||||
generated_at_millis: snapshot.generated_at_millis,
|
||||
system: snapshot.system.clone(),
|
||||
global: snapshot.global.clone(),
|
||||
engine: snapshot.engine.clone(),
|
||||
active_scene: snapshot.active_scene.clone(),
|
||||
nodes: snapshot.nodes.clone(),
|
||||
panels: snapshot.panels.clone(),
|
||||
recent_events: snapshot.recent_events.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{HostApiPort, SimulationHostService};
|
||||
use infinity_config::{PanelPosition, ProjectConfig};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sample_project() -> ProjectConfig {
|
||||
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||
.expect("project config must parse")
|
||||
}
|
||||
|
||||
fn bridge() -> ExternalControlBridge {
|
||||
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
|
||||
ExternalControlBridge::new(service)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_preserves_session_pending_state_for_staged_flows() {
|
||||
let mut bridge = bridge();
|
||||
let response = bridge.handle_request(ExternalBridgeRequest {
|
||||
request_id: Some("r1".to_string()),
|
||||
session_id: Some("desk-a".to_string()),
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::SetPattern {
|
||||
pattern_id: "noise".to_string(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(response.semantic_version, SHOW_CONTROL_V1_VERSION);
|
||||
match response.result {
|
||||
ExternalBridgeResult::PrimitiveBuffered { summary } => {
|
||||
assert!(summary.contains("pattern staged: noise"));
|
||||
}
|
||||
other => panic!("expected buffered result, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
response
|
||||
.session
|
||||
.expect("session view")
|
||||
.pending
|
||||
.pattern_id
|
||||
.as_deref(),
|
||||
Some("noise")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_passes_through_session_required_error_unchanged() {
|
||||
let mut bridge = bridge();
|
||||
let response = bridge.handle_request(ExternalBridgeRequest {
|
||||
request_id: Some("r1".to_string()),
|
||||
session_id: None,
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::TriggerTransition,
|
||||
},
|
||||
});
|
||||
|
||||
match response.result {
|
||||
ExternalBridgeResult::Error { error } => {
|
||||
assert_eq!(error.code, "show_control_session_required");
|
||||
}
|
||||
other => panic!("expected error result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_get_state_returns_projection_without_preview() {
|
||||
let mut bridge = bridge();
|
||||
let response = bridge.handle_request(ExternalBridgeRequest {
|
||||
request_id: Some("state-1".to_string()),
|
||||
session_id: None,
|
||||
command: ExternalBridgeCommand::GetState,
|
||||
});
|
||||
|
||||
match response.result {
|
||||
ExternalBridgeResult::State { state } => {
|
||||
assert_eq!(state.system.project_name, "Infinity Vis");
|
||||
assert_eq!(state.nodes.len(), 6);
|
||||
assert_eq!(state.panels.len(), 18);
|
||||
}
|
||||
other => panic!("expected state result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_clear_session_requires_session_id() {
|
||||
let mut bridge = bridge();
|
||||
let response = bridge.handle_request(ExternalBridgeRequest {
|
||||
request_id: Some("clear-1".to_string()),
|
||||
session_id: None,
|
||||
command: ExternalBridgeCommand::ClearSession,
|
||||
});
|
||||
|
||||
match response.result {
|
||||
ExternalBridgeResult::Error { error } => {
|
||||
assert_eq!(error.code, "session_id_required");
|
||||
}
|
||||
other => panic!("expected error result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_can_upsert_group_and_commit_staged_transition() {
|
||||
let mut bridge = bridge();
|
||||
let session_id = Some("desk-a".to_string());
|
||||
|
||||
let requests = [
|
||||
ExternalBridgeRequest {
|
||||
request_id: Some("group".to_string()),
|
||||
session_id: session_id.clone(),
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::UpsertGroup {
|
||||
group_id: "focus_pair".to_string(),
|
||||
tags: vec!["runtime".to_string()],
|
||||
members: vec![
|
||||
crate::PanelTarget {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Top,
|
||||
},
|
||||
crate::PanelTarget {
|
||||
node_id: "node-01".to_string(),
|
||||
panel_position: PanelPosition::Middle,
|
||||
},
|
||||
],
|
||||
overwrite: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ExternalBridgeRequest {
|
||||
request_id: Some("param".to_string()),
|
||||
session_id: session_id.clone(),
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::SetGroupParameter {
|
||||
group_id: Some("focus_pair".to_string()),
|
||||
key: "grain".to_string(),
|
||||
value: crate::SceneParameterValue::Scalar(0.88),
|
||||
},
|
||||
},
|
||||
},
|
||||
ExternalBridgeRequest {
|
||||
request_id: Some("style".to_string()),
|
||||
session_id: session_id.clone(),
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::SetTransitionStyle {
|
||||
style: crate::SceneTransitionStyle::Chase,
|
||||
duration_ms: Some(420),
|
||||
},
|
||||
},
|
||||
},
|
||||
ExternalBridgeRequest {
|
||||
request_id: Some("pattern".to_string()),
|
||||
session_id: session_id.clone(),
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::SetPattern {
|
||||
pattern_id: "noise".to_string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
ExternalBridgeRequest {
|
||||
request_id: Some("trigger".to_string()),
|
||||
session_id,
|
||||
command: ExternalBridgeCommand::ExecutePrimitive {
|
||||
primitive: ShowControlPrimitive::TriggerTransition,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for request in requests {
|
||||
let _ = bridge.handle_request(request);
|
||||
}
|
||||
|
||||
let state = bridge.handle_request(ExternalBridgeRequest {
|
||||
request_id: Some("state".to_string()),
|
||||
session_id: None,
|
||||
command: ExternalBridgeCommand::GetState,
|
||||
});
|
||||
|
||||
match state.result {
|
||||
ExternalBridgeResult::State { state } => {
|
||||
assert_eq!(state.global.selected_group.as_deref(), Some("focus_pair"));
|
||||
assert_eq!(state.global.transition_duration_ms, 420);
|
||||
assert_eq!(state.active_scene.pattern_id, "noise");
|
||||
}
|
||||
other => panic!("expected state result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use crate::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const SHOW_CONTROL_V1_VERSION: &str = "v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
|
||||
pub enum ShowControlPrimitive {
|
||||
@@ -80,6 +82,16 @@ pub enum ShowControlPrimitiveOutcome {
|
||||
Snapshot(HostSnapshot),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ShowControlPendingState {
|
||||
pub pattern_id: Option<String>,
|
||||
pub has_group_target: bool,
|
||||
pub group_id: Option<String>,
|
||||
pub parameters: BTreeMap<String, SceneParameterValue>,
|
||||
pub transition_style: Option<SceneTransitionStyle>,
|
||||
pub transition_duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ShowControlSession {
|
||||
pending_pattern_id: Option<String>,
|
||||
@@ -246,6 +258,25 @@ impl ShowControlSession {
|
||||
self.pending_transition_style = None;
|
||||
self.pending_transition_duration_ms = None;
|
||||
}
|
||||
|
||||
pub fn pending_state(&self) -> ShowControlPendingState {
|
||||
ShowControlPendingState {
|
||||
pattern_id: self.pending_pattern_id.clone(),
|
||||
has_group_target: self.pending_group_id.is_some(),
|
||||
group_id: self.pending_group_id.clone().flatten(),
|
||||
parameters: self.pending_parameters.clone(),
|
||||
transition_style: self.pending_transition_style,
|
||||
transition_duration_ms: self.pending_transition_duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_pending_transition(&self) -> bool {
|
||||
self.pending_pattern_id.is_some()
|
||||
|| self.pending_group_id.is_some()
|
||||
|| !self.pending_parameters.is_empty()
|
||||
|| self.pending_transition_style.is_some()
|
||||
|| self.pending_transition_duration_ms.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
|
||||
@@ -374,6 +405,10 @@ impl<P: HostApiPort> ReferenceShowControlClient<P> {
|
||||
self.adapter.session()
|
||||
}
|
||||
|
||||
pub fn pending_state(&self) -> ShowControlPendingState {
|
||||
self.adapter.session().pending_state()
|
||||
}
|
||||
|
||||
pub fn apply_primitive(
|
||||
&mut self,
|
||||
primitive: ShowControlPrimitive,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod control;
|
||||
pub mod external_bridge;
|
||||
pub mod external_control;
|
||||
pub mod runtime;
|
||||
pub mod scene;
|
||||
@@ -6,6 +7,7 @@ pub mod show_store;
|
||||
pub mod simulation;
|
||||
|
||||
pub use control::*;
|
||||
pub use external_bridge::*;
|
||||
pub use external_control::*;
|
||||
pub use runtime::*;
|
||||
pub use scene::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
|
||||
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
use infinity_host::{ExternalControlBridge, HostApiPort, RealtimeEngine, SimulationHostService};
|
||||
use std::{io, path::PathBuf, process::ExitCode, sync::Arc};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
@@ -32,6 +32,12 @@ enum Command {
|
||||
#[arg(long)]
|
||||
config: PathBuf,
|
||||
},
|
||||
ExternalControlBridge {
|
||||
#[arg(long)]
|
||||
config: PathBuf,
|
||||
#[arg(long, default_value = "data/runtime_state.json")]
|
||||
runtime_state: PathBuf,
|
||||
},
|
||||
OpenValidationPoints,
|
||||
}
|
||||
|
||||
@@ -56,6 +62,10 @@ fn main() -> ExitCode {
|
||||
Command::Validate { config, mode } => validate_command(config, mode),
|
||||
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
|
||||
Command::Snapshot { config } => snapshot_command(config),
|
||||
Command::ExternalControlBridge {
|
||||
config,
|
||||
runtime_state,
|
||||
} => external_control_bridge_command(config, runtime_state),
|
||||
Command::OpenValidationPoints => {
|
||||
print_open_validation_points();
|
||||
ExitCode::SUCCESS
|
||||
@@ -147,6 +157,47 @@ fn snapshot_command(config: PathBuf) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn external_control_bridge_command(config: PathBuf, runtime_state: PathBuf) -> ExitCode {
|
||||
let project = match load_project_from_path(&config) {
|
||||
Ok(project) => project,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let service =
|
||||
match SimulationHostService::try_spawn_shared_with_persistence(project, &runtime_state) {
|
||||
Ok(service) => service,
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"Failed to initialize external control bridge with runtime state '{}': {error}",
|
||||
runtime_state.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"Infinity Vis external control bridge listening on stdin/stdout JSONL with runtime state {}",
|
||||
runtime_state.display()
|
||||
);
|
||||
|
||||
let service: Arc<dyn HostApiPort> = service;
|
||||
let mut bridge = ExternalControlBridge::new(service);
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut reader = stdin.lock();
|
||||
let mut writer = stdout.lock();
|
||||
match bridge.run_jsonl(&mut reader, &mut writer) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("External control bridge failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_open_validation_points() {
|
||||
for line in [
|
||||
"Pending hardware validation gates:",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
use crate::{
|
||||
control::{
|
||||
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
|
||||
PanelTarget, PresetSummary, SceneTransitionStyle,
|
||||
OutputBackendMode, PanelTarget, PresetSummary, SceneTransitionStyle,
|
||||
},
|
||||
scene::{PatternRegistry, SceneRuntime},
|
||||
};
|
||||
use infinity_config::{PanelPosition, ProjectConfig};
|
||||
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ProjectConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -59,16 +59,64 @@ impl Default for PersistedGlobalState {
|
||||
Self {
|
||||
blackout: false,
|
||||
master_brightness: 0.20,
|
||||
transition_duration_ms: 150,
|
||||
transition_duration_ms: 2_000,
|
||||
transition_style: SceneTransitionStyle::Crossfade,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_fps() -> u16 {
|
||||
40
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PersistedNodeState {
|
||||
pub node_id: String,
|
||||
pub reserved_ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PersistedPanelState {
|
||||
pub target: PanelTarget,
|
||||
pub physical_output_name: String,
|
||||
pub driver_kind: DriverKind,
|
||||
pub driver_reference: String,
|
||||
pub led_count: u16,
|
||||
pub direction: LedDirection,
|
||||
pub color_order: ColorOrder,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PersistedTechnicalState {
|
||||
pub backend_mode: OutputBackendMode,
|
||||
pub output_enabled: bool,
|
||||
#[serde(default = "default_output_fps")]
|
||||
pub output_fps: u16,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<PersistedNodeState>,
|
||||
#[serde(default)]
|
||||
pub panels: Vec<PersistedPanelState>,
|
||||
}
|
||||
|
||||
impl Default for PersistedTechnicalState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend_mode: OutputBackendMode::PreviewOnly,
|
||||
output_enabled: false,
|
||||
output_fps: default_output_fps(),
|
||||
nodes: Vec::new(),
|
||||
panels: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct PersistedRuntimeState {
|
||||
pub active_scene: Option<SceneRuntime>,
|
||||
pub global: PersistedGlobalState,
|
||||
#[serde(default)]
|
||||
pub technical: PersistedTechnicalState,
|
||||
pub user_presets: Vec<StoredPreset>,
|
||||
pub user_groups: Vec<StoredGroup>,
|
||||
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
|
||||
@@ -467,6 +515,36 @@ impl ShowStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_preset(&mut self, preset_id: &str) -> Result<(), HostCommandError> {
|
||||
if preset_id.trim().is_empty() {
|
||||
return Err(HostCommandError::new(
|
||||
"invalid_preset_id",
|
||||
"preset_id must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let preset_index = self
|
||||
.presets
|
||||
.iter()
|
||||
.position(|preset| preset.preset_id == preset_id)
|
||||
.ok_or_else(|| {
|
||||
HostCommandError::new(
|
||||
"unknown_preset",
|
||||
format!("preset '{preset_id}' does not exist"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if self.presets[preset_index].source != CatalogSource::RuntimeUser {
|
||||
return Err(HostCommandError::new(
|
||||
"preset_delete_forbidden",
|
||||
format!("preset '{preset_id}' is built-in and cannot be deleted"),
|
||||
));
|
||||
}
|
||||
|
||||
self.presets.remove(preset_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_creative_snapshot(
|
||||
&mut self,
|
||||
snapshot_id: &str,
|
||||
@@ -554,10 +632,12 @@ impl ShowStore {
|
||||
&self,
|
||||
active_scene: &SceneRuntime,
|
||||
global: PersistedGlobalState,
|
||||
technical: PersistedTechnicalState,
|
||||
) -> PersistedRuntimeState {
|
||||
PersistedRuntimeState {
|
||||
active_scene: Some(active_scene.clone()),
|
||||
global,
|
||||
technical,
|
||||
user_presets: self
|
||||
.presets
|
||||
.iter()
|
||||
@@ -663,6 +743,45 @@ mod tests {
|
||||
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_presets_can_be_deleted_but_builtins_cannot() {
|
||||
let registry = PatternRegistry::new();
|
||||
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||
let scene = registry.scene_for_pattern(
|
||||
"noise",
|
||||
None,
|
||||
Some("top_panels".to_string()),
|
||||
31,
|
||||
vec!["#AA8844".to_string()],
|
||||
false,
|
||||
);
|
||||
store
|
||||
.save_preset_from_scene(
|
||||
"runtime_delete_me",
|
||||
&scene,
|
||||
210,
|
||||
SceneTransitionStyle::Crossfade,
|
||||
false,
|
||||
)
|
||||
.expect("runtime preset save should succeed");
|
||||
store
|
||||
.delete_preset("runtime_delete_me")
|
||||
.expect("runtime preset delete should succeed");
|
||||
assert!(store.scene_from_preset_id("runtime_delete_me").is_none());
|
||||
|
||||
let built_in_preset_id = store
|
||||
.catalog(®istry)
|
||||
.presets
|
||||
.iter()
|
||||
.find(|preset| preset.source == CatalogSource::BuiltIn)
|
||||
.map(|preset| preset.preset_id.clone())
|
||||
.expect("sample project should contain built-in presets");
|
||||
let delete_error = store
|
||||
.delete_preset(&built_in_preset_id)
|
||||
.expect_err("built-in presets should not be deletable");
|
||||
assert_eq!(delete_error.code, "preset_delete_forbidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
||||
let registry = PatternRegistry::new();
|
||||
@@ -701,6 +820,7 @@ mod tests {
|
||||
transition_duration_ms: 220,
|
||||
transition_style: SceneTransitionStyle::Chase,
|
||||
},
|
||||
PersistedTechnicalState::default(),
|
||||
);
|
||||
storage.save(&runtime).expect("save should work");
|
||||
let loaded = storage.load().expect("load should work");
|
||||
|
||||
@@ -2,15 +2,19 @@ use crate::{
|
||||
control::{
|
||||
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
|
||||
HostCommand, HostCommandError, HostSnapshot, NodeConnectionState, NodeSnapshot,
|
||||
PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource, SceneTransitionStyle,
|
||||
StatusEvent, StatusEventKind, SystemSnapshot, HOST_API_VERSION,
|
||||
OutputBackendMode, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
|
||||
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot, TechnicalSnapshot,
|
||||
HOST_API_VERSION,
|
||||
},
|
||||
runtime::TickSchedule,
|
||||
scene::{
|
||||
apply_group_gate, blackout_preview, blend_previews, panel_membership_key,
|
||||
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
|
||||
},
|
||||
show_store::{PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError},
|
||||
show_store::{
|
||||
PersistedGlobalState, PersistedNodeState, PersistedPanelState, PersistedTechnicalState,
|
||||
RuntimeStateStorage, ShowStore, ShowStoreError,
|
||||
},
|
||||
};
|
||||
use infinity_config::{PanelPosition, ProjectConfig};
|
||||
use std::{
|
||||
@@ -43,6 +47,7 @@ struct SimulationState {
|
||||
schedule: TickSchedule,
|
||||
current_scene: SceneRuntime,
|
||||
active_transition: Option<TransitionRuntime>,
|
||||
technical_state: PersistedTechnicalState,
|
||||
snapshot: HostSnapshot,
|
||||
}
|
||||
|
||||
@@ -135,12 +140,17 @@ impl SimulationState {
|
||||
let persisted_runtime = runtime_load.runtime;
|
||||
let restored_scene = persisted_runtime.active_scene.clone();
|
||||
let restored_global = persisted_runtime.global.clone();
|
||||
let restored_technical = persisted_runtime.technical.clone();
|
||||
show_store.apply_persisted(persisted_runtime);
|
||||
let group_members = show_store.group_members_map();
|
||||
let schedule = TickSchedule::default();
|
||||
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry));
|
||||
let catalog = show_store.catalog(®istry);
|
||||
let available_patterns = show_store.available_patterns(®istry);
|
||||
let initial_offline_status = offline_status_message(
|
||||
restored_technical.backend_mode,
|
||||
restored_technical.output_enabled,
|
||||
);
|
||||
let nodes = project
|
||||
.topology
|
||||
.nodes
|
||||
@@ -149,9 +159,9 @@ impl SimulationState {
|
||||
node_id: node.node_id.clone(),
|
||||
display_name: node.display_name.clone(),
|
||||
reserved_ip: node.network.reserved_ip.clone(),
|
||||
connection: NodeConnectionState::Online,
|
||||
last_contact_ms: 10,
|
||||
error_status: None,
|
||||
connection: NodeConnectionState::Offline,
|
||||
last_contact_ms: 0,
|
||||
error_status: Some(initial_offline_status.clone()),
|
||||
panel_count: node.outputs.len(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -160,21 +170,23 @@ impl SimulationState {
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|node| {
|
||||
let initial_offline_status = initial_offline_status.clone();
|
||||
node.outputs.iter().map(move |output| PanelSnapshot {
|
||||
target: PanelTarget {
|
||||
node_id: node.node_id.clone(),
|
||||
panel_position: output.panel_position.clone(),
|
||||
},
|
||||
physical_output_name: output.physical_output_name.clone(),
|
||||
driver_kind: output.driver_channel.kind.clone(),
|
||||
driver_reference: output.driver_channel.reference.clone(),
|
||||
led_count: output.led_count,
|
||||
direction: output.direction.clone(),
|
||||
color_order: output.color_order.clone(),
|
||||
enabled: output.enabled,
|
||||
validation_state: output.validation_state.clone(),
|
||||
connection: NodeConnectionState::Online,
|
||||
connection: NodeConnectionState::Offline,
|
||||
last_test_trigger_ms: None,
|
||||
error_status: None,
|
||||
error_status: Some(initial_offline_status.clone()),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -193,15 +205,22 @@ impl SimulationState {
|
||||
schedule: schedule.clone(),
|
||||
current_scene,
|
||||
active_transition: None,
|
||||
technical_state: restored_technical.clone(),
|
||||
snapshot: HostSnapshot {
|
||||
api_version: HOST_API_VERSION,
|
||||
backend_label: "simulation-core".to_string(),
|
||||
backend_label: "preview-only simulation".to_string(),
|
||||
generated_at_millis: 0,
|
||||
system: SystemSnapshot {
|
||||
project_name: project.metadata.project_name.clone(),
|
||||
schema_version: project.metadata.schema_version,
|
||||
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
|
||||
},
|
||||
technical: TechnicalSnapshot {
|
||||
backend_mode: restored_technical.backend_mode,
|
||||
output_enabled: restored_technical.output_enabled,
|
||||
output_fps: restored_technical.output_fps,
|
||||
live_status: "preview only - live output disabled".to_string(),
|
||||
},
|
||||
global: GlobalControlSnapshot {
|
||||
blackout: restored_global.blackout,
|
||||
master_brightness: restored_global.master_brightness,
|
||||
@@ -236,13 +255,28 @@ impl SimulationState {
|
||||
recent_events: Vec::new(),
|
||||
},
|
||||
};
|
||||
state.apply_technical_state(&restored_technical);
|
||||
state.refresh_technical_status();
|
||||
state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone();
|
||||
state.snapshot.global.selected_group = state.current_scene.target_group.clone();
|
||||
state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene);
|
||||
let (startup_code, startup_message) = match state.technical_state.backend_mode {
|
||||
OutputBackendMode::PreviewOnly => (
|
||||
"preview_only_mode",
|
||||
"preview-only simulation active; no live nodes connected".to_string(),
|
||||
),
|
||||
OutputBackendMode::DdpWled => (
|
||||
"output_backend_mode",
|
||||
format!(
|
||||
"ddp (wled) mode active; {}",
|
||||
state.snapshot.technical.live_status
|
||||
),
|
||||
),
|
||||
};
|
||||
state.push_event(
|
||||
StatusEventKind::Info,
|
||||
None,
|
||||
"simulation host service started".to_string(),
|
||||
Some(startup_code.to_string()),
|
||||
startup_message,
|
||||
);
|
||||
if state.runtime_storage.is_some() {
|
||||
let (code, message) = if runtime_loaded_from_disk {
|
||||
@@ -298,6 +332,145 @@ impl SimulationState {
|
||||
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
|
||||
let mut should_persist = false;
|
||||
let summary = match command {
|
||||
HostCommand::SetOutputBackendMode(mode) => {
|
||||
self.technical_state.backend_mode = mode;
|
||||
self.refresh_technical_status();
|
||||
should_persist = true;
|
||||
let summary = format!("output backend mode set to {}", mode.display_label());
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetLiveOutputEnabled(enabled) => {
|
||||
self.technical_state.output_enabled = enabled;
|
||||
self.refresh_technical_status();
|
||||
should_persist = true;
|
||||
let summary = if enabled {
|
||||
"live output enabled".to_string()
|
||||
} else {
|
||||
"live output disabled".to_string()
|
||||
};
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetOutputFps(output_fps) => {
|
||||
if !(1..=240).contains(&output_fps) {
|
||||
let error = HostCommandError::new(
|
||||
"invalid_output_fps",
|
||||
format!("output_fps must be between 1 and 240, got {output_fps}"),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
self.technical_state.output_fps = output_fps;
|
||||
self.refresh_technical_status();
|
||||
should_persist = true;
|
||||
let summary = format!("output fps set to {output_fps}");
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetNodeReservedIp {
|
||||
node_id,
|
||||
reserved_ip,
|
||||
} => {
|
||||
let Some(node) = self
|
||||
.snapshot
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.node_id == node_id)
|
||||
else {
|
||||
let error = HostCommandError::new(
|
||||
"unknown_node",
|
||||
format!("node '{node_id}' does not exist"),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
};
|
||||
node.reserved_ip = reserved_ip.clone();
|
||||
replace_or_append_node_state(
|
||||
&mut self.technical_state.nodes,
|
||||
PersistedNodeState {
|
||||
node_id: node_id.clone(),
|
||||
reserved_ip: reserved_ip.clone(),
|
||||
},
|
||||
);
|
||||
should_persist = true;
|
||||
let summary = format!(
|
||||
"node target updated: {} -> {}",
|
||||
node_id,
|
||||
reserved_ip.as_deref().unwrap_or("unassigned")
|
||||
);
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::UpdatePanelMapping {
|
||||
target,
|
||||
physical_output_name,
|
||||
driver_kind,
|
||||
driver_reference,
|
||||
led_count,
|
||||
direction,
|
||||
color_order,
|
||||
enabled,
|
||||
} => {
|
||||
let Some(panel) = self
|
||||
.snapshot
|
||||
.panels
|
||||
.iter_mut()
|
||||
.find(|panel| panel.target == target)
|
||||
else {
|
||||
let error = HostCommandError::new(
|
||||
"unknown_panel",
|
||||
format!(
|
||||
"panel '{}:{}' does not exist",
|
||||
target.node_id,
|
||||
panel_position_label(&target.panel_position)
|
||||
),
|
||||
);
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some(error.code.clone()),
|
||||
error.message.clone(),
|
||||
);
|
||||
return Err(error);
|
||||
};
|
||||
panel.physical_output_name = physical_output_name.clone();
|
||||
panel.driver_kind = driver_kind.clone();
|
||||
panel.driver_reference = driver_reference.clone();
|
||||
panel.led_count = led_count;
|
||||
panel.direction = direction.clone();
|
||||
panel.color_order = color_order.clone();
|
||||
panel.enabled = enabled;
|
||||
replace_or_append_panel_state(
|
||||
&mut self.technical_state.panels,
|
||||
PersistedPanelState {
|
||||
target: target.clone(),
|
||||
physical_output_name,
|
||||
driver_kind,
|
||||
driver_reference,
|
||||
led_count,
|
||||
direction,
|
||||
color_order,
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
self.refresh_technical_status();
|
||||
should_persist = true;
|
||||
let summary = format!(
|
||||
"panel mapping updated: {}:{}",
|
||||
target.node_id,
|
||||
panel_position_label(&target.panel_position)
|
||||
);
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SetBlackout(enabled) => {
|
||||
self.snapshot.global.blackout = enabled;
|
||||
should_persist = true;
|
||||
@@ -329,14 +502,9 @@ impl SimulationState {
|
||||
false,
|
||||
);
|
||||
self.next_seed += 1;
|
||||
|
||||
if let Some(speed) = self.current_scene.parameters.get("speed").cloned() {
|
||||
for (key, value) in self.current_scene.parameters.clone() {
|
||||
self.registry
|
||||
.set_scene_parameter(&mut new_scene, "speed", speed);
|
||||
}
|
||||
if let Some(intensity) = self.current_scene.parameters.get("intensity").cloned() {
|
||||
self.registry
|
||||
.set_scene_parameter(&mut new_scene, "intensity", intensity);
|
||||
.set_scene_parameter(&mut new_scene, &key, value);
|
||||
}
|
||||
|
||||
let duration_ms = self.snapshot.global.transition_duration_ms;
|
||||
@@ -479,6 +647,18 @@ impl SimulationState {
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::DeletePreset { preset_id } => {
|
||||
self.show_store.delete_preset(&preset_id)?;
|
||||
if self.current_scene.preset_id.as_deref() == Some(preset_id.as_str()) {
|
||||
self.current_scene.preset_id = None;
|
||||
}
|
||||
self.rebuild_catalog();
|
||||
self.group_members = self.show_store.group_members_map();
|
||||
should_persist = true;
|
||||
let summary = format!("preset deleted: {preset_id}");
|
||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||
summary
|
||||
}
|
||||
HostCommand::SaveCreativeSnapshot {
|
||||
snapshot_id,
|
||||
label,
|
||||
@@ -596,43 +776,64 @@ impl SimulationState {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_states(&mut self) {
|
||||
let previous_states: BTreeMap<_, _> = self
|
||||
.snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.node_id.clone(), node.connection))
|
||||
.collect();
|
||||
let mut transition_messages = Vec::new();
|
||||
|
||||
for (index, node) in self.snapshot.nodes.iter_mut().enumerate() {
|
||||
let connection = simulated_connection_state(index, self.tick_count);
|
||||
node.connection = connection;
|
||||
node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection);
|
||||
node.error_status = simulated_error_status(connection);
|
||||
if previous_states
|
||||
.get(&node.node_id)
|
||||
.copied()
|
||||
.unwrap_or(NodeConnectionState::Offline)
|
||||
!= connection
|
||||
fn apply_technical_state(&mut self, technical: &PersistedTechnicalState) {
|
||||
self.technical_state = technical.clone();
|
||||
for node_state in &technical.nodes {
|
||||
if let Some(node) = self
|
||||
.snapshot
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.node_id == node_state.node_id)
|
||||
{
|
||||
transition_messages.push(format!(
|
||||
"{} is now {}",
|
||||
node.display_name,
|
||||
connection.label()
|
||||
));
|
||||
node.reserved_ip = node_state.reserved_ip.clone();
|
||||
}
|
||||
}
|
||||
|
||||
for message in transition_messages {
|
||||
self.push_event(
|
||||
StatusEventKind::Warning,
|
||||
Some("node_connection_state".to_string()),
|
||||
message,
|
||||
);
|
||||
for panel_state in &technical.panels {
|
||||
if let Some(panel) = self
|
||||
.snapshot
|
||||
.panels
|
||||
.iter_mut()
|
||||
.find(|panel| panel.target == panel_state.target)
|
||||
{
|
||||
panel.physical_output_name = panel_state.physical_output_name.clone();
|
||||
panel.driver_kind = panel_state.driver_kind.clone();
|
||||
panel.driver_reference = panel_state.driver_reference.clone();
|
||||
panel.led_count = panel_state.led_count;
|
||||
panel.direction = panel_state.direction.clone();
|
||||
panel.color_order = panel_state.color_order.clone();
|
||||
panel.enabled = panel_state.enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_technical_status(&mut self) {
|
||||
self.snapshot.backend_label = technical_backend_label(self.technical_state.backend_mode);
|
||||
self.snapshot.technical = TechnicalSnapshot {
|
||||
backend_mode: self.technical_state.backend_mode,
|
||||
output_enabled: self.technical_state.output_enabled,
|
||||
output_fps: self.technical_state.output_fps,
|
||||
live_status: technical_live_status(
|
||||
self.technical_state.backend_mode,
|
||||
self.technical_state.output_enabled,
|
||||
&self.snapshot.nodes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn update_node_states(&mut self) {
|
||||
let elapsed_ms = self.elapsed_millis();
|
||||
let offline_status = offline_status_message(
|
||||
self.technical_state.backend_mode,
|
||||
self.technical_state.output_enabled,
|
||||
);
|
||||
for node in &mut self.snapshot.nodes {
|
||||
node.connection = NodeConnectionState::Offline;
|
||||
node.last_contact_ms = elapsed_ms;
|
||||
node.error_status = Some(offline_status.clone());
|
||||
}
|
||||
self.refresh_technical_status();
|
||||
}
|
||||
|
||||
fn update_panel_states(&mut self) {
|
||||
let node_states: BTreeMap<_, _> = self
|
||||
.snapshot
|
||||
@@ -652,7 +853,10 @@ impl SimulationState {
|
||||
panel.error_status = match (node_error, panel.enabled) {
|
||||
(_, false) => Some("output disabled".to_string()),
|
||||
(Some(error), _) => Some(error.clone()),
|
||||
(None, true) => None,
|
||||
(None, true) => Some(offline_status_message(
|
||||
self.technical_state.backend_mode,
|
||||
self.technical_state.output_enabled,
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -679,7 +883,7 @@ impl SimulationState {
|
||||
fn render_preview_for_panel(
|
||||
&self,
|
||||
panel: &PanelSnapshot,
|
||||
panel_index: usize,
|
||||
_panel_index: usize,
|
||||
elapsed_ms: u64,
|
||||
) -> (RenderedPreview, PreviewSource) {
|
||||
if self.snapshot.global.blackout || self.current_scene.blackout || !panel.enabled {
|
||||
@@ -697,16 +901,38 @@ impl SimulationState {
|
||||
}
|
||||
}
|
||||
|
||||
let panel_count = self.snapshot.panels.len();
|
||||
let current =
|
||||
self.registry
|
||||
.render_preview(&self.current_scene, panel_index, panel_count, elapsed_ms);
|
||||
let panel_row = match panel.target.panel_position {
|
||||
PanelPosition::Top => 0,
|
||||
PanelPosition::Middle => 1,
|
||||
PanelPosition::Bottom => 2,
|
||||
};
|
||||
let panel_col = self
|
||||
.snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|node| node.node_id == panel.target.node_id)
|
||||
.unwrap_or(0);
|
||||
let panel_rows = 3;
|
||||
let panel_cols = self.snapshot.nodes.len().max(1);
|
||||
let led_count = panel.led_count as usize;
|
||||
let current = self.registry.render_preview(
|
||||
&self.current_scene,
|
||||
panel_row,
|
||||
panel_col,
|
||||
panel_rows,
|
||||
panel_cols,
|
||||
led_count,
|
||||
elapsed_ms,
|
||||
);
|
||||
let mut source = PreviewSource::Scene;
|
||||
let mut preview = if let Some(transition) = &self.active_transition {
|
||||
let from = self.registry.render_preview(
|
||||
&transition.from_scene,
|
||||
panel_index,
|
||||
panel_count,
|
||||
panel_row,
|
||||
panel_col,
|
||||
panel_rows,
|
||||
panel_cols,
|
||||
led_count,
|
||||
elapsed_ms,
|
||||
);
|
||||
let progress = self
|
||||
@@ -757,9 +983,11 @@ impl SimulationState {
|
||||
};
|
||||
let storage_path = storage.path().to_path_buf();
|
||||
|
||||
let runtime_state = self
|
||||
.show_store
|
||||
.persisted_runtime(&self.current_scene, self.persisted_global_state());
|
||||
let runtime_state = self.show_store.persisted_runtime(
|
||||
&self.current_scene,
|
||||
self.persisted_global_state(),
|
||||
self.technical_state.clone(),
|
||||
);
|
||||
if let Err(error) = storage.save(&runtime_state) {
|
||||
let command_error = HostCommandError::new(
|
||||
"persist_failed",
|
||||
@@ -806,6 +1034,12 @@ fn unavailable_snapshot() -> HostSnapshot {
|
||||
schema_version: 0,
|
||||
topology_label: "unknown".to_string(),
|
||||
},
|
||||
technical: TechnicalSnapshot {
|
||||
backend_mode: OutputBackendMode::PreviewOnly,
|
||||
output_enabled: false,
|
||||
output_fps: 40,
|
||||
live_status: "service unavailable".to_string(),
|
||||
},
|
||||
global: GlobalControlSnapshot {
|
||||
blackout: true,
|
||||
master_brightness: 0.0,
|
||||
@@ -851,46 +1085,6 @@ fn unavailable_snapshot() -> HostSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState {
|
||||
match index {
|
||||
4 => {
|
||||
if tick_count % 24 < 8 {
|
||||
NodeConnectionState::Degraded
|
||||
} else {
|
||||
NodeConnectionState::Online
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
if tick_count % 32 < 7 {
|
||||
NodeConnectionState::Offline
|
||||
} else {
|
||||
NodeConnectionState::Online
|
||||
}
|
||||
}
|
||||
_ => NodeConnectionState::Online,
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated_last_contact_ms(
|
||||
index: usize,
|
||||
tick_count: u64,
|
||||
connection: NodeConnectionState,
|
||||
) -> u64 {
|
||||
match connection {
|
||||
NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6),
|
||||
NodeConnectionState::Degraded => 180 + (tick_count % 90),
|
||||
NodeConnectionState::Offline => 2_500 + (tick_count % 700),
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated_error_status(connection: NodeConnectionState) -> Option<String> {
|
||||
match connection {
|
||||
NodeConnectionState::Online => None,
|
||||
NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()),
|
||||
NodeConnectionState::Offline => Some("awaiting reconnect".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_position_label(position: &PanelPosition) -> &'static str {
|
||||
match position {
|
||||
PanelPosition::Top => "top",
|
||||
@@ -899,6 +1093,65 @@ fn panel_position_label(position: &PanelPosition) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn technical_backend_label(mode: OutputBackendMode) -> String {
|
||||
match mode {
|
||||
OutputBackendMode::PreviewOnly => "preview-only simulation".to_string(),
|
||||
OutputBackendMode::DdpWled => "ddp (wled) simulation".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn offline_status_message(mode: OutputBackendMode, output_enabled: bool) -> String {
|
||||
match mode {
|
||||
OutputBackendMode::PreviewOnly => "preview only - live output disabled".to_string(),
|
||||
OutputBackendMode::DdpWled if output_enabled => {
|
||||
"ddp (wled) output enabled - no live client connected".to_string()
|
||||
}
|
||||
OutputBackendMode::DdpWled => "ddp (wled) selected - output disabled".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn technical_live_status(
|
||||
mode: OutputBackendMode,
|
||||
output_enabled: bool,
|
||||
nodes: &[NodeSnapshot],
|
||||
) -> String {
|
||||
let online_count = nodes
|
||||
.iter()
|
||||
.filter(|node| node.connection == NodeConnectionState::Online)
|
||||
.count();
|
||||
match mode {
|
||||
OutputBackendMode::PreviewOnly => "Preview Only active - no live output".to_string(),
|
||||
OutputBackendMode::DdpWled if !output_enabled => {
|
||||
"DDP (WLED) selected - output disabled".to_string()
|
||||
}
|
||||
OutputBackendMode::DdpWled => {
|
||||
format!(
|
||||
"DDP (WLED) armed - {online_count}/{} nodes online",
|
||||
nodes.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_or_append_node_state(states: &mut Vec<PersistedNodeState>, next: PersistedNodeState) {
|
||||
if let Some(existing) = states
|
||||
.iter_mut()
|
||||
.find(|state| state.node_id == next.node_id)
|
||||
{
|
||||
*existing = next;
|
||||
} else {
|
||||
states.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_or_append_panel_state(states: &mut Vec<PersistedPanelState>, next: PersistedPanelState) {
|
||||
if let Some(existing) = states.iter_mut().find(|state| state.target == next.target) {
|
||||
*existing = next;
|
||||
} else {
|
||||
states.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_preview(mut preview: RenderedPreview, factor: f32) -> RenderedPreview {
|
||||
let factor = factor.clamp(0.0, 1.0);
|
||||
preview.representative_color_hex = scale_hex_color(&preview.representative_color_hex, factor);
|
||||
@@ -946,6 +1199,19 @@ mod tests {
|
||||
.any(|preset| preset.preset_id == "amber_chase_top"));
|
||||
assert_eq!(snapshot.preview.panels.len(), 18);
|
||||
assert_eq!(snapshot.nodes.len(), 6);
|
||||
assert_eq!(snapshot.backend_label, "preview-only simulation");
|
||||
assert!(snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.all(|node| node.connection == NodeConnectionState::Offline));
|
||||
assert!(snapshot
|
||||
.panels
|
||||
.iter()
|
||||
.all(|panel| panel.connection == NodeConnectionState::Offline));
|
||||
assert!(snapshot.recent_events.iter().any(|event| {
|
||||
event.kind == StatusEventKind::Info
|
||||
&& event.code.as_deref() == Some("preview_only_mode")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1020,4 +1286,54 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulation_ticks_do_not_emit_fake_node_connection_events() {
|
||||
let mut state = SimulationState::try_new(sample_project(), None).expect("simulation state");
|
||||
for _ in 0..8 {
|
||||
state.simulate_tick();
|
||||
}
|
||||
|
||||
assert!(state
|
||||
.snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.all(|node| node.connection == NodeConnectionState::Offline));
|
||||
assert!(state
|
||||
.snapshot
|
||||
.panels
|
||||
.iter()
|
||||
.all(|panel| panel.connection == NodeConnectionState::Offline));
|
||||
assert!(state.snapshot.recent_events.iter().all(|event| {
|
||||
event
|
||||
.code
|
||||
.as_deref()
|
||||
.map(|code| !code.starts_with("node_connection_"))
|
||||
.unwrap_or(true)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_switch_preserves_edit_parameters_like_the_legacy_tool() {
|
||||
let service = SimulationHostService::new(sample_project());
|
||||
let _ = service.send_command(HostCommand::SetSceneParameter {
|
||||
key: "fade".to_string(),
|
||||
value: SceneParameterValue::Scalar(0.62),
|
||||
});
|
||||
let _ = service.send_command(HostCommand::SetSceneParameter {
|
||||
key: "tempo_multiplier".to_string(),
|
||||
value: SceneParameterValue::Scalar(1.75),
|
||||
});
|
||||
let _ = service.send_command(HostCommand::SelectPattern("scan".to_string()));
|
||||
|
||||
let snapshot = service.snapshot();
|
||||
assert_eq!(snapshot.active_scene.pattern_id, "scan");
|
||||
assert!(snapshot.active_scene.parameters.iter().any(|parameter| {
|
||||
parameter.key == "fade" && parameter.value == SceneParameterValue::Scalar(0.62)
|
||||
}));
|
||||
assert!(snapshot.active_scene.parameters.iter().any(|parameter| {
|
||||
parameter.key == "tempo_multiplier"
|
||||
&& parameter.value == SceneParameterValue::Scalar(1.75)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "staged pattern plus transition commit",
|
||||
"steps": [
|
||||
{
|
||||
"request": {
|
||||
"request_id": "style",
|
||||
"session_id": "desk-a",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_transition_style",
|
||||
"payload": {
|
||||
"style": "chase",
|
||||
"duration_ms": 480
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "transition style staged",
|
||||
"session": {
|
||||
"parameter_keys": [],
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 480
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "pattern",
|
||||
"session_id": "desk-a",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_pattern",
|
||||
"payload": {
|
||||
"pattern_id": "noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "pattern staged: noise",
|
||||
"session": {
|
||||
"pattern_id": "noise",
|
||||
"parameter_keys": [],
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 480
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "trigger",
|
||||
"session_id": "desk-a",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "trigger_transition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "transition triggered: noise",
|
||||
"session": {
|
||||
"parameter_keys": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "state",
|
||||
"command": {
|
||||
"type": "get_state"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "state",
|
||||
"state": {
|
||||
"active_pattern_id": "noise",
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 480
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "group update plus parameter change plus commit",
|
||||
"steps": [
|
||||
{
|
||||
"request": {
|
||||
"request_id": "group",
|
||||
"session_id": "desk-b",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "upsert_group",
|
||||
"payload": {
|
||||
"group_id": "focus_pair",
|
||||
"tags": ["runtime", "focus"],
|
||||
"members": [
|
||||
{ "node_id": "node-01", "panel_position": "top" },
|
||||
{ "node_id": "node-01", "panel_position": "middle" }
|
||||
],
|
||||
"overwrite": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "group updated: focus_pair"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "param",
|
||||
"session_id": "desk-b",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_group_parameter",
|
||||
"payload": {
|
||||
"group_id": "focus_pair",
|
||||
"key": "grain",
|
||||
"value": { "kind": "scalar", "value": 0.81 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "group parameter staged: grain",
|
||||
"session": {
|
||||
"has_group_target": true,
|
||||
"group_id": "focus_pair",
|
||||
"parameter_keys": ["grain"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "style",
|
||||
"session_id": "desk-b",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_transition_style",
|
||||
"payload": {
|
||||
"style": "chase",
|
||||
"duration_ms": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "transition style staged",
|
||||
"session": {
|
||||
"has_group_target": true,
|
||||
"group_id": "focus_pair",
|
||||
"parameter_keys": ["grain"],
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 420
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "pattern",
|
||||
"session_id": "desk-b",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_pattern",
|
||||
"payload": {
|
||||
"pattern_id": "noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "pattern staged: noise",
|
||||
"session": {
|
||||
"pattern_id": "noise",
|
||||
"has_group_target": true,
|
||||
"group_id": "focus_pair",
|
||||
"parameter_keys": ["grain"],
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 420
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "trigger",
|
||||
"session_id": "desk-b",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "trigger_transition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "transition triggered: noise on focus_pair",
|
||||
"session": {
|
||||
"parameter_keys": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "state",
|
||||
"command": {
|
||||
"type": "get_state"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "state",
|
||||
"state": {
|
||||
"selected_group": "focus_pair",
|
||||
"active_pattern_id": "noise",
|
||||
"active_scene_group": "focus_pair",
|
||||
"transition_style": "chase",
|
||||
"transition_duration_ms": 420,
|
||||
"scalar_parameters": [
|
||||
{ "key": "grain", "value": 0.81 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "preset recall during running transition",
|
||||
"steps": [
|
||||
{
|
||||
"request": {
|
||||
"request_id": "style",
|
||||
"session_id": "desk-c",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_transition_style",
|
||||
"payload": {
|
||||
"style": "crossfade",
|
||||
"duration_ms": 1600
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "transition style staged"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "pattern",
|
||||
"session_id": "desk-c",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_pattern",
|
||||
"payload": {
|
||||
"pattern_id": "noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "pattern staged: noise"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "trigger",
|
||||
"session_id": "desk-c",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "trigger_transition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "transition triggered: noise"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "recall",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "recall_preset",
|
||||
"payload": {
|
||||
"preset_id": "ocean_gradient"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "preset recalled: ocean_gradient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "state",
|
||||
"command": {
|
||||
"type": "get_state"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "state",
|
||||
"state": {
|
||||
"preset_id": "ocean_gradient",
|
||||
"active_pattern_id": "gradient",
|
||||
"active_transition_present": true,
|
||||
"event_message_contains": "preset recalled: ocean_gradient"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"name": "blackout during staged session",
|
||||
"steps": [
|
||||
{
|
||||
"request": {
|
||||
"request_id": "pattern",
|
||||
"session_id": "desk-d",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_pattern",
|
||||
"payload": {
|
||||
"pattern_id": "noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "pattern staged: noise",
|
||||
"session": {
|
||||
"pattern_id": "noise",
|
||||
"parameter_keys": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "param",
|
||||
"session_id": "desk-d",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_group_parameter",
|
||||
"payload": {
|
||||
"group_id": "bottom_panels",
|
||||
"key": "grain",
|
||||
"value": { "kind": "scalar", "value": 0.74 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "primitive_buffered",
|
||||
"summary_contains": "group parameter staged: grain",
|
||||
"session": {
|
||||
"pattern_id": "noise",
|
||||
"has_group_target": true,
|
||||
"group_id": "bottom_panels",
|
||||
"parameter_keys": ["grain"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "blackout",
|
||||
"session_id": "desk-d",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "blackout",
|
||||
"payload": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "blackout enabled",
|
||||
"session": {
|
||||
"pattern_id": "noise",
|
||||
"has_group_target": true,
|
||||
"group_id": "bottom_panels",
|
||||
"parameter_keys": ["grain"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "trigger",
|
||||
"session_id": "desk-d",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "trigger_transition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "command_accepted",
|
||||
"summary_contains": "transition triggered: noise on bottom_panels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"request_id": "state",
|
||||
"command": {
|
||||
"type": "get_state"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "state",
|
||||
"state": {
|
||||
"blackout": true,
|
||||
"active_pattern_id": "noise",
|
||||
"active_scene_group": "bottom_panels",
|
||||
"scalar_parameters": [
|
||||
{ "key": "grain", "value": 0.74 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "stateless bridge path rejects staged primitive",
|
||||
"steps": [
|
||||
{
|
||||
"request": {
|
||||
"request_id": "reject",
|
||||
"command": {
|
||||
"type": "execute_primitive",
|
||||
"payload": {
|
||||
"primitive": {
|
||||
"primitive": "set_pattern",
|
||||
"payload": {
|
||||
"pattern_id": "noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"result_type": "error",
|
||||
"error_code": "show_control_session_required"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
307
crates/infinity_host/tests/show_control_v1_golden.rs
Normal file
307
crates/infinity_host/tests/show_control_v1_golden.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use infinity_config::ProjectConfig;
|
||||
use infinity_host::{
|
||||
ExternalBridgeRequest, ExternalBridgeResponse, ExternalBridgeResult, ExternalBridgeSessionView,
|
||||
ExternalBridgeStateView, ExternalControlBridge, HostApiPort, SceneParameterValue,
|
||||
SimulationHostService,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::PathBuf, sync::Arc};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GoldenTrace {
|
||||
name: String,
|
||||
steps: Vec<GoldenStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GoldenStep {
|
||||
request: ExternalBridgeRequest,
|
||||
expect: GoldenExpectation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GoldenExpectation {
|
||||
result_type: String,
|
||||
#[serde(default)]
|
||||
summary_contains: Option<String>,
|
||||
#[serde(default)]
|
||||
error_code: Option<String>,
|
||||
#[serde(default)]
|
||||
session: Option<ExpectedSession>,
|
||||
#[serde(default)]
|
||||
state: Option<ExpectedState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExpectedSession {
|
||||
#[serde(default)]
|
||||
pattern_id: Option<String>,
|
||||
#[serde(default)]
|
||||
has_group_target: Option<bool>,
|
||||
#[serde(default)]
|
||||
group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
parameter_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
transition_style: Option<String>,
|
||||
#[serde(default)]
|
||||
transition_duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExpectedState {
|
||||
#[serde(default)]
|
||||
blackout: Option<bool>,
|
||||
#[serde(default)]
|
||||
selected_group: Option<String>,
|
||||
#[serde(default)]
|
||||
active_pattern_id: Option<String>,
|
||||
#[serde(default)]
|
||||
preset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
active_scene_group: Option<String>,
|
||||
#[serde(default)]
|
||||
transition_style: Option<String>,
|
||||
#[serde(default)]
|
||||
transition_duration_ms: Option<u32>,
|
||||
#[serde(default)]
|
||||
active_transition_present: Option<bool>,
|
||||
#[serde(default)]
|
||||
event_message_contains: Option<String>,
|
||||
#[serde(default)]
|
||||
scalar_parameters: Vec<ExpectedScalarParameter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExpectedScalarParameter {
|
||||
key: String,
|
||||
value: f32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_control_v1_golden_traces_replay_cleanly() {
|
||||
for path in golden_trace_paths() {
|
||||
let fixture = fs::read_to_string(&path).expect("golden trace fixture must be readable");
|
||||
let trace: GoldenTrace =
|
||||
serde_json::from_str(&fixture).expect("golden trace fixture must parse");
|
||||
run_trace(&trace, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_trace(trace: &GoldenTrace, path: &PathBuf) {
|
||||
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
|
||||
let mut bridge = ExternalControlBridge::new(service);
|
||||
|
||||
for (index, step) in trace.steps.iter().enumerate() {
|
||||
let response = bridge.handle_request(step.request.clone());
|
||||
assert_response(trace, path, index, &response, &step.expect);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_response(
|
||||
trace: &GoldenTrace,
|
||||
path: &PathBuf,
|
||||
step_index: usize,
|
||||
response: &ExternalBridgeResponse,
|
||||
expect: &GoldenExpectation,
|
||||
) {
|
||||
let context = format!(
|
||||
"{} step {} ({})",
|
||||
path.display(),
|
||||
step_index + 1,
|
||||
trace.name
|
||||
);
|
||||
|
||||
match expect.result_type.as_str() {
|
||||
"primitive_buffered" => match &response.result {
|
||||
ExternalBridgeResult::PrimitiveBuffered { summary } => {
|
||||
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
|
||||
}
|
||||
other => panic!("{context}: expected primitive_buffered, got {other:?}"),
|
||||
},
|
||||
"command_accepted" => match &response.result {
|
||||
ExternalBridgeResult::CommandAccepted { summary, .. } => {
|
||||
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
|
||||
}
|
||||
other => panic!("{context}: expected command_accepted, got {other:?}"),
|
||||
},
|
||||
"state" => match &response.result {
|
||||
ExternalBridgeResult::State { state } => {
|
||||
assert_state(state, expect.state.as_ref(), &context);
|
||||
}
|
||||
other => panic!("{context}: expected state, got {other:?}"),
|
||||
},
|
||||
"error" => match &response.result {
|
||||
ExternalBridgeResult::Error { error } => {
|
||||
let expected_code = expect
|
||||
.error_code
|
||||
.as_deref()
|
||||
.expect("error result requires error_code");
|
||||
assert_eq!(
|
||||
error.code, expected_code,
|
||||
"{context}: unexpected error code"
|
||||
);
|
||||
}
|
||||
other => panic!("{context}: expected error, got {other:?}"),
|
||||
},
|
||||
other => panic!("{context}: unsupported expected result type '{other}'"),
|
||||
}
|
||||
|
||||
assert_session(response.session.as_ref(), expect.session.as_ref(), &context);
|
||||
}
|
||||
|
||||
fn assert_summary_contains(summary: &str, expected: Option<&str>, context: &str) {
|
||||
if let Some(expected) = expected {
|
||||
assert!(
|
||||
summary.contains(expected),
|
||||
"{context}: summary '{summary}' does not contain '{expected}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_session(
|
||||
actual: Option<&ExternalBridgeSessionView>,
|
||||
expected: Option<&ExpectedSession>,
|
||||
context: &str,
|
||||
) {
|
||||
let Some(expected) = expected else {
|
||||
return;
|
||||
};
|
||||
let actual = actual.expect("expected session view");
|
||||
assert_eq!(
|
||||
actual.pending.pattern_id.as_deref(),
|
||||
expected.pattern_id.as_deref(),
|
||||
"{context}: unexpected pending pattern"
|
||||
);
|
||||
if let Some(has_group_target) = expected.has_group_target {
|
||||
assert_eq!(
|
||||
actual.pending.has_group_target, has_group_target,
|
||||
"{context}: unexpected pending group target flag"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
actual.pending.group_id.as_deref(),
|
||||
expected.group_id.as_deref(),
|
||||
"{context}: unexpected pending group id"
|
||||
);
|
||||
if let Some(style) = expected.transition_style.as_deref() {
|
||||
assert_eq!(
|
||||
actual.pending.transition_style.map(|value| value.label()),
|
||||
Some(style),
|
||||
"{context}: unexpected pending transition style"
|
||||
);
|
||||
}
|
||||
if let Some(duration_ms) = expected.transition_duration_ms {
|
||||
assert_eq!(
|
||||
actual.pending.transition_duration_ms,
|
||||
Some(duration_ms),
|
||||
"{context}: unexpected pending transition duration"
|
||||
);
|
||||
}
|
||||
for key in &expected.parameter_keys {
|
||||
assert!(
|
||||
actual.pending.parameters.contains_key(key),
|
||||
"{context}: missing pending parameter '{key}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_state(actual: &ExternalBridgeStateView, expected: Option<&ExpectedState>, context: &str) {
|
||||
let Some(expected) = expected else {
|
||||
return;
|
||||
};
|
||||
if let Some(blackout) = expected.blackout {
|
||||
assert_eq!(
|
||||
actual.global.blackout, blackout,
|
||||
"{context}: unexpected blackout state"
|
||||
);
|
||||
}
|
||||
if let Some(selected_group) = expected.selected_group.as_deref() {
|
||||
assert_eq!(
|
||||
actual.global.selected_group.as_deref(),
|
||||
Some(selected_group),
|
||||
"{context}: unexpected selected group"
|
||||
);
|
||||
}
|
||||
if let Some(active_pattern_id) = expected.active_pattern_id.as_deref() {
|
||||
assert_eq!(
|
||||
actual.active_scene.pattern_id, active_pattern_id,
|
||||
"{context}: unexpected active pattern"
|
||||
);
|
||||
}
|
||||
if let Some(preset_id) = expected.preset_id.as_deref() {
|
||||
assert_eq!(
|
||||
actual.active_scene.preset_id.as_deref(),
|
||||
Some(preset_id),
|
||||
"{context}: unexpected preset id"
|
||||
);
|
||||
}
|
||||
if let Some(active_scene_group) = expected.active_scene_group.as_deref() {
|
||||
assert_eq!(
|
||||
actual.active_scene.target_group.as_deref(),
|
||||
Some(active_scene_group),
|
||||
"{context}: unexpected active scene group"
|
||||
);
|
||||
}
|
||||
if let Some(transition_style) = expected.transition_style.as_deref() {
|
||||
assert_eq!(
|
||||
actual.global.transition_style.label(),
|
||||
transition_style,
|
||||
"{context}: unexpected transition style"
|
||||
);
|
||||
}
|
||||
if let Some(transition_duration_ms) = expected.transition_duration_ms {
|
||||
assert_eq!(
|
||||
actual.global.transition_duration_ms, transition_duration_ms,
|
||||
"{context}: unexpected transition duration"
|
||||
);
|
||||
}
|
||||
if let Some(active_transition_present) = expected.active_transition_present {
|
||||
assert_eq!(
|
||||
actual.engine.active_transition.is_some(),
|
||||
active_transition_present,
|
||||
"{context}: unexpected active transition presence"
|
||||
);
|
||||
}
|
||||
if let Some(message_fragment) = expected.event_message_contains.as_deref() {
|
||||
assert!(
|
||||
actual
|
||||
.recent_events
|
||||
.iter()
|
||||
.any(|event| event.message.contains(message_fragment)),
|
||||
"{context}: missing event containing '{message_fragment}'"
|
||||
);
|
||||
}
|
||||
for expected_parameter in &expected.scalar_parameters {
|
||||
assert!(
|
||||
actual.active_scene.parameters.iter().any(|parameter| {
|
||||
parameter.key == expected_parameter.key
|
||||
&& parameter.value == SceneParameterValue::Scalar(expected_parameter.value)
|
||||
}),
|
||||
"{context}: missing scalar parameter '{}'={}",
|
||||
expected_parameter.key,
|
||||
expected_parameter.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_trace_paths() -> Vec<PathBuf> {
|
||||
let mut paths = fs::read_dir(golden_trace_dir())
|
||||
.expect("golden trace directory must exist")
|
||||
.map(|entry| entry.expect("golden trace entry").path())
|
||||
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
fn golden_trace_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden_traces")
|
||||
}
|
||||
|
||||
fn sample_project() -> ProjectConfig {
|
||||
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||
.expect("project config must parse")
|
||||
}
|
||||
@@ -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<ApiDiscoveryResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiDiscoveryResult {
|
||||
pub ip: String,
|
||||
pub reachable: bool,
|
||||
pub detected_type: ApiDiscoveredNodeType,
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
},
|
||||
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<String>,
|
||||
@@ -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<HostCommand, String> {
|
||||
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,
|
||||
|
||||
@@ -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<dyn HostApiPort>) -> 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<dyn HostApiPort>) -> 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::<ApiDiscoveryScanRequest>(&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::<Ipv4Addr>()
|
||||
.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<Vec<Ipv4Addr>, 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::<Ipv4Addr>()
|
||||
.map_err(|_| format!("subnet '{subnet}' contains an invalid IPv4 address"))?;
|
||||
let prefix = prefix
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.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<ApiDiscoveryResult> {
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let worker_count = usize::min(32, targets.len().max(1));
|
||||
let (job_sender, job_receiver) = mpsc::channel::<Ipv4Addr>();
|
||||
let job_receiver = Arc::new(Mutex::new(job_receiver));
|
||||
let (result_sender, result_receiver) = mpsc::channel::<ApiDiscoveryResult>();
|
||||
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<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn probe_http_endpoint(ip: Ipv4Addr, port: u16, path: &str) -> Option<HttpProbe> {
|
||||
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::<HashMap<_, _>>();
|
||||
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<String> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&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,
|
||||
|
||||
@@ -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("<h2>Preview</h2>"));
|
||||
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("<h2>Groups</h2>"));
|
||||
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("<h2>Preview</h2>"));
|
||||
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();
|
||||
|
||||
87
data/runtime_state.json
Normal file
87
data/runtime_state.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
92
docs/control_ownership.md
Normal file
92
docs/control_ownership.md
Normal file
@@ -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
|
||||
126
docs/external_control_bridge.md
Normal file
126
docs/external_control_bridge.md
Normal file
@@ -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`
|
||||
61
docs/pattern_matrix_v1.md
Normal file
61
docs/pattern_matrix_v1.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
49
scripts/launch_software_only_web.sh
Executable file
49
scripts/launch_software_only_web.sh
Executable file
@@ -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"
|
||||
2347
web/v1/app.js
2347
web/v1/app.js
File diff suppressed because it is too large
Load Diff
@@ -3,219 +3,276 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Infinity Vis Creative Console</title>
|
||||
<title>Infinity Vis Control</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Infinity Vis / Creative Surface</p>
|
||||
<h1 id="project-name">Loading project...</h1>
|
||||
<p id="topology-label" class="hero-subtitle">
|
||||
Shared host API bootstrap in progress.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<div class="status-card">
|
||||
<span class="status-label">API stream</span>
|
||||
<span id="connection-pill" class="pill pill-offline">connecting</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">Preview refresh</span>
|
||||
<span id="preview-updated">waiting for data</span>
|
||||
</div>
|
||||
<button id="refresh-button" class="ghost-button" type="button">
|
||||
Refresh snapshot
|
||||
<div class="app-shell">
|
||||
<header class="topbar topbar-creative">
|
||||
<div class="topbar-actions">
|
||||
<a href="/technical" class="toolbar-button toolbar-link">Mapping Settings</a>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Tempo</span>
|
||||
<input id="tempo-bpm-input" type="number" min="10" max="300" step="1" />
|
||||
<strong id="tempo-bpm-label">120 BPM</strong>
|
||||
</label>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Mode</span>
|
||||
<select id="work-mode-select">
|
||||
<option value="test_edit">Test/Edit</option>
|
||||
<option value="show_event">Show/Event</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button id="go-button" class="toolbar-button" type="button">Go</button>
|
||||
<button id="fade-go-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Fade Go
|
||||
</button>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline toolbar-control-fade">
|
||||
<span class="toolbar-label">Fade</span>
|
||||
<input id="transition-seconds-input" type="number" min="0.1" max="30" step="0.1" />
|
||||
<strong id="transition-seconds-label">2.0 s</strong>
|
||||
</label>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Style</span>
|
||||
<select id="transition-style-select">
|
||||
<option value="snap">Snap</option>
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="chase">Chase</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span id="edit-context-label" class="status-chip status-chip-edit">Edit: Live</span>
|
||||
|
||||
<button id="blackout-button" class="toolbar-button toolbar-button-alert" type="button">
|
||||
Blackout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel controls-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Global Look</h2>
|
||||
<p>Pattern, preset, group and transition control against the shared host API.</p>
|
||||
</div>
|
||||
|
||||
<div class="control-grid">
|
||||
<label class="field">
|
||||
<main class="workspace">
|
||||
<aside class="workspace-rail workspace-rail-left">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Pattern</h2>
|
||||
<p>Old-tool style mode access on top of the stable show-control primitives.</p>
|
||||
</div>
|
||||
<label class="control-field">
|
||||
<span>Pattern</span>
|
||||
<select id="pattern-select"></select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition Duration</span>
|
||||
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
|
||||
<strong id="transition-value">0 ms</strong>
|
||||
</label>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Look & Motion</h2>
|
||||
<p>Pattern behavior and movement parameters.</p>
|
||||
</div>
|
||||
<div id="motion-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition Style</span>
|
||||
<select id="transition-style-select">
|
||||
<option value="snap">Snap</option>
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="chase">Chase</option>
|
||||
</select>
|
||||
</label>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Colors</h2>
|
||||
<p>Palette and color-facing controls.</p>
|
||||
</div>
|
||||
<div id="color-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Brightness</h2>
|
||||
<p>Global intensity plus pattern-level brightness controls.</p>
|
||||
</div>
|
||||
<label class="control-field">
|
||||
<span>Master Brightness</span>
|
||||
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
|
||||
<strong id="brightness-value">0%</strong>
|
||||
</label>
|
||||
<div id="brightness-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="field">
|
||||
<span>Blackout</span>
|
||||
<button id="blackout-button" class="danger-button" type="button">
|
||||
Enable blackout
|
||||
<section class="workspace-stage">
|
||||
<section class="stage-panel stage-panel-preview">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Preview</h2>
|
||||
<p id="project-name">Loading project...</p>
|
||||
<p id="topology-label">Shared host API bootstrap in progress.</p>
|
||||
</div>
|
||||
<div class="stage-meta">
|
||||
<span class="stage-meta-label">Refresh</span>
|
||||
<strong id="preview-updated">waiting for data</strong>
|
||||
<button id="refresh-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-stage">
|
||||
<div id="preview-grid" class="preview-grid preview-grid-mode-leds"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stage-panel stage-panel-events">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Status & Eventfeed</h2>
|
||||
<p>Live status messages stay hot without rebuilding the whole workbench.</p>
|
||||
</div>
|
||||
<div class="status-chip-row">
|
||||
<span id="connection-pill" class="status-chip status-chip-warning">connecting</span>
|
||||
<span id="control-mode-pill" class="status-chip status-chip-live">Test/Edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-filter-bar">
|
||||
<select id="event-kind-filter">
|
||||
<option value="all">All kinds</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
id="event-search-filter"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by code or message"
|
||||
/>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="workspace-rail workspace-rail-right">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Pending Transition</h2>
|
||||
<p id="pending-panel-description">Stage direct edits locally and commit them consciously.</p>
|
||||
</div>
|
||||
<div class="pending-status-row">
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Scope</span>
|
||||
<strong id="session-scope-label">local browser session</strong>
|
||||
</div>
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Buffer</span>
|
||||
<strong id="pending-compact-label">empty</strong>
|
||||
</div>
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Commit</span>
|
||||
<span id="pending-commit-pill" class="status-chip status-chip-idle">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||
<div class="button-stack">
|
||||
<button id="trigger-transition-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Trigger Transition
|
||||
</button>
|
||||
<button id="clear-staged-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Clear Staged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Pending Transition</h3>
|
||||
<p>Stage primitives locally and commit them with one explicit trigger.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Presets</h2>
|
||||
<p>Quick recall for curated looks plus compact save controls.</p>
|
||||
</div>
|
||||
<div class="session-panel">
|
||||
<div class="session-status-row">
|
||||
<div class="status-card">
|
||||
<span class="status-label">Control mode</span>
|
||||
<span id="control-mode-pill" class="pill pill-online">stateful</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">Commit state</span>
|
||||
<span id="pending-commit-pill" class="pill pill-offline">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||
<div class="session-actions">
|
||||
<button id="trigger-transition-button" class="ghost-button" type="button">
|
||||
Trigger Transition
|
||||
</button>
|
||||
<button id="clear-staged-button" class="ghost-button" type="button">
|
||||
Clear Staged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Presets</h3>
|
||||
<p>Recall look snapshots without leaving the creative console.</p>
|
||||
</div>
|
||||
<div id="preset-list" class="pill-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Preset Capture</h3>
|
||||
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Preset ID</span>
|
||||
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<div id="preset-list" class="list-stack"></div>
|
||||
<div class="compact-form compact-form-presets">
|
||||
<input id="preset-id-input" type="text" placeholder="preset id" />
|
||||
<label class="inline-toggle">
|
||||
<input id="preset-overwrite-input" type="checkbox" />
|
||||
<span>Overwrite</span>
|
||||
</label>
|
||||
<button id="save-preset-button" class="ghost-button" type="button">
|
||||
Save Current Scene As Preset
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="save-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Save Current
|
||||
</button>
|
||||
<button id="load-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Load
|
||||
</button>
|
||||
<button id="delete-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Groups</h3>
|
||||
<p>Focus looks on a subset while keeping the core scene model shared.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Selected Tile</h2>
|
||||
<p>Stable tile focus for operator actions and diagnostics.</p>
|
||||
</div>
|
||||
<input
|
||||
id="group-filter-input"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter groups by id or tag"
|
||||
/>
|
||||
<div id="group-list" class="pill-row"></div>
|
||||
</div>
|
||||
<div id="selected-tile-card" class="selected-tile-card empty-state">
|
||||
Click a tile in the preview.
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="white-test-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
White Test
|
||||
</button>
|
||||
<button id="live-pattern-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Live Pattern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Creative Snapshots</h3>
|
||||
<p>Capture exploratory variants without replacing curated presets.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Utilities</h2>
|
||||
<p>Fast operator actions aligned with the old desk workflow.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Snapshot ID</span>
|
||||
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<div class="button-stack">
|
||||
<button id="utility-blackout-button" class="toolbar-button toolbar-button-alert" type="button">
|
||||
Blackout
|
||||
</button>
|
||||
<button id="utility-go-button" class="toolbar-button" type="button">Go</button>
|
||||
<button id="utility-fade-go-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Fade Go
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Creative Snapshots</h2>
|
||||
<p>Capture and recall exploratory variants without changing architecture.</p>
|
||||
</div>
|
||||
<div class="compact-form compact-form-two">
|
||||
<input id="snapshot-id-input" type="text" placeholder="snapshot id" />
|
||||
<input id="snapshot-label-input" type="text" placeholder="label" />
|
||||
<label class="inline-toggle">
|
||||
<input id="snapshot-overwrite-input" type="checkbox" />
|
||||
<span>Overwrite</span>
|
||||
</label>
|
||||
<button id="save-snapshot-button" class="ghost-button" type="button">
|
||||
Save Creative Snapshot
|
||||
<button id="save-snapshot-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Save Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div id="snapshot-list" class="snapshot-list"></div>
|
||||
</div>
|
||||
<div id="snapshot-list" class="list-stack"></div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Scene Parameters</h3>
|
||||
<p>Rendered from the active scene schema, not hardcoded per frontend.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>View & Output</h2>
|
||||
<p>Read-only state and output context for live operation.</p>
|
||||
</div>
|
||||
<div id="scene-params" class="parameter-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel preview-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Preview</h2>
|
||||
<p>Live panel previews from the host snapshot and stream feed.</p>
|
||||
</div>
|
||||
<div id="preview-grid" class="preview-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel summary-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Snapshot</h2>
|
||||
<p>Operator-friendly scene state with a raw API view underneath.</p>
|
||||
</div>
|
||||
<div id="summary-cards" class="summary-cards"></div>
|
||||
<pre id="snapshot-json" class="snapshot-json"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel event-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Event Stream</h2>
|
||||
<p>Recent notices from the websocket feed.</p>
|
||||
</div>
|
||||
<div class="event-filter-bar">
|
||||
<select id="event-kind-filter">
|
||||
<option value="all">All kinds</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
id="event-search-filter"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by code or message"
|
||||
/>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</section>
|
||||
<span id="preview-mode-label" class="hidden">LEDs Only</span>
|
||||
<div id="view-output-list" class="info-list"></div>
|
||||
<details class="raw-snapshot">
|
||||
<summary>Raw Snapshot</summary>
|
||||
<pre id="snapshot-json" class="snapshot-json"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
1489
web/v1/styles.css
1489
web/v1/styles.css
File diff suppressed because it is too large
Load Diff
206
web/v1/technical.html
Normal file
206
web/v1/technical.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Infinity Vis Mapping Settings</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="technical-shell">
|
||||
<header class="topbar topbar-technical">
|
||||
<div class="topbar-brand">
|
||||
<div class="brand-title">Infinity Vis Mapping Settings</div>
|
||||
<div id="technical-project-name" class="brand-project">Loading technical surface...</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-strip">
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Backend</span>
|
||||
<span id="technical-backend-pill" class="status-chip status-chip-warning">loading</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Output</span>
|
||||
<span id="technical-output-pill" class="status-chip status-chip-warning">loading</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Nodes</span>
|
||||
<span id="technical-nodes-pill" class="status-chip status-chip-warning">0/0 online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<a href="/" class="toolbar-button toolbar-link">Creative Surface</a>
|
||||
<button id="technical-refresh-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="technical-workspace">
|
||||
<section class="technical-stack">
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Backend & Output</h2>
|
||||
<p>Preview Only and DDP (WLED) stay explicit and honest.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="technical-summary-grid" class="technical-summary-grid"></div>
|
||||
|
||||
<div class="technical-form-grid">
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Backend Mode</span>
|
||||
<select id="backend-mode-select">
|
||||
<option value="preview_only">Preview Only</option>
|
||||
<option value="ddp_wled">DDP (WLED)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Output FPS</span>
|
||||
<input id="output-fps-input" type="number" min="1" max="240" step="1" />
|
||||
</label>
|
||||
|
||||
<label class="technical-checkbox technical-field-wide">
|
||||
<input id="output-enabled-input" type="checkbox" />
|
||||
<span>Output Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="technical-field technical-field-wide">
|
||||
<span class="toolbar-label">Live Status</span>
|
||||
<div id="technical-live-status" class="status-banner">Waiting for state...</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-field technical-field-wide">
|
||||
<span class="toolbar-label">Semantics</span>
|
||||
<div id="technical-backend-semantics" class="technical-note">
|
||||
Preview Only means no live output. DDP (WLED) is armed only when explicitly enabled.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button id="save-output-settings-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Apply Output Settings
|
||||
</button>
|
||||
</div>
|
||||
<div id="technical-feedback-banner" class="status-banner hidden"></div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Node / IP Discovery</h2>
|
||||
<p>Scan subnet ranges and assign discovered IPs explicitly to node slots.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-form-grid">
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Subnet</span>
|
||||
<input id="discovery-subnet-input" type="text" value="192.168.40.0/24" />
|
||||
</label>
|
||||
<div class="technical-field">
|
||||
<span class="toolbar-label">Discovery</span>
|
||||
<button id="discovery-scan-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Discover / Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discovery-summary" class="technical-note">
|
||||
No scan executed yet.
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap">
|
||||
<table class="technical-table technical-table-dense">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Reachable</th>
|
||||
<th>Type</th>
|
||||
<th>Hostname / mDNS</th>
|
||||
<th>Assign to Node Slot</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="discovery-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Node Targets</h2>
|
||||
<p>Reserved IPs and honest connection status per node.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap">
|
||||
<table class="technical-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Display</th>
|
||||
<th>Reserved IP</th>
|
||||
<th>Status</th>
|
||||
<th>Live Note</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="node-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="technical-stack technical-stack-wide">
|
||||
<section class="stage-panel technical-section">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Panel Mapping</h2>
|
||||
<p>Real output slots with backend-facing routing details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap technical-table-wrap-grow">
|
||||
<table class="technical-table technical-table-dense">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Panel</th>
|
||||
<th>Output</th>
|
||||
<th>Driver Kind</th>
|
||||
<th>GPIO / Channel</th>
|
||||
<th>LEDs</th>
|
||||
<th>Direction</th>
|
||||
<th>Color</th>
|
||||
<th>Enabled</th>
|
||||
<th>Validation</th>
|
||||
<th>Status</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="panel-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stage-panel technical-section technical-events-panel">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Recent Events</h2>
|
||||
<p>Live backend and mapping changes without fake node traffic.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="technical-event-list" class="event-list technical-event-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/technical.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
951
web/v1/technical.js
Normal file
951
web/v1/technical.js
Normal file
@@ -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 `
|
||||
<tr data-node-id="${escapeHtml(node.node_id)}">
|
||||
<td>${escapeHtml(node.node_id)}</td>
|
||||
<td>${escapeHtml(node.display_name)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
data-node-reserved-ip
|
||||
value="${escapeHtml(draft.reserved_ip)}"
|
||||
placeholder="unassigned"
|
||||
/>
|
||||
</td>
|
||||
<td>${connectionBadge(node.connection)}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
<span>${escapeHtml(node.error_status ?? "no active error")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-save-node="${escapeHtml(node.node_id)}"
|
||||
${draft.dirty ? "" : "disabled"}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.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 `
|
||||
<tr data-panel-key="${escapeHtml(key)}">
|
||||
<td>${escapeHtml(panel.node_id)}</td>
|
||||
<td>${escapeHtml(panel.panel_position)}</td>
|
||||
<td>
|
||||
<input type="text" data-field="physical_output_name" value="${escapeHtml(draft.physical_output_name)}" />
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="driver_kind">
|
||||
${renderOptions(DRIVER_KIND_OPTIONS, draft.driver_kind)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" data-field="driver_reference" value="${escapeHtml(draft.driver_reference)}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" max="2048" step="1" data-field="led_count" value="${escapeHtml(String(draft.led_count))}" />
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="direction">
|
||||
${renderOptions(DIRECTION_OPTIONS, draft.direction)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="color_order">
|
||||
${renderOptions(COLOR_ORDER_OPTIONS, draft.color_order)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<label class="table-checkbox">
|
||||
<input type="checkbox" data-field="enabled" ${draft.enabled ? "checked" : ""} />
|
||||
<span>${draft.enabled ? "on" : "off"}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>${escapeHtml(panel.validation_state)}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
${connectionBadge(panel.connection)}
|
||||
<span>${escapeHtml(panel.error_status ?? "no active error")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-save-panel="${escapeHtml(key)}"
|
||||
${draft.dirty ? "" : "disabled"}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.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 =
|
||||
'<tr><td colspan="6" class="empty-state">Run a subnet scan to list discoverable IP targets.</td></tr>';
|
||||
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 `
|
||||
<tr data-discovery-ip="${escapeHtml(result.ip)}">
|
||||
<td>${escapeHtml(result.ip)}</td>
|
||||
<td>${result.reachable ? connectionBadge("online") : connectionBadge("offline")}</td>
|
||||
<td>${escapeHtml(discoveredTypeLabel(result.detected_type))}</td>
|
||||
<td>${escapeHtml(result.hostname || "n/a")}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
<select data-discovery-assignment>
|
||||
<option value="">manual assignment</option>
|
||||
${renderNodeOptions(selectedNode)}
|
||||
</select>
|
||||
<span class="table-muted">
|
||||
${assignedNode ? `currently ${escapeHtml(assignedNode)}` : "currently unassigned"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-apply-discovery="${escapeHtml(result.ip)}"
|
||||
${canApply ? "" : "disabled"}
|
||||
>
|
||||
${changed ? "Assign" : "Apply"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
if (!appState.snapshot) {
|
||||
elements.eventList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.eventList.innerHTML = appState.snapshot.recent_events
|
||||
.map(
|
||||
(event) => `
|
||||
<article class="event-entry">
|
||||
<div class="event-header">
|
||||
<span class="status-chip ${eventKindChipClass(event.kind)}">${escapeHtml(event.kind)}</span>
|
||||
<span class="event-meta">${escapeHtml(event.code ?? "event")} | ${escapeHtml(String(event.at_millis))} ms</span>
|
||||
</div>
|
||||
<div>${escapeHtml(event.message)}</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.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 `<option value="${escapeHtml(node.node_id)}" ${selected}>${escapeHtml(node.node_id)}</option>`;
|
||||
})
|
||||
.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 `<span class="status-chip ${chipClassForTone(tone)}">${escapeHtml(connection)}</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="summary-card summary-card-${escapeHtml(tone)}">
|
||||
<span class="toolbar-label">${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOptions(options, selectedValue) {
|
||||
return options
|
||||
.map(
|
||||
(option) => `
|
||||
<option value="${escapeHtml(option.value)}" ${option.value === selectedValue ? "selected" : ""}>
|
||||
${escapeHtml(option.label)}
|
||||
</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();
|
||||
Reference in New Issue
Block a user