Compare commits

..

5 Commits

43 changed files with 11335 additions and 1542 deletions
-10
View File
@@ -1,11 +1 @@
/target/
/build/
/.idea/
/.vscode/
*.swp
*.tmp
*.log
sdkconfig
sdkconfig.old
firmware/esp32_node/build/
+6
View File
@@ -43,6 +43,12 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
- [Architecture](docs/architecture.md)
- [Host API](docs/host_api.md)
- [Local Software-Only Runbook](docs/local_software_only_runbook.md)
- [Qwen 14B Handoff](docs/qwen14b_handoff.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
View File
@@ -24,4 +24,3 @@ pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, P
let raw = fs::read_to_string(path)?;
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
}
+18 -5
View File
@@ -369,7 +369,11 @@ impl ProjectConfig {
}
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
let preset_ids: BTreeSet<_> = self.presets.iter().map(|preset| preset.preset_id.as_str()).collect();
let preset_ids: BTreeSet<_> = self
.presets
.iter()
.map(|preset| preset.preset_id.as_str())
.collect();
for (index, profile) in self.safety_profiles.iter().enumerate() {
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
report.push(
@@ -380,7 +384,8 @@ impl ProjectConfig {
);
}
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness) {
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness)
{
report.push(
ValidationSeverity::Error,
"invalid_start_brightness",
@@ -511,7 +516,11 @@ mod tests {
SceneConfig, TopologyConfig, TransportMode, TransportProfileConfig,
};
fn build_output(position: PanelPosition, label: &str, driver_kind: DriverKind) -> PanelOutputConfig {
fn build_output(
position: PanelPosition,
label: &str,
driver_kind: DriverKind,
) -> PanelOutputConfig {
PanelOutputConfig {
panel_position: position,
physical_output_name: label.to_string(),
@@ -630,8 +639,12 @@ mod tests {
#[test]
fn rejects_duplicate_driver_refs() {
let mut project = build_project();
project.topology.nodes[0].outputs[1].driver_channel.reference =
project.topology.nodes[0].outputs[0].driver_channel.reference.clone();
project.topology.nodes[0].outputs[1]
.driver_channel
.reference = project.topology.nodes[0].outputs[0]
.driver_channel
.reference
.clone();
let report = project.validate(ValidationMode::Structural);
assert!(!report.is_ok());
assert!(report
+61 -1
View File
@@ -1,4 +1,4 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
use serde::{Deserialize, Serialize};
pub const HOST_API_VERSION: u16 = 1;
@@ -9,6 +9,7 @@ pub struct HostSnapshot {
pub backend_label: String,
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub technical: TechnicalSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub catalog: CatalogSnapshot,
@@ -27,6 +28,21 @@ pub struct SystemSnapshot {
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TechnicalSnapshot {
pub backend_mode: OutputBackendMode,
pub output_enabled: bool,
pub output_fps: u16,
pub live_status: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputBackendMode {
PreviewOnly,
DdpWled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobalControlSnapshot {
pub blackout: bool,
@@ -204,6 +220,7 @@ pub struct NodeSnapshot {
pub struct PanelSnapshot {
pub target: PanelTarget,
pub physical_output_name: String,
pub driver_kind: DriverKind,
pub driver_reference: String,
pub led_count: u16,
pub direction: LedDirection,
@@ -248,6 +265,23 @@ pub struct PanelTarget {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
pub enum HostCommand {
SetOutputBackendMode(OutputBackendMode),
SetLiveOutputEnabled(bool),
SetOutputFps(u16),
SetNodeReservedIp {
node_id: String,
reserved_ip: Option<String>,
},
UpdatePanelMapping {
target: PanelTarget,
physical_output_name: String,
driver_kind: DriverKind,
driver_reference: String,
led_count: u16,
direction: LedDirection,
color_order: ColorOrder,
enabled: bool,
},
SetBlackout(bool),
SetMasterBrightness(f32),
SelectPattern(String),
@@ -271,6 +305,9 @@ pub enum HostCommand {
preset_id: String,
overwrite: bool,
},
DeletePreset {
preset_id: String,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
@@ -334,6 +371,22 @@ impl SceneTransitionStyle {
}
}
impl OutputBackendMode {
pub fn label(self) -> &'static str {
match self {
Self::PreviewOnly => "preview_only",
Self::DdpWled => "ddp_wled",
}
}
pub fn display_label(self) -> &'static str {
match self {
Self::PreviewOnly => "Preview Only",
Self::DdpWled => "DDP (WLED)",
}
}
}
impl CatalogSource {
pub fn label(self) -> &'static str {
match self {
@@ -369,6 +422,13 @@ impl SceneParameterValue {
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(value) => Some(value.as_str()),
_ => None,
}
}
pub fn as_toggle(&self) -> Option<bool> {
match self {
Self::Toggle(value) => Some(*value),
+483
View File
@@ -0,0 +1,483 @@
use crate::{
ActiveSceneSnapshot, BufferedShowControlAdapter, EngineSnapshot, ExternalShowControlAdapter,
ExternalShowControlPort, GlobalControlSnapshot, HostApiPort, HostCommandError, HostSnapshot,
NodeSnapshot, ShowControlPendingState, ShowControlPrimitive, ShowControlPrimitiveOutcome,
StatusEvent, SystemSnapshot, SHOW_CONTROL_V1_VERSION,
};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
io::{self, BufRead, Write},
sync::Arc,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeRequest {
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
pub command: ExternalBridgeCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ExternalBridgeCommand {
ExecutePrimitive { primitive: ShowControlPrimitive },
GetState,
ClearSession,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeResponse {
pub semantic_version: String,
pub request_id: Option<String>,
pub session_id: Option<String>,
pub result: ExternalBridgeResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<ExternalBridgeSessionView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ExternalBridgeResult {
PrimitiveBuffered {
summary: String,
},
CommandAccepted {
generated_at_millis: u64,
summary: String,
},
Snapshot {
snapshot: HostSnapshot,
},
State {
state: ExternalBridgeStateView,
},
SessionCleared {
had_session: bool,
},
Error {
error: ExternalBridgeError,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalBridgeError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeSessionView {
pub session_id: String,
pub pending: ShowControlPendingState,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalBridgeStateView {
pub generated_at_millis: u64,
pub system: SystemSnapshot,
pub global: GlobalControlSnapshot,
pub engine: EngineSnapshot,
pub active_scene: ActiveSceneSnapshot,
pub nodes: Vec<NodeSnapshot>,
pub panels: Vec<crate::PanelSnapshot>,
pub recent_events: Vec<StatusEvent>,
}
pub struct ExternalControlBridge {
service: Arc<dyn HostApiPort>,
sessions: BTreeMap<String, BufferedShowControlAdapter>,
}
impl ExternalControlBridge {
pub fn new(service: Arc<dyn HostApiPort>) -> Self {
Self {
service,
sessions: BTreeMap::new(),
}
}
pub fn handle_request(&mut self, request: ExternalBridgeRequest) -> ExternalBridgeResponse {
match self.try_handle_request(request.clone()) {
Ok(response) => response,
Err(error) => {
let session_view = self.session_view(request.session_id.as_deref());
ExternalBridgeResponse::error(
request.request_id,
request.session_id,
error.into(),
session_view,
)
}
}
}
pub fn run_jsonl<R: BufRead, W: Write>(
&mut self,
reader: &mut R,
writer: &mut W,
) -> io::Result<()> {
let mut line = String::new();
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
return Ok(());
}
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<ExternalBridgeRequest>(&line) {
Ok(request) => self.handle_request(request),
Err(error) => ExternalBridgeResponse::error(
None,
None,
ExternalBridgeError::new(
"invalid_bridge_request_json",
format!("bridge request JSON could not be parsed: {error}"),
),
None,
),
};
let payload = serde_json::to_string(&response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
writer.write_all(payload.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
}
}
fn try_handle_request(
&mut self,
request: ExternalBridgeRequest,
) -> Result<ExternalBridgeResponse, HostCommandError> {
let request_id = request.request_id.clone();
let session_id = request.session_id.clone();
match request.command {
ExternalBridgeCommand::ExecutePrimitive { primitive } => {
let outcome = if let Some(session_id) = session_id.as_deref() {
self.sessions
.entry(session_id.to_string())
.or_default()
.apply_primitive(self.service.as_ref(), primitive)?
} else {
self.service.execute_primitive(primitive)?
};
let result = match outcome {
ShowControlPrimitiveOutcome::Buffered { summary } => {
ExternalBridgeResult::PrimitiveBuffered { summary }
}
ShowControlPrimitiveOutcome::Command(outcome) => {
ExternalBridgeResult::CommandAccepted {
generated_at_millis: outcome.generated_at_millis,
summary: outcome.summary,
}
}
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
ExternalBridgeResult::Snapshot { snapshot }
}
};
Ok(ExternalBridgeResponse::success(
request_id,
session_id.clone(),
result,
self.session_view(session_id.as_deref()),
))
}
ExternalBridgeCommand::GetState => {
let snapshot = self.service.snapshot();
Ok(ExternalBridgeResponse::success(
request_id,
session_id.clone(),
ExternalBridgeResult::State {
state: ExternalBridgeStateView::from_snapshot(&snapshot),
},
self.session_view(session_id.as_deref()),
))
}
ExternalBridgeCommand::ClearSession => {
let Some(session_id) = session_id else {
return Err(HostCommandError::new(
"session_id_required",
"clear_session requires a session_id",
));
};
let had_session = self.sessions.remove(&session_id).is_some();
Ok(ExternalBridgeResponse::success(
request_id,
Some(session_id),
ExternalBridgeResult::SessionCleared { had_session },
None,
))
}
}
}
fn session_view(&self, session_id: Option<&str>) -> Option<ExternalBridgeSessionView> {
let session_id = session_id?;
let adapter = self.sessions.get(session_id)?;
Some(ExternalBridgeSessionView {
session_id: session_id.to_string(),
pending: adapter.session().pending_state(),
})
}
}
impl ExternalBridgeResponse {
fn success(
request_id: Option<String>,
session_id: Option<String>,
result: ExternalBridgeResult,
session: Option<ExternalBridgeSessionView>,
) -> Self {
Self {
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
request_id,
session_id,
result,
session,
}
}
fn error(
request_id: Option<String>,
session_id: Option<String>,
error: ExternalBridgeError,
session: Option<ExternalBridgeSessionView>,
) -> Self {
Self {
semantic_version: SHOW_CONTROL_V1_VERSION.to_string(),
request_id,
session_id,
result: ExternalBridgeResult::Error { error },
session,
}
}
}
impl ExternalBridgeError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl From<HostCommandError> for ExternalBridgeError {
fn from(value: HostCommandError) -> Self {
Self {
code: value.code,
message: value.message,
}
}
}
impl ExternalBridgeStateView {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
generated_at_millis: snapshot.generated_at_millis,
system: snapshot.system.clone(),
global: snapshot.global.clone(),
engine: snapshot.engine.clone(),
active_scene: snapshot.active_scene.clone(),
nodes: snapshot.nodes.clone(),
panels: snapshot.panels.clone(),
recent_events: snapshot.recent_events.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{HostApiPort, SimulationHostService};
use infinity_config::{PanelPosition, ProjectConfig};
use std::sync::Arc;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
fn bridge() -> ExternalControlBridge {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
ExternalControlBridge::new(service)
}
#[test]
fn bridge_preserves_session_pending_state_for_staged_flows() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("r1".to_string()),
session_id: Some("desk-a".to_string()),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
},
},
});
assert_eq!(response.semantic_version, SHOW_CONTROL_V1_VERSION);
match response.result {
ExternalBridgeResult::PrimitiveBuffered { summary } => {
assert!(summary.contains("pattern staged: noise"));
}
other => panic!("expected buffered result, got {other:?}"),
}
assert_eq!(
response
.session
.expect("session view")
.pending
.pattern_id
.as_deref(),
Some("noise")
);
}
#[test]
fn bridge_passes_through_session_required_error_unchanged() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("r1".to_string()),
session_id: None,
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::TriggerTransition,
},
});
match response.result {
ExternalBridgeResult::Error { error } => {
assert_eq!(error.code, "show_control_session_required");
}
other => panic!("expected error result, got {other:?}"),
}
}
#[test]
fn bridge_get_state_returns_projection_without_preview() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("state-1".to_string()),
session_id: None,
command: ExternalBridgeCommand::GetState,
});
match response.result {
ExternalBridgeResult::State { state } => {
assert_eq!(state.system.project_name, "Infinity Vis");
assert_eq!(state.nodes.len(), 6);
assert_eq!(state.panels.len(), 18);
}
other => panic!("expected state result, got {other:?}"),
}
}
#[test]
fn bridge_clear_session_requires_session_id() {
let mut bridge = bridge();
let response = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("clear-1".to_string()),
session_id: None,
command: ExternalBridgeCommand::ClearSession,
});
match response.result {
ExternalBridgeResult::Error { error } => {
assert_eq!(error.code, "session_id_required");
}
other => panic!("expected error result, got {other:?}"),
}
}
#[test]
fn bridge_can_upsert_group_and_commit_staged_transition() {
let mut bridge = bridge();
let session_id = Some("desk-a".to_string());
let requests = [
ExternalBridgeRequest {
request_id: Some("group".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::UpsertGroup {
group_id: "focus_pair".to_string(),
tags: vec!["runtime".to_string()],
members: vec![
crate::PanelTarget {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Top,
},
crate::PanelTarget {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Middle,
},
],
overwrite: true,
},
},
},
ExternalBridgeRequest {
request_id: Some("param".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetGroupParameter {
group_id: Some("focus_pair".to_string()),
key: "grain".to_string(),
value: crate::SceneParameterValue::Scalar(0.88),
},
},
},
ExternalBridgeRequest {
request_id: Some("style".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetTransitionStyle {
style: crate::SceneTransitionStyle::Chase,
duration_ms: Some(420),
},
},
},
ExternalBridgeRequest {
request_id: Some("pattern".to_string()),
session_id: session_id.clone(),
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
},
},
},
ExternalBridgeRequest {
request_id: Some("trigger".to_string()),
session_id,
command: ExternalBridgeCommand::ExecutePrimitive {
primitive: ShowControlPrimitive::TriggerTransition,
},
},
];
for request in requests {
let _ = bridge.handle_request(request);
}
let state = bridge.handle_request(ExternalBridgeRequest {
request_id: Some("state".to_string()),
session_id: None,
command: ExternalBridgeCommand::GetState,
});
match state.result {
ExternalBridgeResult::State { state } => {
assert_eq!(state.global.selected_group.as_deref(), Some("focus_pair"));
assert_eq!(state.global.transition_duration_ms, 420);
assert_eq!(state.active_scene.pattern_id, "noise");
}
other => panic!("expected state result, got {other:?}"),
}
}
}
+677 -67
View File
@@ -3,68 +3,110 @@ use crate::{
SceneParameterValue, SceneTransitionStyle,
};
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 = "action", content = "payload")]
pub enum ExternalControlAction {
SetBlackout {
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
pub enum ShowControlPrimitive {
Blackout {
enabled: bool,
},
SetMasterBrightness {
value: f32,
},
SelectPattern {
pattern_id: String,
},
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: SceneParameterValue,
},
SetTransitionConfig {
duration_ms: u32,
style: SceneTransitionStyle,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
TriggerPanelTest {
target: PanelTarget,
SetMasterBrightness {
value: f32,
},
SetPattern {
pattern_id: String,
},
SetGroupParameter {
group_id: Option<String>,
key: String,
value: SceneParameterValue,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
SetTransitionStyle {
style: SceneTransitionStyle,
duration_ms: Option<u32>,
},
TriggerTransition,
RequestSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalAdapterCapabilities {
pub supports_group_targeting: bool,
pub supports_blackout: bool,
pub supports_preset_recall: bool,
pub supports_parameter_updates: bool,
pub supports_transition_config: bool,
pub supports_panel_tests: bool,
pub supports_creative_snapshot_recall: bool,
pub supports_master_brightness: bool,
pub supports_pattern_staging: bool,
pub supports_group_parameter_staging: bool,
pub supports_group_upsert: bool,
pub supports_transition_staging: bool,
pub supports_explicit_trigger: bool,
pub supports_snapshot_request: bool,
}
impl Default for ExternalAdapterCapabilities {
fn default() -> Self {
Self {
supports_group_targeting: true,
supports_blackout: true,
supports_preset_recall: true,
supports_parameter_updates: true,
supports_transition_config: true,
supports_panel_tests: false,
supports_creative_snapshot_recall: true,
supports_master_brightness: true,
supports_pattern_staging: true,
supports_group_parameter_staging: true,
supports_group_upsert: true,
supports_transition_staging: true,
supports_explicit_trigger: true,
supports_snapshot_request: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShowControlPrimitiveOutcome {
Buffered { summary: String },
Command(CommandOutcome),
Snapshot(HostSnapshot),
}
#[derive(Debug, Clone, 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>,
pending_group_id: Option<Option<String>>,
pending_parameters: BTreeMap<String, SceneParameterValue>,
pending_transition_style: Option<SceneTransitionStyle>,
pending_transition_duration_ms: Option<u32>,
}
pub trait ExternalShowControlPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn execute_action(
fn execute_primitive(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError>;
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
pub trait ExternalShowControlAdapter: Send {
@@ -72,10 +114,169 @@ pub trait ExternalShowControlAdapter: Send {
fn capabilities(&self) -> ExternalAdapterCapabilities {
ExternalAdapterCapabilities::default()
}
fn translate(
fn apply_primitive(
&mut self,
action: ExternalControlAction,
) -> Result<Vec<HostCommand>, HostCommandError>;
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
}
impl ShowControlSession {
pub fn apply(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
port.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ShowControlPrimitive::SetPattern { pattern_id } => {
if pattern_id.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_pattern_id",
"pattern_id must not be empty",
));
}
self.pending_pattern_id = Some(pattern_id.clone());
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("pattern staged: {pattern_id}"),
})
}
ShowControlPrimitive::SetGroupParameter {
group_id,
key,
value,
} => {
if key.trim().is_empty() {
return Err(HostCommandError::new(
"invalid_group_parameter_key",
"group parameter key must not be empty",
));
}
self.pending_group_id = Some(group_id.clone());
self.pending_parameters.insert(key.clone(), value);
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!(
"group parameter staged: {} for {}",
key,
group_id.as_deref().unwrap_or("current_group")
),
})
}
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::SetTransitionStyle { style, duration_ms } => {
self.pending_transition_style = Some(style);
if let Some(duration_ms) = duration_ms {
self.pending_transition_duration_ms = Some(duration_ms);
}
Ok(ShowControlPrimitiveOutcome::Buffered {
summary: format!("transition style staged: {}", style.label()),
})
}
ShowControlPrimitive::TriggerTransition => self.trigger_transition(port),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(port.snapshot()))
}
}
}
fn trigger_transition(
&mut self,
port: &dyn HostApiPort,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
let Some(pattern_id) = self.pending_pattern_id.clone() else {
return Err(HostCommandError::new(
"transition_pattern_required",
"trigger_transition requires a staged pattern",
));
};
if let Some(group_id) = self.pending_group_id.clone() {
port.send_command(HostCommand::SelectGroup { group_id })?;
}
if let Some(duration_ms) = self.pending_transition_duration_ms {
port.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
}
if let Some(style) = self.pending_transition_style {
port.send_command(HostCommand::SetTransitionStyle(style))?;
}
let mut outcome = port.send_command(HostCommand::SelectPattern(pattern_id.clone()))?;
for (key, value) in self.pending_parameters.clone() {
outcome = port.send_command(HostCommand::SetSceneParameter { key, value })?;
}
let summary = if let Some(group_id) = self.pending_group_id.as_ref() {
format!(
"transition triggered: {} on {}",
pattern_id,
group_id.as_deref().unwrap_or("all_panels")
)
} else {
format!("transition triggered: {pattern_id}")
};
self.clear_transition_buffer();
Ok(ShowControlPrimitiveOutcome::Command(CommandOutcome {
generated_at_millis: outcome.generated_at_millis,
summary,
}))
}
pub fn clear_transition_buffer(&mut self) {
self.pending_pattern_id = None;
self.pending_group_id = None;
self.pending_parameters.clear();
self.pending_transition_style = None;
self.pending_transition_duration_ms = None;
}
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 {
@@ -83,42 +284,451 @@ impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
HostApiPort::snapshot(self)
}
fn execute_action(
fn execute_primitive(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError> {
match action {
ExternalControlAction::SetBlackout { enabled } => {
self.send_command(HostCommand::SetBlackout(enabled))
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match primitive {
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetBlackout(enabled))?,
)),
ShowControlPrimitive::RecallPreset { preset_id } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::RecallPreset { preset_id })?,
))
}
ExternalControlAction::SetMasterBrightness { value } => {
self.send_command(HostCommand::SetMasterBrightness(value))
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::RecallCreativeSnapshot { snapshot_id },
)?))
}
ExternalControlAction::SelectPattern { pattern_id } => {
self.send_command(HostCommand::SelectPattern(pattern_id))
ShowControlPrimitive::SetMasterBrightness { value } => {
Ok(ShowControlPrimitiveOutcome::Command(
self.send_command(HostCommand::SetMasterBrightness(value))?,
))
}
ExternalControlAction::RecallPreset { preset_id } => {
self.send_command(HostCommand::RecallPreset { preset_id })
}
ExternalControlAction::SelectGroup { group_id } => {
self.send_command(HostCommand::SelectGroup { group_id })
}
ExternalControlAction::SetSceneParameter { key, value } => {
self.send_command(HostCommand::SetSceneParameter { key, value })
}
ExternalControlAction::SetTransitionConfig { duration_ms, style } => {
self.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
self.send_command(HostCommand::SetTransitionStyle(style))
}
ExternalControlAction::RecallCreativeSnapshot { snapshot_id } => {
self.send_command(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ExternalControlAction::TriggerPanelTest { target } => {
self.send_command(HostCommand::TriggerPanelTest {
target,
pattern: crate::TestPatternKind::WalkingPixel106,
})
ShowControlPrimitive::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
},
)?)),
ShowControlPrimitive::RequestSnapshot => {
Ok(ShowControlPrimitiveOutcome::Snapshot(self.snapshot()))
}
ShowControlPrimitive::SetPattern { .. }
| ShowControlPrimitive::SetGroupParameter { .. }
| ShowControlPrimitive::SetTransitionStyle { .. }
| ShowControlPrimitive::TriggerTransition => Err(HostCommandError::new(
"show_control_session_required",
"staged show-control primitives require a stateful ShowControlSession or adapter",
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct BufferedShowControlAdapter {
session: ShowControlSession,
}
impl BufferedShowControlAdapter {
pub fn new() -> Self {
Self::default()
}
pub fn session(&self) -> &ShowControlSession {
&self.session
}
}
impl ExternalShowControlAdapter for BufferedShowControlAdapter {
fn adapter_id(&self) -> &str {
"buffered_show_control"
}
fn apply_primitive(
&mut self,
port: &dyn HostApiPort,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
self.session.apply(port, primitive)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceShowControlMode {
StatefulSession,
StatelessPort,
}
#[derive(Debug, Clone)]
pub struct ReferenceShowControlClient<P> {
port: P,
mode: ReferenceShowControlMode,
adapter: BufferedShowControlAdapter,
}
impl<P: HostApiPort> ReferenceShowControlClient<P> {
pub fn stateful(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatefulSession,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn stateless(port: P) -> Self {
Self {
port,
mode: ReferenceShowControlMode::StatelessPort,
adapter: BufferedShowControlAdapter::new(),
}
}
pub fn mode(&self) -> ReferenceShowControlMode {
self.mode
}
pub fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(&self.port)
}
pub fn pending_session(&self) -> &ShowControlSession {
self.adapter.session()
}
pub fn pending_state(&self) -> ShowControlPendingState {
self.adapter.session().pending_state()
}
pub fn apply_primitive(
&mut self,
primitive: ShowControlPrimitive,
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
match self.mode {
ReferenceShowControlMode::StatefulSession => {
self.adapter.apply_primitive(&self.port, primitive)
}
ReferenceShowControlMode::StatelessPort => self.port.execute_primitive(primitive),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SimulationHostService;
use infinity_config::ProjectConfig;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
#[test]
fn staged_pattern_and_transition_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
let outcome = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
match outcome {
ShowControlPrimitiveOutcome::Command(outcome) => {
assert!(outcome.summary.contains("transition triggered: noise"));
}
other => panic!("expected command outcome, got {other:?}"),
}
let snapshot = client.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn trigger_transition_requires_a_staged_pattern() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
let error = client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect_err("trigger without pattern should fail");
assert_eq!(error.code, "transition_pattern_required");
}
#[test]
fn staged_primitives_only_mutate_host_when_transition_is_triggered() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let staged = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
assert_eq!(
staged,
ShowControlPrimitiveOutcome::Buffered {
summary: "pattern staged: noise".to_string(),
}
);
let staged_snapshot = client.snapshot();
assert_eq!(
staged_snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn group_update_parameter_change_and_commit_replay_cleanly() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::UpsertGroup {
group_id: "focus_pair".to_string(),
tags: vec!["runtime".to_string(), "focus".to_string()],
members: vec![
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Top,
},
PanelTarget {
node_id: "node-01".to_string(),
panel_position: infinity_config::PanelPosition::Middle,
},
],
overwrite: true,
})
.expect("group upsert should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("focus_pair".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.81),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Chase,
duration_ms: Some(480),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.global.selected_group.as_deref(),
Some("focus_pair")
);
assert_eq!(
snapshot.global.transition_style,
SceneTransitionStyle::Chase
);
assert_eq!(snapshot.global.transition_duration_ms, 480);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("focus_pair")
);
assert!(snapshot
.active_scene
.parameters
.iter()
.any(|parameter| parameter.key == "grain"
&& parameter.value == SceneParameterValue::Scalar(0.81)));
assert!(snapshot
.catalog
.groups
.iter()
.any(|group| group.group_id == "focus_pair"));
}
#[test]
fn preset_recall_interrupts_running_transition_with_a_new_transition() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
style: SceneTransitionStyle::Crossfade,
duration_ms: Some(1600),
})
.expect("transition style staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let active_before_recall = client.snapshot();
assert!(active_before_recall.engine.active_transition.is_some());
client
.apply_primitive(ShowControlPrimitive::RecallPreset {
preset_id: "ocean_gradient".to_string(),
})
.expect("preset recall should succeed");
let snapshot = client.snapshot();
assert_eq!(
snapshot.active_scene.preset_id.as_deref(),
Some("ocean_gradient")
);
assert_eq!(snapshot.active_scene.pattern_id, "gradient");
assert!(snapshot.engine.active_transition.is_some());
assert!(snapshot
.recent_events
.iter()
.any(|event| event.message.contains("preset recalled: ocean_gradient")));
}
#[test]
fn blackout_during_staged_session_keeps_pending_transition_buffer() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateful(service);
client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect("pattern staging should succeed");
client
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
group_id: Some("bottom_panels".to_string()),
key: "grain".to_string(),
value: SceneParameterValue::Scalar(0.74),
})
.expect("group parameter staging should succeed");
client
.apply_primitive(ShowControlPrimitive::Blackout { enabled: true })
.expect("blackout should succeed");
assert_eq!(
client.pending_session().pending_pattern_id.as_deref(),
Some("noise")
);
assert_eq!(
client.pending_session().pending_group_id,
Some(Some("bottom_panels".to_string()))
);
client
.apply_primitive(ShowControlPrimitive::TriggerTransition)
.expect("trigger should succeed");
let snapshot = client.snapshot();
assert_eq!(snapshot.global.blackout, true);
assert_eq!(snapshot.active_scene.pattern_id, "noise");
assert_eq!(
snapshot.active_scene.target_group.as_deref(),
Some("bottom_panels")
);
assert!(client.pending_session().pending_pattern_id.is_none());
}
#[test]
fn request_snapshot_is_read_only() {
let service = SimulationHostService::new(sample_project());
let baseline = crate::HostApiPort::snapshot(&service);
let mut client = ReferenceShowControlClient::stateful(service);
let outcome = client
.apply_primitive(ShowControlPrimitive::RequestSnapshot)
.expect("snapshot request should succeed");
match outcome {
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
assert_eq!(
snapshot.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
other => panic!("expected snapshot outcome, got {other:?}"),
}
let after = client.snapshot();
assert_eq!(
after.active_scene.pattern_id,
baseline.active_scene.pattern_id
);
}
#[test]
fn staged_primitives_are_rejected_on_a_stateless_port() {
let service = SimulationHostService::new(sample_project());
let mut client = ReferenceShowControlClient::stateless(service);
let error = client
.apply_primitive(ShowControlPrimitive::SetPattern {
pattern_id: "noise".to_string(),
})
.expect_err("staged primitive should require a session");
assert_eq!(error.code, "show_control_session_required");
}
#[test]
fn invalid_group_parameter_key_is_rejected() {
let service = SimulationHostService::new(sample_project());
let mut session = ShowControlSession::default();
let error = session
.apply(
&service,
ShowControlPrimitive::SetGroupParameter {
group_id: Some("top_panels".to_string()),
key: " ".to_string(),
value: SceneParameterValue::Scalar(0.4),
},
)
.expect_err("empty parameter key should fail");
assert_eq!(error.code, "invalid_group_parameter_key");
}
}
+3 -1
View File
@@ -1,11 +1,13 @@
pub mod control;
pub mod external_bridge;
pub mod external_control;
pub mod runtime;
pub mod scene;
pub mod show_store;
pub mod simulation;
pub mod external_control;
pub use control::*;
pub use external_bridge::*;
pub use external_control::*;
pub use runtime::*;
pub use scene::*;
+62 -4
View File
@@ -1,10 +1,14 @@
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(author, version, about = "Infinity Vis host-side validation and planning CLI")]
#[command(
author,
version,
about = "Infinity Vis host-side validation and planning CLI"
)]
struct Cli {
#[command(subcommand)]
command: Command,
@@ -28,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,
}
@@ -52,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
@@ -80,7 +94,10 @@ fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
ValidationSeverity::Warning => "WARN",
ValidationSeverity::Error => "ERROR",
};
println!("[{level}] {} at {}: {}", issue.code, issue.path, issue.message);
println!(
"[{level}] {} at {}: {}",
issue.code, issue.path, issue.message
);
}
if report.is_ok() {
@@ -140,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:",
+13 -5
View File
@@ -22,7 +22,7 @@ impl Default for TickSchedule {
logic_hz: 120,
frame_synthesis_hz: 60,
network_send_hz: 60,
preview_hz: 15,
preview_hz: 60,
}
}
}
@@ -48,21 +48,29 @@ impl Default for RealtimeEngine {
}
impl RealtimeEngine {
pub fn validate_project(&self, project: &ProjectConfig, mode: ValidationMode) -> ValidationReport {
pub fn validate_project(
&self,
project: &ProjectConfig,
mode: ValidationMode,
) -> ValidationReport {
let mut report = project.validate(mode);
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
if self.schedule.preview_hz > self.schedule.frame_synthesis_hz {
report.issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: "preview_rate_too_high",
path: "runtime.schedule.preview_hz".to_string(),
message: "preview rate should stay below frame synthesis rate".to_string(),
message: "preview rate should not exceed frame synthesis rate".to_string(),
});
}
report
}
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
let Some(preset) = project.presets.iter().find(|preset| preset.preset_id == preset_id) else {
let Some(preset) = project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
else {
return Vec::new();
};
File diff suppressed because it is too large Load Diff
+335 -19
View File
@@ -1,11 +1,11 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
OutputBackendMode, PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{SceneRuntime, PatternRegistry},
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>,
@@ -86,6 +134,19 @@ pub struct RuntimeStateStorage {
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeStateLoadWarning {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeStateLoadResult {
pub runtime: PersistedRuntimeState,
pub loaded_from_disk: bool,
pub warnings: Vec<RuntimeStateLoadWarning>,
}
#[derive(Debug, thiserror::Error)]
pub enum ShowStoreError {
#[error("runtime state I/O failed: {0}")]
@@ -118,16 +179,86 @@ impl RuntimeStateStorage {
}
let raw = fs::read_to_string(&self.path)?;
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(&raw)?;
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
return Err(ShowStoreError::Validation(format!(
"unsupported runtime state schema version {} at {}",
envelope.schema_version,
self.path.display()
)));
parse_runtime_state(&raw, &self.path)
}
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
if !self.path.exists() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: Vec::new(),
};
}
Ok(envelope.runtime)
let raw = match fs::read_to_string(&self.path) {
Ok(raw) => raw,
Err(error) => {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
};
}
};
if raw.trim().is_empty() {
return RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_empty",
format!(
"runtime state at {} was empty and was reset to defaults",
self.path.display()
),
)],
};
}
match parse_runtime_state(&raw, &self.path) {
Ok(runtime) => RuntimeStateLoadResult {
runtime,
loaded_from_disk: true,
warnings: Vec::new(),
},
Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_parse_failed",
format!(
"runtime state at {} could not be parsed and was reset to defaults: {error}",
self.path.display()
),
)],
},
Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_schema_unsupported",
format!("{message}; runtime state was reset to defaults"),
)],
},
Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult {
runtime: PersistedRuntimeState::default(),
loaded_from_disk: false,
warnings: vec![RuntimeStateLoadWarning::new(
"runtime_state_read_failed",
format!(
"runtime state at {} could not be read and was reset to defaults: {error}",
self.path.display()
),
)],
},
}
}
pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> {
@@ -146,6 +277,28 @@ impl RuntimeStateStorage {
}
}
impl RuntimeStateLoadWarning {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
fn parse_runtime_state(raw: &str, path: &Path) -> Result<PersistedRuntimeState, ShowStoreError> {
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(raw)?;
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
return Err(ShowStoreError::Validation(format!(
"unsupported runtime state schema version {} at {}",
envelope.schema_version,
path.display()
)));
}
Ok(envelope.runtime)
}
impl ShowStore {
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
let presets = project
@@ -155,7 +308,9 @@ impl ShowStore {
preset_id: preset.preset_id.clone(),
scene: registry.scene_from_preset_config(preset),
transition_duration_ms: preset.transition_ms,
transition_style: crate::scene::transition_style_from_duration(preset.transition_ms),
transition_style: crate::scene::transition_style_from_duration(
preset.transition_ms,
),
source: CatalogSource::BuiltIn,
updated_at_unix_ms: None,
})
@@ -283,10 +438,7 @@ impl ShowStore {
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
}
pub fn recall_creative_snapshot(
&self,
snapshot_id: &str,
) -> Option<StoredCreativeSnapshot> {
pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option<StoredCreativeSnapshot> {
self.creative_snapshots
.iter()
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
@@ -332,7 +484,11 @@ impl ShowStore {
));
}
if let Some(existing) = self.presets.iter().find(|preset| preset.preset_id == preset_id) {
if let Some(existing) = self
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
{
if !overwrite {
return Err(HostCommandError::new(
"preset_exists",
@@ -359,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,
@@ -446,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()
@@ -512,7 +700,10 @@ mod tests {
.presets
.iter()
.any(|preset| preset.preset_id == "ocean_gradient"));
assert!(catalog.groups.iter().any(|group| group.group_id == "top_panels"));
assert!(catalog
.groups
.iter()
.any(|group| group.group_id == "top_panels"));
}
#[test]
@@ -552,6 +743,45 @@ mod tests {
assert!(store.recall_creative_snapshot("variant_a").is_some());
}
#[test]
fn runtime_presets_can_be_deleted_but_builtins_cannot() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
let scene = registry.scene_for_pattern(
"noise",
None,
Some("top_panels".to_string()),
31,
vec!["#AA8844".to_string()],
false,
);
store
.save_preset_from_scene(
"runtime_delete_me",
&scene,
210,
SceneTransitionStyle::Crossfade,
false,
)
.expect("runtime preset save should succeed");
store
.delete_preset("runtime_delete_me")
.expect("runtime preset delete should succeed");
assert!(store.scene_from_preset_id("runtime_delete_me").is_none());
let built_in_preset_id = store
.catalog(&registry)
.presets
.iter()
.find(|preset| preset.source == CatalogSource::BuiltIn)
.map(|preset| preset.preset_id.clone())
.expect("sample project should contain built-in presets");
let delete_error = store
.delete_preset(&built_in_preset_id)
.expect_err("built-in presets should not be deletable");
assert_eq!(delete_error.code, "preset_delete_forbidden");
}
#[test]
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
let registry = PatternRegistry::new();
@@ -590,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");
@@ -602,4 +833,89 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_empty_file() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_empty_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "").expect("empty file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_empty");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_invalid_json() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_invalid_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(&path, "{ definitely not json").expect("invalid file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed");
let _ = std::fs::remove_file(path);
}
#[test]
fn runtime_state_storage_recovers_from_unsupported_schema() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_show_store_schema_{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis()
));
let storage = RuntimeStateStorage::new(&path);
std::fs::write(
&path,
r#"{
"schema_version": 99,
"saved_at_unix_ms": 1,
"runtime": {
"active_scene": null,
"global": {
"blackout": false,
"master_brightness": 0.2,
"transition_duration_ms": 150,
"transition_style": "crossfade"
},
"user_presets": [],
"user_groups": [],
"creative_snapshots": []
}
}"#,
)
.expect("schema file should write");
let loaded = storage.load_with_recovery();
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
assert!(!loaded.loaded_from_disk);
assert_eq!(loaded.warnings.len(), 1);
assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported");
let _ = std::fs::remove_file(path);
}
}
+502 -123
View File
@@ -1,9 +1,10 @@
use crate::{
control::{
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
HostCommand, HostCommandError, HostSnapshot, HOST_API_VERSION, NodeConnectionState,
NodeSnapshot, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot,
HostCommand, HostCommandError, HostSnapshot, NodeConnectionState, NodeSnapshot,
OutputBackendMode, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot, TechnicalSnapshot,
HOST_API_VERSION,
},
runtime::TickSchedule,
scene::{
@@ -11,7 +12,8 @@ use crate::{
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
},
show_store::{
PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError,
PersistedGlobalState, PersistedNodeState, PersistedPanelState, PersistedTechnicalState,
RuntimeStateStorage, ShowStore, ShowStoreError,
},
};
use infinity_config::{PanelPosition, ProjectConfig};
@@ -45,6 +47,7 @@ struct SimulationState {
schedule: TickSchedule,
current_scene: SceneRuntime,
active_transition: Option<TransitionRuntime>,
technical_state: PersistedTechnicalState,
snapshot: HostSnapshot,
}
@@ -123,19 +126,31 @@ impl SimulationState {
) -> Result<Self, ShowStoreError> {
let registry = PatternRegistry::new();
let mut show_store = ShowStore::from_project(&project, &registry);
let persisted_runtime = if let Some(storage) = &runtime_storage {
storage.load()?
let runtime_load = if let Some(storage) = &runtime_storage {
storage.load_with_recovery()
} else {
Default::default()
crate::show_store::RuntimeStateLoadResult {
runtime: Default::default(),
loaded_from_disk: false,
warnings: Vec::new(),
}
};
let runtime_loaded_from_disk = runtime_load.loaded_from_disk;
let runtime_warnings = runtime_load.warnings;
let persisted_runtime = runtime_load.runtime;
let restored_scene = persisted_runtime.active_scene.clone();
let restored_global = persisted_runtime.global.clone();
let restored_technical = persisted_runtime.technical.clone();
show_store.apply_persisted(persisted_runtime);
let group_members = show_store.group_members_map();
let schedule = TickSchedule::default();
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(&registry));
let catalog = show_store.catalog(&registry);
let available_patterns = show_store.available_patterns(&registry);
let initial_offline_status = offline_status_message(
restored_technical.backend_mode,
restored_technical.output_enabled,
);
let nodes = project
.topology
.nodes
@@ -144,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<_>>();
@@ -155,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<_>>();
@@ -188,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,
@@ -231,15 +255,48 @@ 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);
state.push_event(StatusEventKind::Info, None, "simulation host service started".to_string());
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,
Some(startup_code.to_string()),
startup_message,
);
if state.runtime_storage.is_some() {
let (code, message) = if runtime_loaded_from_disk {
(
"runtime_state_restored",
"runtime state restored from persistence".to_string(),
)
} else {
(
"runtime_state_persistence_enabled",
"runtime state persistence enabled".to_string(),
)
};
state.push_event(StatusEventKind::Info, Some(code.to_string()), message);
}
for warning in runtime_warnings {
state.push_event(
StatusEventKind::Info,
Some("runtime_state_restored".to_string()),
"runtime state persistence enabled".to_string(),
StatusEventKind::Warning,
Some(warning.code),
warning.message,
);
}
state.simulate_tick();
@@ -261,10 +318,11 @@ impl SimulationState {
self.update_node_states();
self.update_panel_states();
self.resolve_transition_if_complete();
self.snapshot.engine.active_transition = self
.active_transition
.as_ref()
.map(|transition| self.registry.transition_snapshot(&self.current_scene, transition));
self.snapshot.engine.active_transition =
self.active_transition.as_ref().map(|transition| {
self.registry
.transition_snapshot(&self.current_scene, transition)
});
self.snapshot.active_scene = self.registry.active_scene_snapshot(&self.current_scene);
self.snapshot.global.selected_pattern = self.current_scene.pattern_id.clone();
self.snapshot.global.selected_group = self.current_scene.target_group.clone();
@@ -274,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;
@@ -305,13 +502,9 @@ impl SimulationState {
false,
);
self.next_seed += 1;
if let Some(speed) = self.current_scene.parameters.get("speed").cloned() {
self.registry.set_scene_parameter(&mut new_scene, "speed", speed);
}
if let Some(intensity) = self.current_scene.parameters.get("intensity").cloned() {
for (key, value) in self.current_scene.parameters.clone() {
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;
@@ -454,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,
@@ -563,56 +768,83 @@ impl SimulationState {
if finished {
self.active_transition = None;
self.push_event(StatusEventKind::Info, None, format!(
"transition completed to {}",
self.current_scene.pattern_id
));
self.push_event(
StatusEventKind::Info,
None,
format!("transition completed to {}", self.current_scene.pattern_id),
);
}
}
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
.nodes
.iter()
.map(|node| (node.node_id.clone(), (node.connection, node.error_status.clone())))
.map(|node| {
(
node.node_id.clone(),
(node.connection, node.error_status.clone()),
)
})
.collect();
for panel in &mut self.snapshot.panels {
@@ -621,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,
)),
};
}
}
@@ -648,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 {
@@ -666,15 +901,40 @@ 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, elapsed_ms);
let from = self.registry.render_preview(
&transition.from_scene,
panel_row,
panel_col,
panel_rows,
panel_cols,
led_count,
elapsed_ms,
);
let progress = self
.registry
.transition_snapshot(&self.current_scene, transition)
@@ -698,7 +958,10 @@ impl SimulationState {
preview = apply_group_gate(&preview, active_in_group);
}
(scale_preview(preview, self.snapshot.global.master_brightness), source)
(
scale_preview(preview, self.snapshot.global.master_brightness),
source,
)
}
fn rebuild_catalog(&mut self) {
@@ -720,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",
@@ -741,12 +1006,7 @@ impl SimulationState {
Ok(())
}
fn push_event(
&mut self,
kind: StatusEventKind,
code: Option<String>,
message: String,
) {
fn push_event(&mut self, kind: StatusEventKind, code: Option<String>, message: String) {
self.snapshot.recent_events.insert(
0,
StatusEvent {
@@ -774,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,
@@ -819,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",
@@ -867,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);
@@ -914,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]
@@ -932,7 +1230,10 @@ mod tests {
let snapshot = service.snapshot();
assert_eq!(snapshot.active_scene.pattern_id, "walking_pixel");
assert_eq!(snapshot.global.selected_group.as_deref(), Some("all_panels"));
assert_eq!(
snapshot.global.selected_group.as_deref(),
Some("all_panels")
);
assert!(snapshot
.active_scene
.parameters
@@ -957,4 +1258,82 @@ mod tests {
.any(|event| event.kind == StatusEventKind::Warning
&& event.code.as_deref() == Some("unknown_group")));
}
#[test]
fn persistent_service_recovers_from_invalid_runtime_state_with_warning_event() {
let path = std::env::temp_dir().join(format!(
"infinity_vis_simulation_invalid_{}.json",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_millis()
));
std::fs::write(&path, "{ broken").expect("invalid runtime state should write");
let service = SimulationHostService::try_new_with_persistence(sample_project(), &path)
.expect("service should recover from invalid runtime state");
let snapshot = service.snapshot();
assert!(snapshot.recent_events.iter().any(|event| {
event.kind == StatusEventKind::Warning
&& event.code.as_deref() == Some("runtime_state_parse_failed")
}));
assert!(snapshot.recent_events.iter().any(|event| {
event.kind == StatusEventKind::Info
&& event.code.as_deref() == Some("runtime_state_persistence_enabled")
}));
assert_eq!(snapshot.active_scene.pattern_id, "solid_color");
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"
}
}
]
}
@@ -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")
}
+256 -22
View File
@@ -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,
@@ -561,15 +648,17 @@ impl ApiStateSnapshot {
uptime_ms: snapshot.engine.uptime_ms,
frame_index: snapshot.engine.frame_index,
dropped_frames: snapshot.engine.dropped_frames,
active_transition: snapshot.engine.active_transition.as_ref().map(|transition| {
ApiTransitionState {
active_transition: snapshot
.engine
.active_transition
.as_ref()
.map(|transition| ApiTransitionState {
style: map_transition_style(transition.style),
from_pattern_id: transition.from_pattern_id.clone(),
to_pattern_id: transition.to_pattern_id.clone(),
duration_ms: transition.duration_ms,
progress: transition.progress,
}
}),
}),
},
active_scene: ApiActiveScene {
preset_id: snapshot.active_scene.preset_id.clone(),
@@ -613,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()),
@@ -652,19 +742,50 @@ 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))
}
ApiCommand::SelectPattern { pattern_id } => {
Ok(HostCommand::SelectPattern(pattern_id))
}
ApiCommand::RecallPreset { preset_id } => {
Ok(HostCommand::RecallPreset { preset_id })
}
ApiCommand::SelectGroup { group_id } => {
Ok(HostCommand::SelectGroup { group_id })
}
ApiCommand::SelectPattern { pattern_id } => Ok(HostCommand::SelectPattern(pattern_id)),
ApiCommand::RecallPreset { preset_id } => Ok(HostCommand::RecallPreset { preset_id }),
ApiCommand::SelectGroup { group_id } => Ok(HostCommand::SelectGroup { group_id }),
ApiCommand::SetSceneParameter { key, value } => Ok(HostCommand::SetSceneParameter {
key,
value: map_command_parameter_value(value),
@@ -672,9 +793,9 @@ impl ApiCommandRequest {
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::SetTransitionStyle { style } => {
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
}
ApiCommand::SetTransitionStyle { style } => Ok(HostCommand::SetTransitionStyle(
map_command_transition_style(style),
)),
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
@@ -695,6 +816,7 @@ impl ApiCommandRequest {
preset_id,
overwrite,
}),
ApiCommand::DeletePreset { preset_id } => Ok(HostCommand::DeletePreset { preset_id }),
ApiCommand::SaveCreativeSnapshot {
snapshot_id,
label,
@@ -774,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,
@@ -849,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",
@@ -859,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",
@@ -867,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()
@@ -875,7 +1102,10 @@ impl ApiCommand {
}
}
Self::SetMasterBrightness { value } => {
format!("master brightness set to {:.0}%", value.clamp(0.0, 1.0) * 100.0)
format!(
"master brightness set to {:.0}%",
value.clamp(0.0, 1.0) * 100.0
)
}
Self::SelectPattern { pattern_id } => format!("pattern selected: {pattern_id}"),
Self::RecallPreset { preset_id } => format!("preset recalled: {preset_id}"),
@@ -900,13 +1130,17 @@ impl ApiCommand {
node_id,
panel_position.label()
),
Self::SavePreset { preset_id, overwrite } => {
Self::SavePreset {
preset_id,
overwrite,
} => {
if *overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
}
}
Self::DeletePreset { preset_id } => format!("preset deleted: {preset_id}"),
Self::SaveCreativeSnapshot {
snapshot_id,
overwrite,
+4 -4
View File
@@ -22,12 +22,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
let server = HostApiServer::bind(&cli.bind, service)?;
println!("Infinity Vis host API listening on http://{}", server.local_addr());
println!("Web UI available at http://{}/", server.local_addr());
println!(
"Runtime state persistence: {}",
cli.runtime_state.display()
"Infinity Vis host API listening on http://{}",
server.local_addr()
);
println!("Web UI available at http://{}/", server.local_addr());
println!("Runtime state persistence: {}", cli.runtime_state.display());
loop {
thread::sleep(Duration::from_secs(60));
+384 -40
View File
@@ -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,
@@ -91,37 +93,62 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
match (request.method.as_str(), request.path.as_str()) {
("GET", "/api/v1/snapshot") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiSnapshotResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiSnapshotResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/state") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiStateResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiStateResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/preview") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPreviewResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiPreviewResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiCatalogResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiCatalogResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/presets") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPresetListResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiPresetListResponse::from_snapshot(&snapshot),
)
}
("GET", "/api/v1/groups") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiGroupListResponse::from_snapshot(&snapshot))
respond_json(
&mut stream,
200,
&ApiGroupListResponse::from_snapshot(&snapshot),
)
}
("POST", "/api/v1/command") => match handle_command_post(&mut stream, request, service) {
Ok(()) => Ok(()),
Err(error) => respond_error(
&mut stream,
error.status,
error.code,
error.message,
),
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,
@@ -134,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,
@@ -152,7 +197,10 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
"application/json; charset=utf-8",
&serde_json::to_string_pretty(&ApiErrorResponse::new(
"not_found",
format!("no route registered for {} {}", request.method, request.path),
format!(
"no route registered for {} {}",
request.method, request.path
),
))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
),
@@ -164,12 +212,13 @@ fn handle_command_post(
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> Result<(), ApiRequestError> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
.map_err(|error| ApiRequestError {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body).map_err(|error| {
ApiRequestError {
status: 400,
code: "invalid_request_json".to_string(),
message: format!("command request body could not be parsed: {error}"),
})?;
}
})?;
let request_id = parsed.request_id.clone();
let command_type = parsed.command.kind_label().to_string();
let command = parsed
@@ -226,7 +275,9 @@ fn handle_websocket(
stream.write_all(response.as_bytes())?;
let mut sequence = 1u64;
let mut last_event_millis = 0u64;
let mut last_event_millis = None::<u64>;
let mut last_event_signatures = Vec::<(Option<String>, String)>::new();
let mut last_streamed_preview = None::<crate::dto::ApiPreviewSnapshot>;
loop {
let snapshot = service.snapshot();
send_stream_message(
@@ -236,36 +287,331 @@ fn handle_websocket(
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
let preview_payload = crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot);
if last_streamed_preview
.as_ref()
.map(|previous| previous != &preview_payload)
.unwrap_or(true)
{
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(preview_payload.clone()),
)?;
sequence += 1;
last_streamed_preview = Some(preview_payload);
}
let mut new_events = snapshot
.recent_events
.iter()
.filter(|event| event.at_millis > last_event_millis)
.filter(|event| match last_event_millis {
None => true,
Some(last_millis) if event.at_millis > last_millis => true,
Some(last_millis) if event.at_millis == last_millis => !last_event_signatures
.iter()
.any(|signature| signature.0 == event.code && signature.1 == event.message),
Some(_) => false,
})
.cloned()
.collect::<Vec<_>>();
new_events.sort_by_key(|event| event.at_millis);
for event in new_events {
last_event_millis = event.at_millis;
let event_millis = event.at_millis;
let current_signature = (event.code.clone(), event.message.clone());
send_stream_message(
&mut stream,
sequence,
event.at_millis,
event_millis,
ApiStreamMessage::Event(event.into()),
)?;
sequence += 1;
match last_event_millis {
Some(last_millis) if last_millis == event_millis => {
last_event_signatures.push(current_signature);
}
_ => {
last_event_millis = Some(event_millis);
last_event_signatures = vec![current_signature];
}
}
}
thread::sleep(Duration::from_millis(250));
}
}
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,
@@ -332,7 +678,9 @@ struct HttpRequest {
impl HttpRequest {
fn header(&self, key: &str) -> Option<&str> {
self.headers.get(&key.to_ascii_lowercase()).map(|value| value.as_str())
self.headers
.get(&key.to_ascii_lowercase())
.map(|value| value.as_str())
}
fn is_websocket(&self) -> bool {
@@ -372,7 +720,8 @@ fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
}
}
let header_end = header_end.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing header end"))?;
let header_end = header_end
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing header end"))?;
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.lines();
let request_line = lines
@@ -393,10 +742,7 @@ fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
let mut headers = HashMap::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.insert(
key.trim().to_ascii_lowercase(),
value.trim().to_string(),
);
headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
@@ -422,7 +768,5 @@ fn parse_content_length(header_text: &str) -> Option<usize> {
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
+11 -4
View File
@@ -28,14 +28,21 @@ pub fn write_text_frame(stream: &mut TcpStream, payload: &str) -> io::Result<()>
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::new();
let mut index = 0;
while index < bytes.len() {
let first = bytes[index];
let second = if index + 1 < bytes.len() { bytes[index + 1] } else { 0 };
let third = if index + 2 < bytes.len() { bytes[index + 2] } else { 0 };
let second = if index + 1 < bytes.len() {
bytes[index + 1]
} else {
0
};
let third = if index + 2 < bytes.len() {
bytes[index + 2]
} else {
0
};
encoded.push(TABLE[(first >> 2) as usize] as char);
encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
+560 -17
View File
@@ -37,7 +37,10 @@ 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);
assert!(html
@@ -45,15 +48,123 @@ 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 &amp; 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 &amp; 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 &amp; 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
.get("content-type")
.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();
}
#[test]
fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
let server = start_server();
let html = send_http_request(server.local_addr(), "GET", "/", None);
let mut stream = open_websocket(server.local_addr());
assert_eq!(html.status_code, 200);
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);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn technical_surface_script_guards_missing_recent_events_in_state_snapshot() {
let server = start_server();
let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None);
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!(technical_js.status_code, 200);
assert!(technical_js
.body
.contains("function snapshotRecentEvents(snapshot)"));
assert!(technical_js
.body
.contains("Array.isArray(snapshot?.recent_events) ? snapshot.recent_events : []"));
assert!(technical_js
.body
.contains("const recentEvents = snapshotRecentEvents(appState.snapshot);"));
assert!(state_body["state"].get("recent_events").is_none());
server.shutdown();
}
@@ -73,13 +184,37 @@ 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"]["nodes"].as_array().map(Vec::len), Some(6));
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");
assert!(preview_body.get("preview").is_some());
assert!(preview_body.get("state").is_none());
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(snapshot.status_code, 200);
assert_eq!(snapshot_body["api_version"], "v1");
@@ -89,6 +224,144 @@ 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 technical_surface_can_disable_output_again_after_enabling_it() {
let server = start_server();
let enable_mode = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#,
);
let enable_output = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#,
);
let disable_output = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":false}}}"#,
);
assert_eq!(enable_mode.status_code, 200);
assert_eq!(enable_output.status_code, 200);
assert_eq!(disable_output.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"], false);
assert_eq!(
state_body["state"]["technical"]["live_status"],
"DDP (WLED) selected - 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();
@@ -122,10 +395,16 @@ fn command_flow_updates_group_parameters_transition_and_blackout() {
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"]["global"]["selected_group"], "top_panels");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"top_panels"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "gradient");
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"gradient"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
@@ -191,14 +470,18 @@ fn presets_and_creative_snapshots_persist_across_restart() {
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "user_noise_floor" && preset["source"] == "runtime_user"));
.any(|preset| preset["preset_id"] == "user_noise_floor"
&& preset["source"] == "runtime_user"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(state_body["state"]["active_scene"]["target_group"], "bottom_panels");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"bottom_panels"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
@@ -209,6 +492,237 @@ 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();
let mut stream = open_websocket(server.local_addr());
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let flow_responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"upsert_group","payload":{"group_id":"focus_pair","tags":["runtime","focus"],"members":[{"node_id":"node-a","panel_position":"top"},{"node_id":"node-a","panel_position":"middle"}],"overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"focus_pair"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":480}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.67}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.81}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":true}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_preset","payload":{"preset_id":"focus_noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"focus_variant","label":"Focus Variant","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"pulse"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"focus_variant"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
),
];
for response in flow_responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"focus_pair"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 480);
assert_eq!(state_body["state"]["global"]["blackout"], true);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"focus_pair"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.81));
assert!(catalog_body["groups"]
.as_array()
.expect("group array")
.iter()
.any(|group| group["group_id"] == "focus_pair" && group["source"] == "runtime_user"));
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "focus_noise"
&& preset["source"] == "runtime_user"
&& preset["transition_style"] == "chase"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "focus_variant"
&& snapshot["label"] == "Focus Variant"));
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
let mut event_messages = Vec::new();
for _ in 0..24 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
if let Some(message) = payload["message"]["payload"]["message"].as_str() {
event_messages.push(message.to_string());
}
}
}
assert!(event_messages
.iter()
.any(|message| message.contains("group saved: focus_pair")));
assert!(event_messages
.iter()
.any(|message| message.contains("preset overwritten: focus_noise")));
assert!(event_messages
.iter()
.any(|message| message.contains("creative snapshot recalled: focus_variant")));
assert!(event_messages
.iter()
.any(|message| message.contains("global blackout enabled")));
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn invalid_runtime_state_file_falls_back_without_blocking_server_start() {
let runtime_state_path = unique_runtime_state_path("invalid_runtime");
std::fs::write(&runtime_state_path, "{ broken").expect("invalid runtime state should write");
let server = start_server_with_runtime_state(&runtime_state_path);
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");
let mut stream = open_websocket(server.local_addr());
assert_eq!(state.status_code, 200);
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"solid_color"
);
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let mut saw_recovery_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "runtime_state_parse_failed"
{
saw_recovery_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
break;
}
}
assert!(
saw_recovery_warning,
"expected recovery warning event after invalid runtime state"
);
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
@@ -231,13 +745,18 @@ fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
let mut saw_warning = false;
for _ in 0..8 {
for _ in 0..12 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "unknown_creative_snapshot"
{
saw_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
assert_eq!(payload["message"]["payload"]["code"], "unknown_creative_snapshot");
assert_eq!(
payload["message"]["payload"]["code"],
"unknown_creative_snapshot"
);
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
@@ -256,7 +775,12 @@ fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let server = start_server();
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
let groups = [None, Some("top_panels"), Some("middle_panels"), Some("bottom_panels")];
let groups = [
None,
Some("top_panels"),
Some("middle_panels"),
Some("bottom_panels"),
];
for index in 0..80 {
let pattern = patterns[index % patterns.len()];
@@ -301,9 +825,21 @@ fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(state_body["state"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(catalog_body["patterns"].as_array().map(Vec::len).unwrap_or_default() >= 5);
assert_eq!(
state_body["state"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert!(
catalog_body["patterns"]
.as_array()
.map(Vec::len)
.unwrap_or_default()
>= 5
);
}
server.shutdown();
@@ -313,7 +849,12 @@ fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
send_http_request(addr, "POST", "/api/v1/command", Some(body))
}
fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
fn send_http_request(
addr: SocketAddr,
method: &str,
path: &str,
body: Option<&str>,
) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
@@ -370,7 +911,9 @@ fn open_websocket(addr: SocketAddr) -> TcpStream {
"GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
host = addr
);
stream.write_all(request.as_bytes()).expect("write handshake");
stream
.write_all(request.as_bytes())
.expect("write handshake");
let header = read_until_header_end(&mut stream);
let header_text = String::from_utf8(header).expect("handshake utf8");
+19 -17
View File
@@ -58,7 +58,9 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
ui.horizontal_wrapped(|ui| {
let blackout = snapshot.global.blackout;
let blackout_button = egui::Button::new(if blackout {
RichText::new("Blackout ACTIVE").strong().color(Color32::WHITE)
RichText::new("Blackout ACTIVE")
.strong()
.color(Color32::WHITE)
} else {
RichText::new("Blackout").strong()
})
@@ -74,10 +76,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
let mut brightness = snapshot.global.master_brightness;
if ui
.add(
egui::Slider::new(&mut brightness, 0.0..=1.0)
.text("Master Brightness"),
)
.add(egui::Slider::new(&mut brightness, 0.0..=1.0).text("Master Brightness"))
.changed()
{
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
@@ -123,18 +122,16 @@ fn draw_node_overview(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
if let Some(error) = &node.error_status {
ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140)));
} else {
ui.label(RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142)));
ui.label(
RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142)),
);
}
});
}
});
}
fn draw_panel_mapping(
ui: &mut egui::Ui,
snapshot: &HostSnapshot,
service: &Arc<dyn HostUiPort>,
) {
fn draw_panel_mapping(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
ui.separator();
ui.heading("Panel Mapping");
ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology.");
@@ -174,7 +171,10 @@ fn draw_panel_mapping(
ui.vertical(|ui| {
ui.label(connection_badge(panel.connection));
if let Some(last_test_ms) = panel.last_test_trigger_ms {
ui.label(format!("last test: {} ms", snapshot.generated_at_millis.saturating_sub(last_test_ms)));
ui.label(format!(
"last test: {} ms",
snapshot.generated_at_millis.saturating_sub(last_test_ms)
));
}
if let Some(error) = &panel.error_status {
ui.label(
@@ -254,11 +254,13 @@ fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
ui.separator();
ui.heading("Recent Events");
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for event in &snapshot.recent_events {
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
}
});
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for event in &snapshot.recent_events {
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
}
});
}
fn header_cell(ui: &mut egui::Ui, label: &str) {
-1
View File
@@ -203,4 +203,3 @@ mod tests {
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
}
}
+87
View 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": []
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ The current delivery order is intentionally software-first:
- Logic tick target: 120 Hz
- Frame synthesis target: 60 Hz
- Network send target: 40-60 Hz, profile dependent
- Preview target: 10-15 Hz
- Preview target: up to 60 Hz when the active surface can render it cleanly, otherwise 30 Hz fallback
Preview and telemetry are explicitly degradable. Realtime output is not.
+121
View File
@@ -0,0 +1,121 @@
# Codex Worklog
## 2026-04-20 - Creative Surface kompakter und Preview-FPS-Pruefung
- Geaenderte Dateien:
- `web/v1/styles.css`
- `web/v1/app.js`
- `crates/infinity_host/src/runtime.rs`
- `docs/architecture.md`
- Fachliche Aenderungen:
- Creative Surface kompakter aufgestellt, damit die 18 Preview-Panels im 6x3-Raster besser vollstaendig sichtbar bleiben.
- Frontend-Preview-Drosselung von ca. 11 fps entfernt und auf eine ruhige `requestAnimationFrame`-basierte Taktung mit Host-orientiertem Ziel-FPS umgestellt.
- Host-Preview-Schedule von 15 Hz auf 60 Hz angehoben, ohne neue Architektur einzuziehen.
- Dokumentation des Preview-Ziels auf den aktuellen Stand gebracht.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler API-Check gegen temporaeren Host auf `127.0.0.1:9002` mit bestaetigtem `engine.preview_hz = 60`
- kein echter Browser-Handtest in dieser CLI-Umgebung moeglich
- Bewusste Abweichungen zur Python-Version:
- Kein Canvas-Preview-Rewrite in diesem Schritt; die bestehende DOM/CSS-Preview bleibt erhalten und wird nur verschlankt sowie render-seitig entdrosselt.
## 2026-04-20 - Diskretere Preview-Wirkung und weniger Preview-Clutter
- Geaenderte Dateien:
- `crates/infinity_host/src/scene.rs`
- `crates/infinity_host_api/src/server.rs`
- `web/v1/app.js`
- `web/v1/index.html`
- Fachliche Aenderungen:
- Normale Pattern-Intensitaet vor Preview-Ausgabe auf 10%-Stufen quantisiert.
- Normales Preview-Smoothing standardmaessig stark reduziert; nur `breathing` darf weiterhin ueber den bestehenden `fade`-Pfad weich bleiben.
- Preview-Stream im WebSocket sendet Preview nur noch bei geaendertem Payload; der bestehende Snapshot-Heartbeat bleibt fuer den API-Vertrag erhalten.
- Master Brightness in den Header verschoben; linke Brightness-Sektion zeigt nur noch pattern-spezifische Helligkeitsparameter.
- Den sichtbaren `speed`-Slider aus der Creative Surface entfernt; BPM oben plus `tempo_multiplier` bleiben die Geschwindigkeitsbedienung.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- Bewusste Abweichungen zur Python-Version:
- Keine vollstaendige Python-Pattern-Engine-Portierung; stattdessen gezielte Diskretisierung und Stream-Beruhigung innerhalb der bestehenden Rust-Host-/Web-Architektur.
- Kein echter Browser-Handtest in dieser CLI-Umgebung moeglich.
## 2026-04-20 - Creative Surface Redesign und Control-Fix (gezielt)
- Geaenderte Dateien:
- `web/v1/index.html`
- `web/v1/app.js`
- `web/v1/styles.css`
- `crates/infinity_host/src/scene.rs`
- Fachliche Aenderungen:
- Redundante globale Brightness-Steuerung in der linken Spalte entfernt; Master Brightness bleibt oben im Header.
- Sichtbaren `speed`-Slider in der Creative Surface entfernt; Bedienung ueber BPM oben plus `tempo_multiplier` in den Pattern-Parametern.
- `palette`, `color_mode`, `direction`, `mirror` als feste Controls (keine freien Textfelder) in der Web-UI beibehalten/abgesichert.
- Palette auf zwei feste Gruppen mit den vorgegebenen Namen und Hex-Werten umgestellt; Host-Palette-Mapping auf dieselben IDs umgestellt.
- Layout gezielt entkapselt: weniger harte Rahmen, leichtere Rails, kompaktere Topbar, mehr zusammenhaengende Arbeitsflaeche.
- Preview-Grid auf feste 6 Spalten gesetzt und Tiles quadratisch gemacht (`aspect-ratio: 1 / 1`) fuer ein klares 3x6-Raster.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- Browser-Handtest:
- Versuch via lokalem Headless-Firefox auf `127.0.0.1:9002` ist in dieser Umgebung fehlgeschlagen (`cannot open display: :0` / Firefox Headless-Crash).
- Bewusste Abweichungen zur Python-Version:
- Die neue, fest vorgegebene Palette-Liste ersetzt die alten Python-Palettenamen; das ist eine inhaltliche Vorgabe dieses Arbeitsschritts.
## 2026-04-20 - Palette ersetzen, linken Brightness-Slider entfernen, linke Rail entzerren
- Geaenderte Dateien:
- `web/v1/index.html`
- `web/v1/app.js`
- `web/v1/styles.css`
- Fachliche Aenderungen:
- Die Creative-Surface-Palette bleibt auf die kuratierte WS2812-Liste mit exakt vorgegebenen Namen und Hex-Werten beschraenkt.
- Den unteren Brightness-Bereich in der linken Spalte vollstaendig entfernt; globale Helligkeit bleibt ausschliesslich im Header.
- Den dynamisch aus Host-Parametern kommenden `brightness`-Control in der linken Rail ausgefiltert.
- Die linke Rail moderat verbreitert und Parameterfelder so nachgezogen, dass Dropdowns, Labels und Farbwerte weniger gequetscht sind.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler CLI-Smoke gegen temporaeren Host auf `127.0.0.1:9002` via `/api/v1/state` und `/api/v1/preview`
- Bewusste Abweichungen zur Python-Version:
- Keine; dieser Schritt korrigiert nur die Web-Darstellung und blendet den global redundanten Brightness-Slider links aus.
## 2026-04-20 - Technical Surface null-safe gegen fehlende recent_events
- Geaenderte Dateien:
- `web/v1/technical.js`
- `crates/infinity_host_api/tests/contract.rs`
- `docs/codex_worklog.md`
- Fachliche Aenderungen:
- Technical Surface gegen unvollstaendige oder startende Snapshot-Daten robuster gemacht.
- `recent_events`, `system`, `technical`, `nodes` und `panels` werden in der UI jetzt ueber null-safe Helper mit Fallbacks gelesen.
- Direkter Zugriff auf `snapshot.recent_events.map(...)` entfernt, damit `/technical` nicht mehr crasht, wenn das Feld fehlt.
- Contract-/UI-Smoke-Test ergaenzt, der absichert, dass das ausgelieferte `technical.js` den Guard fuer fehlende `recent_events` enthaelt, waehrend `/api/v1/state` das Feld weiterhin weglassen darf.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler Host-Smoke auf `127.0.0.1:9011` mit erfolgreichem Abruf von `/` und `/api/v1/state`
- Bewusste Abweichungen zur Python-Version:
- Keine; das ist ein reiner Stabilitaets-/Robustheitsfix in der bestehenden Web-Oberflaeche.
## 2026-04-20 - Browser-Smoke-Runner und Output-Disable-Fix
- Geaenderte Dateien:
- `web/v1/technical.js`
- `crates/infinity_host_api/tests/contract.rs`
- `scripts/codex_browser_smoke.sh`
- `docs/codex_worklog.md`
- Fachliche Aenderungen:
- Technical-Output-Controls gegen Polling-/Apply-Rennen stabilisiert, indem waehrend `saveOutputSettings()` ein `outputSaving`-Zustand gesetzt wird.
- Output-Controls werden waehrend des Speicherns kurz deaktiviert und der Draft wird in dieser Zeit nicht von Polling-Snapshots ueberschrieben.
- Contract-Test ergaenzt, der absichert, dass `output_enabled` nach vorherigem Aktivieren wieder sauber auf `false` gesetzt werden kann.
- Kleinen lokalen Browser-/Route-Smoke-Runner fuer Codex angelegt, der eine Kurzinstanz startet und `/`, `/technical` sowie `/technical.js` selbst prueft.
- Gelaufene Tests:
- `cargo test -q -p infinity_host_api --test contract`
- `scripts/codex_browser_smoke.sh 9012`
- Bewusste Abweichungen zur Python-Version:
- Keine; der Schritt behebt einen Web-Workflow-Bug und verbessert nur den lokalen Debug-Pfad.
+92
View 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
View 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`
+50
View File
@@ -0,0 +1,50 @@
# Local Software-Only Runbook
## Voraussetzungen
- Rust `stable` Toolchain mit `cargo`, `rustc`, `rustfmt` und `clippy`
- dieses Repo ist lokal aktuell **kein echter Git-Clone**, sondern nur ein Arbeitsbaum ohne `.git`
- keine Hardware ist fuer den software-only Betrieb noetig
Beispiel fuer eine user-lokale Rust-Installation:
```bash
curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
. "$HOME/.cargo/env"
rustup component add rustfmt clippy
```
## Start
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
## Lokale URLs
- Creative Web-UI: `http://127.0.0.1:9001/`
- State API: `http://127.0.0.1:9001/api/v1/state`
- Preview API: `http://127.0.0.1:9001/api/v1/preview`
- Snapshot API: `http://127.0.0.1:9001/api/v1/snapshot`
- WebSocket-Stream: `ws://127.0.0.1:9001/api/v1/stream`
## Minimale Smoke-Checks
1. Web-UI laedt unter `http://127.0.0.1:9001/`.
2. `GET /api/v1/state` antwortet mit `api_version: "v1"`.
3. `ws://127.0.0.1:9001/api/v1/stream` verbindet und liefert zuerst `snapshot`, dann `preview`.
4. In der Web-UI oder ueber `POST /api/v1/command` funktionieren diese Basisfluesse:
- preset recall
- preset save / overwrite
- creative snapshot save / recall
- blackout
## Runtime-State und Recovery
- Runtime-Persistenz liegt standardmaessig unter `data/runtime_state.json`.
- Beim Schreiben werden aktiver Scene-State, Runtime-Presets, Runtime-Gruppen, Creative Snapshots und globale Steuerwerte persistiert.
- Fehlende Dateien sind okay.
- Leere, defekte oder schema-inkompatible Persistenzdateien blockieren den Serverstart nicht mehr.
- In diesen Recovery-Faellen startet der Host mit Default-State und erzeugt Warning-/Info-Events im Eventfeed statt abzubrechen.
+61
View 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.
+204
View File
@@ -0,0 +1,204 @@
# Qwen 14B Handoff
## Zweck
Diese Datei ist die schnelle Uebergabe fuer ein kleineres Modell wie Qwen 14B. Sie soll den aktuellen Projektstand, die stabile Architekturgrenze und die naechsten sicheren Arbeitspfade kompakt erklaeren, ohne dass zuerst das ganze Repo rekonstruiert werden muss.
## Aktueller Stand
- Host-Core ist die zentrale Runtime und bleibt die einzige Kernarchitektur.
- API v1 ist die verbindliche Aussenkante fuer State, Preview, Snapshot, Catalog, Commands und Event-Stream.
- Die Creative Surface lebt in `web/v1/` und ist die operatorische Web-Oberflaeche.
- Die Technical Surface in `web/v1/technical.html` plus `web/v1/technical.js` ist die aktuelle technische Web-Oberflaeche.
- Die Desktop-GUI in `crates/infinity_host_ui/` existiert weiter als technische Engineering-/Diagnoseflaeche, ist aber nicht der primaere aktuelle UI-Arbeitspfad.
- Persistenz, Recovery und Runtime-Show-Store sind vorhanden.
- Show-Control-v1-Primitive sind faktisch eingefroren und dokumentiert.
- Ein generischer externer Control-Pfad ist vorhanden, aber bewusst nicht grandMA-spezifisch.
## Frisch umgesetzt in dieser Arbeitsphase
- Creative Surface wurde kompakter gemacht, damit alle 18 Panels im 3x6-Raster gleichzeitig sichtbar bleiben.
- Preview- und normale Pattern-Wirkung wurden diskreter gemacht:
- Preview-Ziel im Host liegt jetzt bei bis zu 60 Hz.
- Normale Preview-/Pattern-Helligkeit ist auf 10%-Stufen quantisiert.
- Preview-Updates und WebSocket-Preview-Frames werden nur noch bei inhaltlicher Aenderung weitergeschoben.
- Die feste WS2812-Palette wurde in Host und Web-UI hinterlegt; alte freie/abweichende Palettennamen wurden ersetzt.
- Globaler Master-Brightness sitzt nur noch im Header; redundante globale Brightness-Bedienung links ist entfernt.
- Die Technical Surface ist robuster gegen unvollstaendige Snapshots, insbesondere fehlende `recent_events`.
- Fuer Codex gibt es jetzt einen lokalen Route-/Browser-Smoke-Runner:
- `scripts/codex_browser_smoke.sh`
- prueft `/`, `/technical` und `/technical.js` gegen eine frische Kurzinstanz
- ersetzt noch keinen vollgerenderten Headless-Browser mit DOM-Interaktion
## Lokale Umgebung auf diesem Rechner
- Arbeitsverzeichnis des Rust-Projekts: `/home/jan/Documents/RFP/Infinity_Vis_Rust`
- Dieses Repo ist hier ein echter Git-Clone mit `.git`.
- Rust-Toolchain ist lokal vorhanden:
- `cargo 1.95.0 (f2d3ce0bd 2026-03-21)`
- `rustc 1.95.0 (59807616e 2026-04-14)`
- Gewuenschte Toolchain laut [rust-toolchain.toml](/home/jan/Documents/RFP/Infinity_Vis_Rust/rust-toolchain.toml:1):
- Channel: `stable`
- Components: `rustfmt`, `clippy`
- Laufzeitpersistenz liegt standardmaessig in [data/runtime_state.json](/home/jan/Documents/RFP/Infinity_Vis_Rust/data/runtime_state.json:1)
- Das alte Python-Referenzprojekt soll lokal daneben liegen unter:
- `/home/jan/Documents/RFP/RFP_Infinity-Vis`
## Lokaler Startpfad
Aus dem Rust-Repo heraus:
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
Danach:
- Creative Surface: `http://127.0.0.1:9001/`
- Technical Surface: `http://127.0.0.1:9001/technical`
- State API: `http://127.0.0.1:9001/api/v1/state`
- Preview API: `http://127.0.0.1:9001/api/v1/preview`
- WebSocket: `ws://127.0.0.1:9001/api/v1/stream`
## Nicht neu interpretieren
Diese Grenzen sind verbindlich:
- keine neue Parallelarchitektur neben Host-Core plus API
- keine direkte grandMA-Kopplung in den Kern
- keine Hardwarevalidierung als Hauptfokus
- Creative Surface bleibt kreative/operatorische Oberflaeche
- Desktop-GUI oder Technical Surface bleibt technische Betriebsoberflaeche
Wenn Verhalten unklar ist, gilt:
1. erst alte Referenz dokumentarisch vergleichen
2. dann an bestehende Host-/API-Semantik anpassen
3. nicht stillschweigend neue Bedienlogik erfinden
## Wo der Arbeitsfortschritt festgehalten ist
Die wichtigsten Fortschrittsanker liegen hier:
- [README.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/README.md:1)
Der Einstieg, grobe Struktur und verlinkte Referenzdokumente.
- [docs/show_control_primitives.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/show_control_primitives.md:1)
Die stabile interne Show-Control-v1-Semantik inklusive staged/direct-Trennung, Fehlercodes und Event-Auswirkungen.
- [docs/pattern_matrix_v1.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/pattern_matrix_v1.md:1)
Alter Python-Bezug versus neue Host-Pattern-IDs, Parameterbasis und UI-Arbeitsmodi.
- [docs/external_control_bridge.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/external_control_bridge.md:1)
Zielbild und Regeln fuer die generische externe Steuerkante.
- [docs/control_ownership.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/control_ownership.md:1)
Konflikt- und Ownership-Regeln zwischen Web-UI, technischer GUI und externen Control-Quellen.
- [docs/local_software_only_runbook.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/local_software_only_runbook.md:1)
Reproduzierbarer software-only Startpfad.
- [docs/codex_worklog.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/codex_worklog.md:1)
Knapper Verlauf der letzten Codex-Arbeitspakete inklusive Tests, Abweichungen und lokalen Smoke-Checks.
- [crates/infinity_host/tests/show_control_v1_golden.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/tests/show_control_v1_golden.rs:1)
Golden-Trace- und Replay-Schutz fuer die zentrale v1-Semantik.
- Git-Historie:
- `a56cecb` `Software-only show-control readiness baseline`
- `07c52db` `Stabilize control surface and external bridge v1`
## Projektstruktur
- `crates/infinity_host/`
Kernruntime, Simulation, Show-Store, Pattern-Logik, externe Control-Semantik.
- `crates/infinity_host_api/`
HTTP- und WebSocket-API v1, DTO-Mapping, Contract-Tests.
- `crates/infinity_host_ui/`
Desktop-Engineering-Oberflaeche fuer technische Konfiguration und Diagnose.
- `crates/infinity_config/`
Projektkonfiguration und Validierung.
- `crates/infinity_protocol/`
Gemeinsame Protokoll- und Modelltypen.
- `web/v1/`
Creative Surface und Technical Surface im Browser.
- `docs/`
Architektur-, Runbook-, Ownership-, Pattern- und API-Dokumentation.
- `scripts/`
Kleine Starthelfer fuer lokalen software-only Betrieb.
- `data/runtime_state.json`
Laufzeitpersistenz fuer Host-State, Runtime-Presets, Gruppen und Snapshots.
## Wichtigste Dateien nach Thema
### Host und Semantik
- [control.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/control.rs:1)
- [simulation.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/simulation.rs:1)
- [show_store.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/show_store.rs:1)
- [scene.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/scene.rs:1)
- [external_control.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/external_control.rs:1)
- [external_bridge.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/external_bridge.rs:1)
### API
- [dto.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/dto.rs:1)
- [server.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/server.rs:1)
- [websocket.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/websocket.rs:1)
- [contract.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/tests/contract.rs:1)
### Web-UI
- [index.html](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/index.html:1)
- [app.js](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/app.js:1)
- [styles.css](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/styles.css:1)
- [technical.html](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.html:1)
- [technical.js](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.js:1)
- [codex_browser_smoke.sh](/home/jan/Documents/RFP/Infinity_Vis_Rust/scripts/codex_browser_smoke.sh:1)
## Was aktuell als stabil gelten soll
- API v1 nach aussen nicht unkoordiniert brechen.
- Show-Control-v1-Primitive nicht still veraendern.
- Fehlercodes und Event-Semantik nicht neu mischen.
- `Test/Edit` bleibt direkt.
- `Show/Event` bleibt staged plus Commit ueber `Go` oder `Fade Go`.
- Preview-Only und Offline-Status ehrlich anzeigen, keine Fake-Nodes.
- Creative Surface nicht mit technischen Mapping-Details ueberladen.
- Technical Surface muss gegen partielle/startende State-Snapshots robust bleiben.
## Sichere Arbeitsreihenfolge fuer weitere Aenderungen
1. Zuerst relevante Docs lesen, nicht direkt Code uminterpretieren.
2. Dann Contract-Tests und Golden-Replays als Schutzplanken ansehen.
3. Danach gezielt nur in der betroffenen Schicht arbeiten:
- Pattern-/Runtime-Logik: `crates/infinity_host/`
- API-Vertrag: `crates/infinity_host_api/`
- Operator-UI: `web/v1/`
4. Danach passende Tests laufen lassen.
## Empfohlene Minimalpruefungen
```bash
. "$HOME/.cargo/env"
cargo test -q -p infinity_host
cargo test -q -p infinity_host_api --test contract
scripts/codex_browser_smoke.sh 9012
```
Fuer lokalen software-only Start:
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
## Offene Vorsichtspunkte
- Das alte Python-Projekt ist derzeit nicht lokal neben dem Repo ausgecheckt. Fuer echte 1:1-Conformance-Vergleiche sollte es bewusst lokal daneben geklont werden.
- Das Runbook enthaelt noch einen veralteten Hinweis aus der frueheren Arbeitsbaum-Phase; aktuell ist das Repo wieder ein echter Git-Clone mit `.git`.
- Die grosse Pattern-Conformance gegen das alte Python-Projekt ist noch nicht vollstaendig als separater, systematischer Abgleich abgeschlossen.
- Ein echter gerenderter Headless-Browser-Runner mit DOM-Interaktion ist lokal noch nicht sauber eingerichtet; vorhanden ist aktuell nur der Route-/Asset-Smoke ueber `scripts/codex_browser_smoke.sh`.
## Kurzbriefing fuer das naechste Modell
Wenn du dieses Projekt weiterbearbeitest:
- halte Host-Core plus API als einzige Grundarchitektur stabil
- behandle die bestehende Show-Control-v1-Semantik als Aussenkante
- nimm das alte Python-Projekt als Primarreferenz fuer UX und Pattern-Verhalten, nicht fuer technische Architektur
- veraendere diskrete UI-Controls, Fehlercodes oder staged/direct-Semantik nicht stillschweigend
- bevor du groessere UI- oder Pattern-Aenderungen machst, vergleiche erst gegen die vorhandenen Docs und Tests
+120
View File
@@ -0,0 +1,120 @@
# Show-Control Primitives
## Ziel
Diese Primitive-Menge ist die kleine, dauerhafte interne Steuersemantik fuer software-only Show-Control. Sie bleibt bewusst generisch:
- keine UI-spezifischen Sonderfaelle
- 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
### `blackout`
- Typ: direkt, mutierend
- Semantik: setzt globalen Blackout an oder aus
- Idempotenz: ja, bezogen auf den Zielzustand
- Fehlercodes: unterliegende Host-Fehler nur bei Persistenzproblemen, typischerweise `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `recall_preset`
- Typ: direkt, mutierend
- Semantik: recalled ein Preset inklusive seiner Zielgruppe und Transition-Metadaten
- Idempotenz: praktisch ja, wenn dasselbe Preset erneut recalled wird
- Fehlercodes: `unknown_preset`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Preset
### `recall_creative_snapshot`
- Typ: direkt, mutierend
- Semantik: recalled einen gespeicherten Creative Snapshot inklusive Scene- und Transition-State
- Idempotenz: praktisch ja, wenn derselbe Snapshot erneut recalled wird
- Fehlercodes: `unknown_creative_snapshot`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Snapshot
### `set_master_brightness`
- Typ: direkt, mutierend
- Semantik: setzt globale Helligkeit, intern auf `0.0..1.0` geklemmt
- Idempotenz: ja, bezogen auf den geklemmten Zielwert
- Fehlercodes: typischerweise nur `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `set_pattern`
- Typ: staged
- Semantik: staged das Pattern fuer die naechste explizite Transition
- Idempotenz: ja, letzter Wert gewinnt
- Fehlercodes: `invalid_pattern_id`
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `set_group_parameter`
- Typ: staged
- Semantik: staged einen Scene-Parameter fuer die naechste Transition und kann optional gleichzeitig die Zielgruppe fuer diese Transition setzen
- Idempotenz: ja, letzter Wert pro Parameter-Key gewinnt
- Fehlercodes: `invalid_group_parameter_key`
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `upsert_group`
- Typ: direkt, mutierend
- Semantik: legt eine Runtime-Gruppe an oder ueberschreibt sie bewusst mit `overwrite: true`
- Idempotenz: ja mit `overwrite: true`, nein mit `overwrite: false`
- Fehlercodes: `invalid_group_id`, `invalid_group_members`, `group_exists`, `persist_failed`
- Event-Auswirkung: Info-Event auf Erfolg
### `set_transition_style`
- Typ: staged
- Semantik: staged Transition-Style und optional Duration fuer die naechste explizite Transition
- Idempotenz: ja, letzter Wert gewinnt
- Fehlercodes: keine zusaetzlichen Primitive-Fehler
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
### `trigger_transition`
- Typ: ausfuehrend, mutierend
- Semantik: materialisiert den aktuell gestagten Transition-Intent in den Host
- Ausfuehrungsreihenfolge:
1. `select_group` nur wenn ueber staged Parameter ein Gruppenkontext gesetzt wurde
2. `set_transition_duration_ms` nur wenn staged
3. `set_transition_style` nur wenn staged
4. `select_pattern`
5. `set_scene_parameter` fuer alle staged Parameter
- Idempotenz: nein, erfolgreicher Trigger konsumiert den gestagten Intent
- Fehlercodes: `transition_pattern_required` plus unterliegende Host-Fehler wie `unknown_group` oder `persist_failed`
- Event-Auswirkung: die unterliegenden Host-Kommandos erzeugen die sichtbaren Info-/Warning-Events
### `request_snapshot`
- Typ: read-only
- Semantik: liefert den aktuellen Host-Snapshot ohne Host-Mutation
- Idempotenz: ja
- Fehlercodes: keine Primitive-eigenen
- Event-Auswirkung: keine
## Hinweis zur Adapter-Nutzung
Die staged Primitive sind fuer externe Show-Control-Adapter gedacht, die absichtlich mehrere kleine Eingaben sammeln und erst mit `trigger_transition` in einen Host-seitigen Uebergang umsetzen. Direkte UI- oder API-Kommandos koennen weiterhin eager bleiben; die stabile interne Adapter-Semantik wird davon nicht aufgeblasen.
Ein stateless Port darf staged Primitive nicht stillschweigend akzeptieren. Wenn `set_pattern`, `set_group_parameter`, `set_transition_style` oder `trigger_transition` ohne Session-/Adapter-Kontext direkt an einem Port landen, ist der erwartete Fehlercode `show_control_session_required`.
## Referenz-Client
Der sehr duenne generische Referenzpfad liegt in `crates/infinity_host/src/external_control.rs`:
- `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
-1
View File
@@ -1,4 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="/home/jan/Documents/RFP/Infinity_Vis_Rust"
PORT="${1:-9011}"
BASE_URL="http://127.0.0.1:${PORT}"
RUNTIME_STATE="/tmp/infinity_vis_runtime_codex_browser_${PORT}.json"
cd "$REPO_DIR"
. "$HOME/.cargo/env"
cargo run -q -p infinity_host_api -- \
--config config/project.example.toml \
--bind "127.0.0.1:${PORT}" \
--runtime-state "$RUNTIME_STATE" &
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 40); do
if curl -fsS "${BASE_URL}/api/v1/state" >/dev/null 2>&1; then
break
fi
sleep 0.25
done
echo "Smoke-checking ${BASE_URL}/"
curl -fsS "${BASE_URL}/" >/tmp/infinity_vis_creative_${PORT}.html >/dev/null
echo "Smoke-checking ${BASE_URL}/technical"
curl -fsS "${BASE_URL}/technical" >/tmp/infinity_vis_technical_${PORT}.html >/dev/null
echo "Smoke-checking ${BASE_URL}/technical.js"
curl -fsS "${BASE_URL}/technical.js" >/tmp/infinity_vis_technical_${PORT}.js >/dev/null
echo "Creative Surface and Technical Surface were served successfully on ${BASE_URL}"
echo "Saved smoke artifacts to /tmp/infinity_vis_creative_${PORT}.html and /tmp/infinity_vis_technical_${PORT}.html"
if command -v xdg-open >/dev/null 2>&1; then
echo "Optional manual open:"
echo " xdg-open ${BASE_URL}/"
echo " xdg-open ${BASE_URL}/technical"
fi
+49
View 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"
+2209 -411
View File
File diff suppressed because it is too large Load Diff
+232 -152
View File
@@ -3,190 +3,270 @@
<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>
<label class="toolbar-control toolbar-control-inline">
<span class="toolbar-label">Master</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
<strong id="brightness-value">0%</strong>
</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 &amp; 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>
<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>
</aside>
<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 &amp; 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>
</label>
<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>
<label class="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 class="field">
<span>Blackout</span>
<button id="blackout-button" class="danger-button" type="button">
Enable blackout
<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>Presets</h3>
<p>Recall look snapshots without leaving the creative console.</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 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 &amp; 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>
+991 -474
View File
File diff suppressed because it is too large Load Diff
+206
View 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 &amp; 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>
+1003
View File
File diff suppressed because it is too large Load Diff