Compare commits
5 Commits
main
..
d2ca1a2aef
| Author | SHA1 | Date | |
|---|---|---|---|
| d2ca1a2aef | |||
| ed1e4b49ab | |||
| 202b86aa72 | |||
| 07c52db5fb | |||
| a56cecb23d |
-10
@@ -1,11 +1 @@
|
|||||||
/target/
|
/target/
|
||||||
/build/
|
|
||||||
/.idea/
|
|
||||||
/.vscode/
|
|
||||||
*.swp
|
|
||||||
*.tmp
|
|
||||||
*.log
|
|
||||||
sdkconfig
|
|
||||||
sdkconfig.old
|
|
||||||
firmware/esp32_node/build/
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
|
|||||||
|
|
||||||
- [Architecture](docs/architecture.md)
|
- [Architecture](docs/architecture.md)
|
||||||
- [Host API](docs/host_api.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)
|
- [Protocol](docs/protocol.md)
|
||||||
- [Config Schema](docs/config_schema.md)
|
- [Config Schema](docs/config_schema.md)
|
||||||
- [Build and Deploy](docs/build_and_deploy.md)
|
- [Build and Deploy](docs/build_and_deploy.md)
|
||||||
|
|||||||
@@ -24,4 +24,3 @@ pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, P
|
|||||||
let raw = fs::read_to_string(path)?;
|
let raw = fs::read_to_string(path)?;
|
||||||
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
|
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,11 @@ impl ProjectConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
|
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() {
|
for (index, profile) in self.safety_profiles.iter().enumerate() {
|
||||||
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
|
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
|
||||||
report.push(
|
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(
|
report.push(
|
||||||
ValidationSeverity::Error,
|
ValidationSeverity::Error,
|
||||||
"invalid_start_brightness",
|
"invalid_start_brightness",
|
||||||
@@ -511,7 +516,11 @@ mod tests {
|
|||||||
SceneConfig, TopologyConfig, TransportMode, TransportProfileConfig,
|
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 {
|
PanelOutputConfig {
|
||||||
panel_position: position,
|
panel_position: position,
|
||||||
physical_output_name: label.to_string(),
|
physical_output_name: label.to_string(),
|
||||||
@@ -630,8 +639,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_duplicate_driver_refs() {
|
fn rejects_duplicate_driver_refs() {
|
||||||
let mut project = build_project();
|
let mut project = build_project();
|
||||||
project.topology.nodes[0].outputs[1].driver_channel.reference =
|
project.topology.nodes[0].outputs[1]
|
||||||
project.topology.nodes[0].outputs[0].driver_channel.reference.clone();
|
.driver_channel
|
||||||
|
.reference = project.topology.nodes[0].outputs[0]
|
||||||
|
.driver_channel
|
||||||
|
.reference
|
||||||
|
.clone();
|
||||||
let report = project.validate(ValidationMode::Structural);
|
let report = project.validate(ValidationMode::Structural);
|
||||||
assert!(!report.is_ok());
|
assert!(!report.is_ok());
|
||||||
assert!(report
|
assert!(report
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const HOST_API_VERSION: u16 = 1;
|
pub const HOST_API_VERSION: u16 = 1;
|
||||||
@@ -9,6 +9,7 @@ pub struct HostSnapshot {
|
|||||||
pub backend_label: String,
|
pub backend_label: String,
|
||||||
pub generated_at_millis: u64,
|
pub generated_at_millis: u64,
|
||||||
pub system: SystemSnapshot,
|
pub system: SystemSnapshot,
|
||||||
|
pub technical: TechnicalSnapshot,
|
||||||
pub global: GlobalControlSnapshot,
|
pub global: GlobalControlSnapshot,
|
||||||
pub engine: EngineSnapshot,
|
pub engine: EngineSnapshot,
|
||||||
pub catalog: CatalogSnapshot,
|
pub catalog: CatalogSnapshot,
|
||||||
@@ -27,6 +28,21 @@ pub struct SystemSnapshot {
|
|||||||
pub topology_label: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct GlobalControlSnapshot {
|
pub struct GlobalControlSnapshot {
|
||||||
pub blackout: bool,
|
pub blackout: bool,
|
||||||
@@ -204,6 +220,7 @@ pub struct NodeSnapshot {
|
|||||||
pub struct PanelSnapshot {
|
pub struct PanelSnapshot {
|
||||||
pub target: PanelTarget,
|
pub target: PanelTarget,
|
||||||
pub physical_output_name: String,
|
pub physical_output_name: String,
|
||||||
|
pub driver_kind: DriverKind,
|
||||||
pub driver_reference: String,
|
pub driver_reference: String,
|
||||||
pub led_count: u16,
|
pub led_count: u16,
|
||||||
pub direction: LedDirection,
|
pub direction: LedDirection,
|
||||||
@@ -248,6 +265,23 @@ pub struct PanelTarget {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
|
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
|
||||||
pub enum HostCommand {
|
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),
|
SetBlackout(bool),
|
||||||
SetMasterBrightness(f32),
|
SetMasterBrightness(f32),
|
||||||
SelectPattern(String),
|
SelectPattern(String),
|
||||||
@@ -271,6 +305,9 @@ pub enum HostCommand {
|
|||||||
preset_id: String,
|
preset_id: String,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
},
|
},
|
||||||
|
DeletePreset {
|
||||||
|
preset_id: String,
|
||||||
|
},
|
||||||
SaveCreativeSnapshot {
|
SaveCreativeSnapshot {
|
||||||
snapshot_id: String,
|
snapshot_id: String,
|
||||||
label: Option<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 {
|
impl CatalogSource {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
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> {
|
pub fn as_toggle(&self) -> Option<bool> {
|
||||||
match self {
|
match self {
|
||||||
Self::Toggle(value) => Some(*value),
|
Self::Toggle(value) => Some(*value),
|
||||||
|
|||||||
@@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,68 +3,110 @@ use crate::{
|
|||||||
SceneParameterValue, SceneTransitionStyle,
|
SceneParameterValue, SceneTransitionStyle,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub const SHOW_CONTROL_V1_VERSION: &str = "v1";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case", tag = "action", content = "payload")]
|
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
|
||||||
pub enum ExternalControlAction {
|
pub enum ShowControlPrimitive {
|
||||||
SetBlackout {
|
Blackout {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
},
|
},
|
||||||
SetMasterBrightness {
|
|
||||||
value: f32,
|
|
||||||
},
|
|
||||||
SelectPattern {
|
|
||||||
pattern_id: String,
|
|
||||||
},
|
|
||||||
RecallPreset {
|
RecallPreset {
|
||||||
preset_id: String,
|
preset_id: String,
|
||||||
},
|
},
|
||||||
SelectGroup {
|
|
||||||
group_id: Option<String>,
|
|
||||||
},
|
|
||||||
SetSceneParameter {
|
|
||||||
key: String,
|
|
||||||
value: SceneParameterValue,
|
|
||||||
},
|
|
||||||
SetTransitionConfig {
|
|
||||||
duration_ms: u32,
|
|
||||||
style: SceneTransitionStyle,
|
|
||||||
},
|
|
||||||
RecallCreativeSnapshot {
|
RecallCreativeSnapshot {
|
||||||
snapshot_id: String,
|
snapshot_id: String,
|
||||||
},
|
},
|
||||||
TriggerPanelTest {
|
SetMasterBrightness {
|
||||||
target: PanelTarget,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ExternalAdapterCapabilities {
|
pub struct ExternalAdapterCapabilities {
|
||||||
pub supports_group_targeting: bool,
|
pub supports_blackout: bool,
|
||||||
pub supports_preset_recall: bool,
|
pub supports_preset_recall: bool,
|
||||||
pub supports_parameter_updates: bool,
|
pub supports_creative_snapshot_recall: bool,
|
||||||
pub supports_transition_config: bool,
|
pub supports_master_brightness: bool,
|
||||||
pub supports_panel_tests: 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 {
|
impl Default for ExternalAdapterCapabilities {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
supports_group_targeting: true,
|
supports_blackout: true,
|
||||||
supports_preset_recall: true,
|
supports_preset_recall: true,
|
||||||
supports_parameter_updates: true,
|
supports_creative_snapshot_recall: true,
|
||||||
supports_transition_config: true,
|
supports_master_brightness: true,
|
||||||
supports_panel_tests: false,
|
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 {
|
pub trait ExternalShowControlPort: Send + Sync {
|
||||||
fn snapshot(&self) -> HostSnapshot;
|
fn snapshot(&self) -> HostSnapshot;
|
||||||
fn execute_action(
|
fn execute_primitive(
|
||||||
&self,
|
&self,
|
||||||
action: ExternalControlAction,
|
primitive: ShowControlPrimitive,
|
||||||
) -> Result<CommandOutcome, HostCommandError>;
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ExternalShowControlAdapter: Send {
|
pub trait ExternalShowControlAdapter: Send {
|
||||||
@@ -72,10 +114,169 @@ pub trait ExternalShowControlAdapter: Send {
|
|||||||
fn capabilities(&self) -> ExternalAdapterCapabilities {
|
fn capabilities(&self) -> ExternalAdapterCapabilities {
|
||||||
ExternalAdapterCapabilities::default()
|
ExternalAdapterCapabilities::default()
|
||||||
}
|
}
|
||||||
fn translate(
|
fn apply_primitive(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: ExternalControlAction,
|
port: &dyn HostApiPort,
|
||||||
) -> Result<Vec<HostCommand>, HostCommandError>;
|
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 {
|
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
|
||||||
@@ -83,42 +284,451 @@ impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
|
|||||||
HostApiPort::snapshot(self)
|
HostApiPort::snapshot(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_action(
|
fn execute_primitive(
|
||||||
&self,
|
&self,
|
||||||
action: ExternalControlAction,
|
primitive: ShowControlPrimitive,
|
||||||
) -> Result<CommandOutcome, HostCommandError> {
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
match action {
|
match primitive {
|
||||||
ExternalControlAction::SetBlackout { enabled } => {
|
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
self.send_command(HostCommand::SetBlackout(enabled))
|
self.send_command(HostCommand::SetBlackout(enabled))?,
|
||||||
|
)),
|
||||||
|
ShowControlPrimitive::RecallPreset { preset_id } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
self.send_command(HostCommand::RecallPreset { preset_id })?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
ExternalControlAction::SetMasterBrightness { value } => {
|
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
|
||||||
self.send_command(HostCommand::SetMasterBrightness(value))
|
Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
|
||||||
|
HostCommand::RecallCreativeSnapshot { snapshot_id },
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
ExternalControlAction::SelectPattern { pattern_id } => {
|
ShowControlPrimitive::SetMasterBrightness { value } => {
|
||||||
self.send_command(HostCommand::SelectPattern(pattern_id))
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
self.send_command(HostCommand::SetMasterBrightness(value))?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
ExternalControlAction::RecallPreset { preset_id } => {
|
ShowControlPrimitive::UpsertGroup {
|
||||||
self.send_command(HostCommand::RecallPreset { preset_id })
|
group_id,
|
||||||
}
|
tags,
|
||||||
ExternalControlAction::SelectGroup { group_id } => {
|
members,
|
||||||
self.send_command(HostCommand::SelectGroup { group_id })
|
overwrite,
|
||||||
}
|
} => Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
|
||||||
ExternalControlAction::SetSceneParameter { key, value } => {
|
HostCommand::UpsertGroup {
|
||||||
self.send_command(HostCommand::SetSceneParameter { key, value })
|
group_id,
|
||||||
}
|
tags,
|
||||||
ExternalControlAction::SetTransitionConfig { duration_ms, style } => {
|
members,
|
||||||
self.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
|
overwrite,
|
||||||
self.send_command(HostCommand::SetTransitionStyle(style))
|
},
|
||||||
}
|
)?)),
|
||||||
ExternalControlAction::RecallCreativeSnapshot { snapshot_id } => {
|
ShowControlPrimitive::RequestSnapshot => {
|
||||||
self.send_command(HostCommand::RecallCreativeSnapshot { snapshot_id })
|
Ok(ShowControlPrimitiveOutcome::Snapshot(self.snapshot()))
|
||||||
}
|
|
||||||
ExternalControlAction::TriggerPanelTest { target } => {
|
|
||||||
self.send_command(HostCommand::TriggerPanelTest {
|
|
||||||
target,
|
|
||||||
pattern: crate::TestPatternKind::WalkingPixel106,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
|
pub mod external_bridge;
|
||||||
|
pub mod external_control;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod show_store;
|
pub mod show_store;
|
||||||
pub mod simulation;
|
pub mod simulation;
|
||||||
pub mod external_control;
|
|
||||||
|
|
||||||
pub use control::*;
|
pub use control::*;
|
||||||
|
pub use external_bridge::*;
|
||||||
pub use external_control::*;
|
pub use external_control::*;
|
||||||
pub use runtime::*;
|
pub use runtime::*;
|
||||||
pub use scene::*;
|
pub use scene::*;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use clap::{Parser, Subcommand, ValueEnum};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
|
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
|
||||||
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
|
use infinity_host::{ExternalControlBridge, HostApiPort, RealtimeEngine, SimulationHostService};
|
||||||
use std::{path::PathBuf, process::ExitCode};
|
use std::{io, path::PathBuf, process::ExitCode, sync::Arc};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[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 {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -28,6 +32,12 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
config: PathBuf,
|
config: PathBuf,
|
||||||
},
|
},
|
||||||
|
ExternalControlBridge {
|
||||||
|
#[arg(long)]
|
||||||
|
config: PathBuf,
|
||||||
|
#[arg(long, default_value = "data/runtime_state.json")]
|
||||||
|
runtime_state: PathBuf,
|
||||||
|
},
|
||||||
OpenValidationPoints,
|
OpenValidationPoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +62,10 @@ fn main() -> ExitCode {
|
|||||||
Command::Validate { config, mode } => validate_command(config, mode),
|
Command::Validate { config, mode } => validate_command(config, mode),
|
||||||
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
|
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
|
||||||
Command::Snapshot { config } => snapshot_command(config),
|
Command::Snapshot { config } => snapshot_command(config),
|
||||||
|
Command::ExternalControlBridge {
|
||||||
|
config,
|
||||||
|
runtime_state,
|
||||||
|
} => external_control_bridge_command(config, runtime_state),
|
||||||
Command::OpenValidationPoints => {
|
Command::OpenValidationPoints => {
|
||||||
print_open_validation_points();
|
print_open_validation_points();
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
@@ -80,7 +94,10 @@ fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
|
|||||||
ValidationSeverity::Warning => "WARN",
|
ValidationSeverity::Warning => "WARN",
|
||||||
ValidationSeverity::Error => "ERROR",
|
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() {
|
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() {
|
fn print_open_validation_points() {
|
||||||
for line in [
|
for line in [
|
||||||
"Pending hardware validation gates:",
|
"Pending hardware validation gates:",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ impl Default for TickSchedule {
|
|||||||
logic_hz: 120,
|
logic_hz: 120,
|
||||||
frame_synthesis_hz: 60,
|
frame_synthesis_hz: 60,
|
||||||
network_send_hz: 60,
|
network_send_hz: 60,
|
||||||
preview_hz: 15,
|
preview_hz: 60,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,21 +48,29 @@ impl Default for RealtimeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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);
|
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 {
|
report.issues.push(ValidationIssue {
|
||||||
severity: ValidationSeverity::Warning,
|
severity: ValidationSeverity::Warning,
|
||||||
code: "preview_rate_too_high",
|
code: "preview_rate_too_high",
|
||||||
path: "runtime.schedule.preview_hz".to_string(),
|
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
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
|
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();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1527
-162
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
control::{
|
control::{
|
||||||
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -59,16 +59,64 @@ impl Default for PersistedGlobalState {
|
|||||||
Self {
|
Self {
|
||||||
blackout: false,
|
blackout: false,
|
||||||
master_brightness: 0.20,
|
master_brightness: 0.20,
|
||||||
transition_duration_ms: 150,
|
transition_duration_ms: 2_000,
|
||||||
transition_style: SceneTransitionStyle::Crossfade,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
pub struct PersistedRuntimeState {
|
pub struct PersistedRuntimeState {
|
||||||
pub active_scene: Option<SceneRuntime>,
|
pub active_scene: Option<SceneRuntime>,
|
||||||
pub global: PersistedGlobalState,
|
pub global: PersistedGlobalState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub technical: PersistedTechnicalState,
|
||||||
pub user_presets: Vec<StoredPreset>,
|
pub user_presets: Vec<StoredPreset>,
|
||||||
pub user_groups: Vec<StoredGroup>,
|
pub user_groups: Vec<StoredGroup>,
|
||||||
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
|
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
|
||||||
@@ -86,6 +134,19 @@ pub struct RuntimeStateStorage {
|
|||||||
path: PathBuf,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ShowStoreError {
|
pub enum ShowStoreError {
|
||||||
#[error("runtime state I/O failed: {0}")]
|
#[error("runtime state I/O failed: {0}")]
|
||||||
@@ -118,16 +179,86 @@ impl RuntimeStateStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let raw = fs::read_to_string(&self.path)?;
|
let raw = fs::read_to_string(&self.path)?;
|
||||||
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(&raw)?;
|
parse_runtime_state(&raw, &self.path)
|
||||||
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()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(envelope.runtime)
|
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
|
||||||
|
if !self.path.exists() {
|
||||||
|
return RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
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 {
|
impl ShowStore {
|
||||||
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
|
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
|
||||||
let presets = project
|
let presets = project
|
||||||
@@ -155,7 +308,9 @@ impl ShowStore {
|
|||||||
preset_id: preset.preset_id.clone(),
|
preset_id: preset.preset_id.clone(),
|
||||||
scene: registry.scene_from_preset_config(preset),
|
scene: registry.scene_from_preset_config(preset),
|
||||||
transition_duration_ms: preset.transition_ms,
|
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,
|
source: CatalogSource::BuiltIn,
|
||||||
updated_at_unix_ms: None,
|
updated_at_unix_ms: None,
|
||||||
})
|
})
|
||||||
@@ -283,10 +438,7 @@ impl ShowStore {
|
|||||||
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
|
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recall_creative_snapshot(
|
pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option<StoredCreativeSnapshot> {
|
||||||
&self,
|
|
||||||
snapshot_id: &str,
|
|
||||||
) -> Option<StoredCreativeSnapshot> {
|
|
||||||
self.creative_snapshots
|
self.creative_snapshots
|
||||||
.iter()
|
.iter()
|
||||||
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
|
.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 {
|
if !overwrite {
|
||||||
return Err(HostCommandError::new(
|
return Err(HostCommandError::new(
|
||||||
"preset_exists",
|
"preset_exists",
|
||||||
@@ -359,6 +515,36 @@ impl ShowStore {
|
|||||||
Ok(())
|
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(
|
pub fn save_creative_snapshot(
|
||||||
&mut self,
|
&mut self,
|
||||||
snapshot_id: &str,
|
snapshot_id: &str,
|
||||||
@@ -446,10 +632,12 @@ impl ShowStore {
|
|||||||
&self,
|
&self,
|
||||||
active_scene: &SceneRuntime,
|
active_scene: &SceneRuntime,
|
||||||
global: PersistedGlobalState,
|
global: PersistedGlobalState,
|
||||||
|
technical: PersistedTechnicalState,
|
||||||
) -> PersistedRuntimeState {
|
) -> PersistedRuntimeState {
|
||||||
PersistedRuntimeState {
|
PersistedRuntimeState {
|
||||||
active_scene: Some(active_scene.clone()),
|
active_scene: Some(active_scene.clone()),
|
||||||
global,
|
global,
|
||||||
|
technical,
|
||||||
user_presets: self
|
user_presets: self
|
||||||
.presets
|
.presets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -512,7 +700,10 @@ mod tests {
|
|||||||
.presets
|
.presets
|
||||||
.iter()
|
.iter()
|
||||||
.any(|preset| preset.preset_id == "ocean_gradient"));
|
.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]
|
#[test]
|
||||||
@@ -552,6 +743,45 @@ mod tests {
|
|||||||
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_presets_can_be_deleted_but_builtins_cannot() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let scene = registry.scene_for_pattern(
|
||||||
|
"noise",
|
||||||
|
None,
|
||||||
|
Some("top_panels".to_string()),
|
||||||
|
31,
|
||||||
|
vec!["#AA8844".to_string()],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_preset_from_scene(
|
||||||
|
"runtime_delete_me",
|
||||||
|
&scene,
|
||||||
|
210,
|
||||||
|
SceneTransitionStyle::Crossfade,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("runtime preset save should succeed");
|
||||||
|
store
|
||||||
|
.delete_preset("runtime_delete_me")
|
||||||
|
.expect("runtime preset delete should succeed");
|
||||||
|
assert!(store.scene_from_preset_id("runtime_delete_me").is_none());
|
||||||
|
|
||||||
|
let built_in_preset_id = store
|
||||||
|
.catalog(®istry)
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.source == CatalogSource::BuiltIn)
|
||||||
|
.map(|preset| preset.preset_id.clone())
|
||||||
|
.expect("sample project should contain built-in presets");
|
||||||
|
let delete_error = store
|
||||||
|
.delete_preset(&built_in_preset_id)
|
||||||
|
.expect_err("built-in presets should not be deletable");
|
||||||
|
assert_eq!(delete_error.code, "preset_delete_forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
||||||
let registry = PatternRegistry::new();
|
let registry = PatternRegistry::new();
|
||||||
@@ -590,6 +820,7 @@ mod tests {
|
|||||||
transition_duration_ms: 220,
|
transition_duration_ms: 220,
|
||||||
transition_style: SceneTransitionStyle::Chase,
|
transition_style: SceneTransitionStyle::Chase,
|
||||||
},
|
},
|
||||||
|
PersistedTechnicalState::default(),
|
||||||
);
|
);
|
||||||
storage.save(&runtime).expect("save should work");
|
storage.save(&runtime).expect("save should work");
|
||||||
let loaded = storage.load().expect("load should work");
|
let loaded = storage.load().expect("load should work");
|
||||||
@@ -602,4 +833,89 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(path);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
control::{
|
control::{
|
||||||
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
|
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
|
||||||
HostCommand, HostCommandError, HostSnapshot, HOST_API_VERSION, NodeConnectionState,
|
HostCommand, HostCommandError, HostSnapshot, NodeConnectionState, NodeSnapshot,
|
||||||
NodeSnapshot, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
|
OutputBackendMode, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
|
||||||
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot,
|
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot, TechnicalSnapshot,
|
||||||
|
HOST_API_VERSION,
|
||||||
},
|
},
|
||||||
runtime::TickSchedule,
|
runtime::TickSchedule,
|
||||||
scene::{
|
scene::{
|
||||||
@@ -11,7 +12,8 @@ use crate::{
|
|||||||
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
|
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
|
||||||
},
|
},
|
||||||
show_store::{
|
show_store::{
|
||||||
PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError,
|
PersistedGlobalState, PersistedNodeState, PersistedPanelState, PersistedTechnicalState,
|
||||||
|
RuntimeStateStorage, ShowStore, ShowStoreError,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use infinity_config::{PanelPosition, ProjectConfig};
|
use infinity_config::{PanelPosition, ProjectConfig};
|
||||||
@@ -45,6 +47,7 @@ struct SimulationState {
|
|||||||
schedule: TickSchedule,
|
schedule: TickSchedule,
|
||||||
current_scene: SceneRuntime,
|
current_scene: SceneRuntime,
|
||||||
active_transition: Option<TransitionRuntime>,
|
active_transition: Option<TransitionRuntime>,
|
||||||
|
technical_state: PersistedTechnicalState,
|
||||||
snapshot: HostSnapshot,
|
snapshot: HostSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,19 +126,31 @@ impl SimulationState {
|
|||||||
) -> Result<Self, ShowStoreError> {
|
) -> Result<Self, ShowStoreError> {
|
||||||
let registry = PatternRegistry::new();
|
let registry = PatternRegistry::new();
|
||||||
let mut show_store = ShowStore::from_project(&project, ®istry);
|
let mut show_store = ShowStore::from_project(&project, ®istry);
|
||||||
let persisted_runtime = if let Some(storage) = &runtime_storage {
|
let runtime_load = if let Some(storage) = &runtime_storage {
|
||||||
storage.load()?
|
storage.load_with_recovery()
|
||||||
} else {
|
} 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_scene = persisted_runtime.active_scene.clone();
|
||||||
let restored_global = persisted_runtime.global.clone();
|
let restored_global = persisted_runtime.global.clone();
|
||||||
|
let restored_technical = persisted_runtime.technical.clone();
|
||||||
show_store.apply_persisted(persisted_runtime);
|
show_store.apply_persisted(persisted_runtime);
|
||||||
let group_members = show_store.group_members_map();
|
let group_members = show_store.group_members_map();
|
||||||
let schedule = TickSchedule::default();
|
let schedule = TickSchedule::default();
|
||||||
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry));
|
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry));
|
||||||
let catalog = show_store.catalog(®istry);
|
let catalog = show_store.catalog(®istry);
|
||||||
let available_patterns = show_store.available_patterns(®istry);
|
let available_patterns = show_store.available_patterns(®istry);
|
||||||
|
let initial_offline_status = offline_status_message(
|
||||||
|
restored_technical.backend_mode,
|
||||||
|
restored_technical.output_enabled,
|
||||||
|
);
|
||||||
let nodes = project
|
let nodes = project
|
||||||
.topology
|
.topology
|
||||||
.nodes
|
.nodes
|
||||||
@@ -144,9 +159,9 @@ impl SimulationState {
|
|||||||
node_id: node.node_id.clone(),
|
node_id: node.node_id.clone(),
|
||||||
display_name: node.display_name.clone(),
|
display_name: node.display_name.clone(),
|
||||||
reserved_ip: node.network.reserved_ip.clone(),
|
reserved_ip: node.network.reserved_ip.clone(),
|
||||||
connection: NodeConnectionState::Online,
|
connection: NodeConnectionState::Offline,
|
||||||
last_contact_ms: 10,
|
last_contact_ms: 0,
|
||||||
error_status: None,
|
error_status: Some(initial_offline_status.clone()),
|
||||||
panel_count: node.outputs.len(),
|
panel_count: node.outputs.len(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -155,21 +170,23 @@ impl SimulationState {
|
|||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|node| {
|
.flat_map(|node| {
|
||||||
|
let initial_offline_status = initial_offline_status.clone();
|
||||||
node.outputs.iter().map(move |output| PanelSnapshot {
|
node.outputs.iter().map(move |output| PanelSnapshot {
|
||||||
target: PanelTarget {
|
target: PanelTarget {
|
||||||
node_id: node.node_id.clone(),
|
node_id: node.node_id.clone(),
|
||||||
panel_position: output.panel_position.clone(),
|
panel_position: output.panel_position.clone(),
|
||||||
},
|
},
|
||||||
physical_output_name: output.physical_output_name.clone(),
|
physical_output_name: output.physical_output_name.clone(),
|
||||||
|
driver_kind: output.driver_channel.kind.clone(),
|
||||||
driver_reference: output.driver_channel.reference.clone(),
|
driver_reference: output.driver_channel.reference.clone(),
|
||||||
led_count: output.led_count,
|
led_count: output.led_count,
|
||||||
direction: output.direction.clone(),
|
direction: output.direction.clone(),
|
||||||
color_order: output.color_order.clone(),
|
color_order: output.color_order.clone(),
|
||||||
enabled: output.enabled,
|
enabled: output.enabled,
|
||||||
validation_state: output.validation_state.clone(),
|
validation_state: output.validation_state.clone(),
|
||||||
connection: NodeConnectionState::Online,
|
connection: NodeConnectionState::Offline,
|
||||||
last_test_trigger_ms: None,
|
last_test_trigger_ms: None,
|
||||||
error_status: None,
|
error_status: Some(initial_offline_status.clone()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -188,15 +205,22 @@ impl SimulationState {
|
|||||||
schedule: schedule.clone(),
|
schedule: schedule.clone(),
|
||||||
current_scene,
|
current_scene,
|
||||||
active_transition: None,
|
active_transition: None,
|
||||||
|
technical_state: restored_technical.clone(),
|
||||||
snapshot: HostSnapshot {
|
snapshot: HostSnapshot {
|
||||||
api_version: HOST_API_VERSION,
|
api_version: HOST_API_VERSION,
|
||||||
backend_label: "simulation-core".to_string(),
|
backend_label: "preview-only simulation".to_string(),
|
||||||
generated_at_millis: 0,
|
generated_at_millis: 0,
|
||||||
system: SystemSnapshot {
|
system: SystemSnapshot {
|
||||||
project_name: project.metadata.project_name.clone(),
|
project_name: project.metadata.project_name.clone(),
|
||||||
schema_version: project.metadata.schema_version,
|
schema_version: project.metadata.schema_version,
|
||||||
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
|
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 {
|
global: GlobalControlSnapshot {
|
||||||
blackout: restored_global.blackout,
|
blackout: restored_global.blackout,
|
||||||
master_brightness: restored_global.master_brightness,
|
master_brightness: restored_global.master_brightness,
|
||||||
@@ -231,15 +255,48 @@ impl SimulationState {
|
|||||||
recent_events: Vec::new(),
|
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_pattern = state.current_scene.pattern_id.clone();
|
||||||
state.snapshot.global.selected_group = state.current_scene.target_group.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.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 {
|
||||||
if state.runtime_storage.is_some() {
|
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(
|
state.push_event(
|
||||||
StatusEventKind::Info,
|
StatusEventKind::Info,
|
||||||
Some("runtime_state_restored".to_string()),
|
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(),
|
"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::Warning,
|
||||||
|
Some(warning.code),
|
||||||
|
warning.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.simulate_tick();
|
state.simulate_tick();
|
||||||
@@ -261,10 +318,11 @@ impl SimulationState {
|
|||||||
self.update_node_states();
|
self.update_node_states();
|
||||||
self.update_panel_states();
|
self.update_panel_states();
|
||||||
self.resolve_transition_if_complete();
|
self.resolve_transition_if_complete();
|
||||||
self.snapshot.engine.active_transition = self
|
self.snapshot.engine.active_transition =
|
||||||
.active_transition
|
self.active_transition.as_ref().map(|transition| {
|
||||||
.as_ref()
|
self.registry
|
||||||
.map(|transition| self.registry.transition_snapshot(&self.current_scene, transition));
|
.transition_snapshot(&self.current_scene, transition)
|
||||||
|
});
|
||||||
self.snapshot.active_scene = self.registry.active_scene_snapshot(&self.current_scene);
|
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_pattern = self.current_scene.pattern_id.clone();
|
||||||
self.snapshot.global.selected_group = self.current_scene.target_group.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> {
|
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
|
||||||
let mut should_persist = false;
|
let mut should_persist = false;
|
||||||
let summary = match command {
|
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) => {
|
HostCommand::SetBlackout(enabled) => {
|
||||||
self.snapshot.global.blackout = enabled;
|
self.snapshot.global.blackout = enabled;
|
||||||
should_persist = true;
|
should_persist = true;
|
||||||
@@ -305,13 +502,9 @@ impl SimulationState {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
self.next_seed += 1;
|
self.next_seed += 1;
|
||||||
|
for (key, value) in self.current_scene.parameters.clone() {
|
||||||
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() {
|
|
||||||
self.registry
|
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;
|
let duration_ms = self.snapshot.global.transition_duration_ms;
|
||||||
@@ -454,6 +647,18 @@ impl SimulationState {
|
|||||||
self.push_event(StatusEventKind::Info, None, summary.clone());
|
self.push_event(StatusEventKind::Info, None, summary.clone());
|
||||||
summary
|
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 {
|
HostCommand::SaveCreativeSnapshot {
|
||||||
snapshot_id,
|
snapshot_id,
|
||||||
label,
|
label,
|
||||||
@@ -563,48 +768,70 @@ impl SimulationState {
|
|||||||
|
|
||||||
if finished {
|
if finished {
|
||||||
self.active_transition = None;
|
self.active_transition = None;
|
||||||
self.push_event(StatusEventKind::Info, None, format!(
|
self.push_event(
|
||||||
"transition completed to {}",
|
StatusEventKind::Info,
|
||||||
self.current_scene.pattern_id
|
None,
|
||||||
));
|
format!("transition completed to {}", self.current_scene.pattern_id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
node.reserved_ip = node_state.reserved_ip.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
fn update_node_states(&mut self) {
|
||||||
let previous_states: BTreeMap<_, _> = self
|
let elapsed_ms = self.elapsed_millis();
|
||||||
.snapshot
|
let offline_status = offline_status_message(
|
||||||
.nodes
|
self.technical_state.backend_mode,
|
||||||
.iter()
|
self.technical_state.output_enabled,
|
||||||
.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
|
|
||||||
{
|
|
||||||
transition_messages.push(format!(
|
|
||||||
"{} is now {}",
|
|
||||||
node.display_name,
|
|
||||||
connection.label()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for message in transition_messages {
|
|
||||||
self.push_event(
|
|
||||||
StatusEventKind::Warning,
|
|
||||||
Some("node_connection_state".to_string()),
|
|
||||||
message,
|
|
||||||
);
|
);
|
||||||
|
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) {
|
fn update_panel_states(&mut self) {
|
||||||
@@ -612,7 +839,12 @@ impl SimulationState {
|
|||||||
.snapshot
|
.snapshot
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
for panel in &mut self.snapshot.panels {
|
for panel in &mut self.snapshot.panels {
|
||||||
@@ -621,7 +853,10 @@ impl SimulationState {
|
|||||||
panel.error_status = match (node_error, panel.enabled) {
|
panel.error_status = match (node_error, panel.enabled) {
|
||||||
(_, false) => Some("output disabled".to_string()),
|
(_, false) => Some("output disabled".to_string()),
|
||||||
(Some(error), _) => Some(error.clone()),
|
(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(
|
fn render_preview_for_panel(
|
||||||
&self,
|
&self,
|
||||||
panel: &PanelSnapshot,
|
panel: &PanelSnapshot,
|
||||||
panel_index: usize,
|
_panel_index: usize,
|
||||||
elapsed_ms: u64,
|
elapsed_ms: u64,
|
||||||
) -> (RenderedPreview, PreviewSource) {
|
) -> (RenderedPreview, PreviewSource) {
|
||||||
if self.snapshot.global.blackout || self.current_scene.blackout || !panel.enabled {
|
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 panel_row = match panel.target.panel_position {
|
||||||
let current = self
|
PanelPosition::Top => 0,
|
||||||
.registry
|
PanelPosition::Middle => 1,
|
||||||
.render_preview(&self.current_scene, panel_index, panel_count, elapsed_ms);
|
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 source = PreviewSource::Scene;
|
||||||
let mut preview = if let Some(transition) = &self.active_transition {
|
let mut preview = if let Some(transition) = &self.active_transition {
|
||||||
let from = self
|
let from = self.registry.render_preview(
|
||||||
.registry
|
&transition.from_scene,
|
||||||
.render_preview(&transition.from_scene, panel_index, panel_count, elapsed_ms);
|
panel_row,
|
||||||
|
panel_col,
|
||||||
|
panel_rows,
|
||||||
|
panel_cols,
|
||||||
|
led_count,
|
||||||
|
elapsed_ms,
|
||||||
|
);
|
||||||
let progress = self
|
let progress = self
|
||||||
.registry
|
.registry
|
||||||
.transition_snapshot(&self.current_scene, transition)
|
.transition_snapshot(&self.current_scene, transition)
|
||||||
@@ -698,7 +958,10 @@ impl SimulationState {
|
|||||||
preview = apply_group_gate(&preview, active_in_group);
|
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) {
|
fn rebuild_catalog(&mut self) {
|
||||||
@@ -720,9 +983,11 @@ impl SimulationState {
|
|||||||
};
|
};
|
||||||
let storage_path = storage.path().to_path_buf();
|
let storage_path = storage.path().to_path_buf();
|
||||||
|
|
||||||
let runtime_state = self
|
let runtime_state = self.show_store.persisted_runtime(
|
||||||
.show_store
|
&self.current_scene,
|
||||||
.persisted_runtime(&self.current_scene, self.persisted_global_state());
|
self.persisted_global_state(),
|
||||||
|
self.technical_state.clone(),
|
||||||
|
);
|
||||||
if let Err(error) = storage.save(&runtime_state) {
|
if let Err(error) = storage.save(&runtime_state) {
|
||||||
let command_error = HostCommandError::new(
|
let command_error = HostCommandError::new(
|
||||||
"persist_failed",
|
"persist_failed",
|
||||||
@@ -741,12 +1006,7 @@ impl SimulationState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_event(
|
fn push_event(&mut self, kind: StatusEventKind, code: Option<String>, message: String) {
|
||||||
&mut self,
|
|
||||||
kind: StatusEventKind,
|
|
||||||
code: Option<String>,
|
|
||||||
message: String,
|
|
||||||
) {
|
|
||||||
self.snapshot.recent_events.insert(
|
self.snapshot.recent_events.insert(
|
||||||
0,
|
0,
|
||||||
StatusEvent {
|
StatusEvent {
|
||||||
@@ -774,6 +1034,12 @@ fn unavailable_snapshot() -> HostSnapshot {
|
|||||||
schema_version: 0,
|
schema_version: 0,
|
||||||
topology_label: "unknown".to_string(),
|
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 {
|
global: GlobalControlSnapshot {
|
||||||
blackout: true,
|
blackout: true,
|
||||||
master_brightness: 0.0,
|
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 {
|
fn panel_position_label(position: &PanelPosition) -> &'static str {
|
||||||
match position {
|
match position {
|
||||||
PanelPosition::Top => "top",
|
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 {
|
fn scale_preview(mut preview: RenderedPreview, factor: f32) -> RenderedPreview {
|
||||||
let factor = factor.clamp(0.0, 1.0);
|
let factor = factor.clamp(0.0, 1.0);
|
||||||
preview.representative_color_hex = scale_hex_color(&preview.representative_color_hex, factor);
|
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"));
|
.any(|preset| preset.preset_id == "amber_chase_top"));
|
||||||
assert_eq!(snapshot.preview.panels.len(), 18);
|
assert_eq!(snapshot.preview.panels.len(), 18);
|
||||||
assert_eq!(snapshot.nodes.len(), 6);
|
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]
|
#[test]
|
||||||
@@ -932,7 +1230,10 @@ mod tests {
|
|||||||
|
|
||||||
let snapshot = service.snapshot();
|
let snapshot = service.snapshot();
|
||||||
assert_eq!(snapshot.active_scene.pattern_id, "walking_pixel");
|
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
|
assert!(snapshot
|
||||||
.active_scene
|
.active_scene
|
||||||
.parameters
|
.parameters
|
||||||
@@ -957,4 +1258,82 @@ mod tests {
|
|||||||
.any(|event| event.kind == StatusEventKind::Warning
|
.any(|event| event.kind == StatusEventKind::Warning
|
||||||
&& event.code.as_deref() == Some("unknown_group")));
|
&& 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")
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
|
||||||
use infinity_host::{
|
use infinity_host::{
|
||||||
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
|
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, OutputBackendMode,
|
||||||
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
|
PreviewSource, SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
|
||||||
TestPatternKind,
|
TechnicalSnapshot, TestPatternKind,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -80,9 +80,40 @@ pub struct ApiErrorBody {
|
|||||||
pub message: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ApiStateSnapshot {
|
pub struct ApiStateSnapshot {
|
||||||
pub system: ApiSystemInfo,
|
pub system: ApiSystemInfo,
|
||||||
|
pub technical: ApiTechnicalState,
|
||||||
pub global: ApiGlobalState,
|
pub global: ApiGlobalState,
|
||||||
pub engine: ApiEngineState,
|
pub engine: ApiEngineState,
|
||||||
pub active_scene: ApiActiveScene,
|
pub active_scene: ApiActiveScene,
|
||||||
@@ -134,6 +165,21 @@ pub struct ApiSystemInfo {
|
|||||||
pub topology_label: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ApiGlobalState {
|
pub struct ApiGlobalState {
|
||||||
pub blackout: bool,
|
pub blackout: bool,
|
||||||
@@ -258,6 +304,7 @@ pub struct ApiPanelStatus {
|
|||||||
pub node_id: String,
|
pub node_id: String,
|
||||||
pub panel_position: ApiPanelPosition,
|
pub panel_position: ApiPanelPosition,
|
||||||
pub physical_output_name: String,
|
pub physical_output_name: String,
|
||||||
|
pub driver_kind: ApiDriverKind,
|
||||||
pub driver_reference: String,
|
pub driver_reference: String,
|
||||||
pub led_count: u16,
|
pub led_count: u16,
|
||||||
pub direction: ApiLedDirection,
|
pub direction: ApiLedDirection,
|
||||||
@@ -337,6 +384,18 @@ pub enum ApiColorOrder {
|
|||||||
Bgr,
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ApiValidationState {
|
pub enum ApiValidationState {
|
||||||
@@ -356,6 +415,30 @@ pub enum ApiParameterValue {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
|
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
|
||||||
pub enum ApiCommand {
|
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 {
|
SetBlackout {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
},
|
},
|
||||||
@@ -390,6 +473,9 @@ pub enum ApiCommand {
|
|||||||
preset_id: String,
|
preset_id: String,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
},
|
},
|
||||||
|
DeletePreset {
|
||||||
|
preset_id: String,
|
||||||
|
},
|
||||||
SaveCreativeSnapshot {
|
SaveCreativeSnapshot {
|
||||||
snapshot_id: String,
|
snapshot_id: String,
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
@@ -546,6 +632,7 @@ impl ApiStateSnapshot {
|
|||||||
schema_version: snapshot.system.schema_version,
|
schema_version: snapshot.system.schema_version,
|
||||||
topology_label: snapshot.system.topology_label.clone(),
|
topology_label: snapshot.system.topology_label.clone(),
|
||||||
},
|
},
|
||||||
|
technical: map_technical_snapshot(&snapshot.technical),
|
||||||
global: ApiGlobalState {
|
global: ApiGlobalState {
|
||||||
blackout: snapshot.global.blackout,
|
blackout: snapshot.global.blackout,
|
||||||
master_brightness: snapshot.global.master_brightness,
|
master_brightness: snapshot.global.master_brightness,
|
||||||
@@ -561,14 +648,16 @@ impl ApiStateSnapshot {
|
|||||||
uptime_ms: snapshot.engine.uptime_ms,
|
uptime_ms: snapshot.engine.uptime_ms,
|
||||||
frame_index: snapshot.engine.frame_index,
|
frame_index: snapshot.engine.frame_index,
|
||||||
dropped_frames: snapshot.engine.dropped_frames,
|
dropped_frames: snapshot.engine.dropped_frames,
|
||||||
active_transition: snapshot.engine.active_transition.as_ref().map(|transition| {
|
active_transition: snapshot
|
||||||
ApiTransitionState {
|
.engine
|
||||||
|
.active_transition
|
||||||
|
.as_ref()
|
||||||
|
.map(|transition| ApiTransitionState {
|
||||||
style: map_transition_style(transition.style),
|
style: map_transition_style(transition.style),
|
||||||
from_pattern_id: transition.from_pattern_id.clone(),
|
from_pattern_id: transition.from_pattern_id.clone(),
|
||||||
to_pattern_id: transition.to_pattern_id.clone(),
|
to_pattern_id: transition.to_pattern_id.clone(),
|
||||||
duration_ms: transition.duration_ms,
|
duration_ms: transition.duration_ms,
|
||||||
progress: transition.progress,
|
progress: transition.progress,
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
active_scene: ApiActiveScene {
|
active_scene: ApiActiveScene {
|
||||||
@@ -613,6 +702,7 @@ impl ApiStateSnapshot {
|
|||||||
node_id: panel.target.node_id.clone(),
|
node_id: panel.target.node_id.clone(),
|
||||||
panel_position: map_panel_position(&panel.target.panel_position),
|
panel_position: map_panel_position(&panel.target.panel_position),
|
||||||
physical_output_name: panel.physical_output_name.clone(),
|
physical_output_name: panel.physical_output_name.clone(),
|
||||||
|
driver_kind: map_driver_kind(panel.driver_kind.clone()),
|
||||||
driver_reference: panel.driver_reference.clone(),
|
driver_reference: panel.driver_reference.clone(),
|
||||||
led_count: panel.led_count,
|
led_count: panel.led_count,
|
||||||
direction: map_led_direction(panel.direction.clone()),
|
direction: map_led_direction(panel.direction.clone()),
|
||||||
@@ -652,19 +742,50 @@ impl ApiPreviewSnapshot {
|
|||||||
impl ApiCommandRequest {
|
impl ApiCommandRequest {
|
||||||
pub fn into_host_command(self) -> Result<HostCommand, String> {
|
pub fn into_host_command(self) -> Result<HostCommand, String> {
|
||||||
match self.command {
|
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::SetBlackout { enabled } => Ok(HostCommand::SetBlackout(enabled)),
|
||||||
ApiCommand::SetMasterBrightness { value } => {
|
ApiCommand::SetMasterBrightness { value } => {
|
||||||
Ok(HostCommand::SetMasterBrightness(value))
|
Ok(HostCommand::SetMasterBrightness(value))
|
||||||
}
|
}
|
||||||
ApiCommand::SelectPattern { pattern_id } => {
|
ApiCommand::SelectPattern { pattern_id } => Ok(HostCommand::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::RecallPreset { preset_id } => {
|
|
||||||
Ok(HostCommand::RecallPreset { preset_id })
|
|
||||||
}
|
|
||||||
ApiCommand::SelectGroup { group_id } => {
|
|
||||||
Ok(HostCommand::SelectGroup { group_id })
|
|
||||||
}
|
|
||||||
ApiCommand::SetSceneParameter { key, value } => Ok(HostCommand::SetSceneParameter {
|
ApiCommand::SetSceneParameter { key, value } => Ok(HostCommand::SetSceneParameter {
|
||||||
key,
|
key,
|
||||||
value: map_command_parameter_value(value),
|
value: map_command_parameter_value(value),
|
||||||
@@ -672,9 +793,9 @@ impl ApiCommandRequest {
|
|||||||
ApiCommand::SetTransitionDurationMs { duration_ms } => {
|
ApiCommand::SetTransitionDurationMs { duration_ms } => {
|
||||||
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
|
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
|
||||||
}
|
}
|
||||||
ApiCommand::SetTransitionStyle { style } => {
|
ApiCommand::SetTransitionStyle { style } => Ok(HostCommand::SetTransitionStyle(
|
||||||
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
|
map_command_transition_style(style),
|
||||||
}
|
)),
|
||||||
ApiCommand::TriggerPanelTest {
|
ApiCommand::TriggerPanelTest {
|
||||||
node_id,
|
node_id,
|
||||||
panel_position,
|
panel_position,
|
||||||
@@ -695,6 +816,7 @@ impl ApiCommandRequest {
|
|||||||
preset_id,
|
preset_id,
|
||||||
overwrite,
|
overwrite,
|
||||||
}),
|
}),
|
||||||
|
ApiCommand::DeletePreset { preset_id } => Ok(HostCommand::DeletePreset { preset_id }),
|
||||||
ApiCommand::SaveCreativeSnapshot {
|
ApiCommand::SaveCreativeSnapshot {
|
||||||
snapshot_id,
|
snapshot_id,
|
||||||
label,
|
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 {
|
fn map_validation_state(state: ValidationState) -> ApiValidationState {
|
||||||
match state {
|
match state {
|
||||||
ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation,
|
ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation,
|
||||||
@@ -849,6 +1036,11 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
|
|||||||
impl ApiCommand {
|
impl ApiCommand {
|
||||||
pub fn kind_label(&self) -> &'static str {
|
pub fn kind_label(&self) -> &'static str {
|
||||||
match self {
|
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::SetBlackout { .. } => "set_blackout",
|
||||||
Self::SetMasterBrightness { .. } => "set_master_brightness",
|
Self::SetMasterBrightness { .. } => "set_master_brightness",
|
||||||
Self::SelectPattern { .. } => "select_pattern",
|
Self::SelectPattern { .. } => "select_pattern",
|
||||||
@@ -859,6 +1051,7 @@ impl ApiCommand {
|
|||||||
Self::SetTransitionStyle { .. } => "set_transition_style",
|
Self::SetTransitionStyle { .. } => "set_transition_style",
|
||||||
Self::TriggerPanelTest { .. } => "trigger_panel_test",
|
Self::TriggerPanelTest { .. } => "trigger_panel_test",
|
||||||
Self::SavePreset { .. } => "save_preset",
|
Self::SavePreset { .. } => "save_preset",
|
||||||
|
Self::DeletePreset { .. } => "delete_preset",
|
||||||
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
|
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
|
||||||
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
|
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
|
||||||
Self::UpsertGroup { .. } => "upsert_group",
|
Self::UpsertGroup { .. } => "upsert_group",
|
||||||
@@ -867,6 +1060,40 @@ impl ApiCommand {
|
|||||||
|
|
||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
match self {
|
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 } => {
|
Self::SetBlackout { enabled } => {
|
||||||
if *enabled {
|
if *enabled {
|
||||||
"blackout enabled".to_string()
|
"blackout enabled".to_string()
|
||||||
@@ -875,7 +1102,10 @@ impl ApiCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::SetMasterBrightness { value } => {
|
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::SelectPattern { pattern_id } => format!("pattern selected: {pattern_id}"),
|
||||||
Self::RecallPreset { preset_id } => format!("preset recalled: {preset_id}"),
|
Self::RecallPreset { preset_id } => format!("preset recalled: {preset_id}"),
|
||||||
@@ -900,13 +1130,17 @@ impl ApiCommand {
|
|||||||
node_id,
|
node_id,
|
||||||
panel_position.label()
|
panel_position.label()
|
||||||
),
|
),
|
||||||
Self::SavePreset { preset_id, overwrite } => {
|
Self::SavePreset {
|
||||||
|
preset_id,
|
||||||
|
overwrite,
|
||||||
|
} => {
|
||||||
if *overwrite {
|
if *overwrite {
|
||||||
format!("preset overwritten: {preset_id}")
|
format!("preset overwritten: {preset_id}")
|
||||||
} else {
|
} else {
|
||||||
format!("preset saved: {preset_id}")
|
format!("preset saved: {preset_id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Self::DeletePreset { preset_id } => format!("preset deleted: {preset_id}"),
|
||||||
Self::SaveCreativeSnapshot {
|
Self::SaveCreativeSnapshot {
|
||||||
snapshot_id,
|
snapshot_id,
|
||||||
overwrite,
|
overwrite,
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
|
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
|
||||||
let server = HostApiServer::bind(&cli.bind, service)?;
|
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!(
|
println!(
|
||||||
"Runtime state persistence: {}",
|
"Infinity Vis host API listening on http://{}",
|
||||||
cli.runtime_state.display()
|
server.local_addr()
|
||||||
);
|
);
|
||||||
|
println!("Web UI available at http://{}/", server.local_addr());
|
||||||
|
println!("Runtime state persistence: {}", cli.runtime_state.display());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
thread::sleep(Duration::from_secs(60));
|
thread::sleep(Duration::from_secs(60));
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
|
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiDiscoveredNodeType,
|
||||||
|
ApiDiscoveryResult, ApiDiscoveryScanRequest, ApiDiscoveryScanResponse, ApiErrorResponse,
|
||||||
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
|
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
|
||||||
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
|
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
|
||||||
};
|
};
|
||||||
use crate::websocket::{websocket_accept_value, write_text_frame};
|
use crate::websocket::{websocket_accept_value, write_text_frame};
|
||||||
use infinity_host::HostApiPort;
|
use infinity_host::HostApiPort;
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
mpsc, Arc, Mutex,
|
||||||
};
|
};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub struct HostApiServer {
|
pub struct HostApiServer {
|
||||||
local_addr: SocketAddr,
|
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()) {
|
match (request.method.as_str(), request.path.as_str()) {
|
||||||
("GET", "/api/v1/snapshot") => {
|
("GET", "/api/v1/snapshot") => {
|
||||||
let snapshot = service.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") => {
|
("GET", "/api/v1/state") => {
|
||||||
let snapshot = service.snapshot();
|
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") => {
|
("GET", "/api/v1/preview") => {
|
||||||
let snapshot = service.snapshot();
|
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") => {
|
("GET", "/api/v1/catalog") => {
|
||||||
let snapshot = service.snapshot();
|
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") => {
|
("GET", "/api/v1/presets") => {
|
||||||
let snapshot = service.snapshot();
|
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") => {
|
("GET", "/api/v1/groups") => {
|
||||||
let snapshot = service.snapshot();
|
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) {
|
("POST", "/api/v1/command") => match handle_command_post(&mut stream, request, service) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(error) => respond_error(
|
Err(error) => respond_error(&mut stream, error.status, error.code, error.message),
|
||||||
&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(
|
("GET", "/") => respond_text(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
200,
|
200,
|
||||||
@@ -134,12 +161,30 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
|
|||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
include_str!("../../../web/v1/index.html"),
|
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(
|
("GET", "/app.js") => respond_text(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
200,
|
200,
|
||||||
"application/javascript; charset=utf-8",
|
"application/javascript; charset=utf-8",
|
||||||
include_str!("../../../web/v1/app.js"),
|
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(
|
("GET", "/styles.css") => respond_text(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
200,
|
200,
|
||||||
@@ -152,7 +197,10 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
|
|||||||
"application/json; charset=utf-8",
|
"application/json; charset=utf-8",
|
||||||
&serde_json::to_string_pretty(&ApiErrorResponse::new(
|
&serde_json::to_string_pretty(&ApiErrorResponse::new(
|
||||||
"not_found",
|
"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))?,
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
|
||||||
),
|
),
|
||||||
@@ -164,11 +212,12 @@ fn handle_command_post(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
service: Arc<dyn HostApiPort>,
|
service: Arc<dyn HostApiPort>,
|
||||||
) -> Result<(), ApiRequestError> {
|
) -> Result<(), ApiRequestError> {
|
||||||
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
|
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body).map_err(|error| {
|
||||||
.map_err(|error| ApiRequestError {
|
ApiRequestError {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "invalid_request_json".to_string(),
|
code: "invalid_request_json".to_string(),
|
||||||
message: format!("command request body could not be parsed: {error}"),
|
message: format!("command request body could not be parsed: {error}"),
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
let request_id = parsed.request_id.clone();
|
let request_id = parsed.request_id.clone();
|
||||||
let command_type = parsed.command.kind_label().to_string();
|
let command_type = parsed.command.kind_label().to_string();
|
||||||
@@ -226,7 +275,9 @@ fn handle_websocket(
|
|||||||
stream.write_all(response.as_bytes())?;
|
stream.write_all(response.as_bytes())?;
|
||||||
|
|
||||||
let mut sequence = 1u64;
|
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 {
|
loop {
|
||||||
let snapshot = service.snapshot();
|
let snapshot = service.snapshot();
|
||||||
send_stream_message(
|
send_stream_message(
|
||||||
@@ -236,36 +287,331 @@ fn handle_websocket(
|
|||||||
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
|
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
|
||||||
)?;
|
)?;
|
||||||
sequence += 1;
|
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(
|
send_stream_message(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
sequence,
|
sequence,
|
||||||
snapshot.generated_at_millis,
|
snapshot.generated_at_millis,
|
||||||
ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)),
|
ApiStreamMessage::Preview(preview_payload.clone()),
|
||||||
)?;
|
)?;
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
|
last_streamed_preview = Some(preview_payload);
|
||||||
|
}
|
||||||
|
|
||||||
let mut new_events = snapshot
|
let mut new_events = snapshot
|
||||||
.recent_events
|
.recent_events
|
||||||
.iter()
|
.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()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
new_events.sort_by_key(|event| event.at_millis);
|
new_events.sort_by_key(|event| event.at_millis);
|
||||||
for event in new_events {
|
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(
|
send_stream_message(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
sequence,
|
sequence,
|
||||||
event.at_millis,
|
event_millis,
|
||||||
ApiStreamMessage::Event(event.into()),
|
ApiStreamMessage::Event(event.into()),
|
||||||
)?;
|
)?;
|
||||||
sequence += 1;
|
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));
|
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(
|
fn send_stream_message(
|
||||||
stream: &mut TcpStream,
|
stream: &mut TcpStream,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
@@ -332,7 +678,9 @@ struct HttpRequest {
|
|||||||
|
|
||||||
impl HttpRequest {
|
impl HttpRequest {
|
||||||
fn header(&self, key: &str) -> Option<&str> {
|
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 {
|
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 header_text = String::from_utf8_lossy(&buffer[..header_end]);
|
||||||
let mut lines = header_text.lines();
|
let mut lines = header_text.lines();
|
||||||
let request_line = lines
|
let request_line = lines
|
||||||
@@ -393,10 +742,7 @@ fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
|
|||||||
let mut headers = HashMap::new();
|
let mut headers = HashMap::new();
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if let Some((key, value)) = line.split_once(':') {
|
if let Some((key, value)) = line.split_once(':') {
|
||||||
headers.insert(
|
headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||||
key.trim().to_ascii_lowercase(),
|
|
||||||
value.trim().to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let body_start = header_end + 4;
|
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> {
|
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||||
buffer
|
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||||
.windows(4)
|
|
||||||
.position(|window| window == b"\r\n\r\n")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,21 @@ pub fn write_text_frame(stream: &mut TcpStream, payload: &str) -> io::Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn base64_encode(bytes: &[u8]) -> String {
|
fn base64_encode(bytes: &[u8]) -> String {
|
||||||
const TABLE: &[u8; 64] =
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
let mut encoded = String::new();
|
let mut encoded = String::new();
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
while index < bytes.len() {
|
while index < bytes.len() {
|
||||||
let first = bytes[index];
|
let first = bytes[index];
|
||||||
let second = if index + 1 < bytes.len() { bytes[index + 1] } else { 0 };
|
let second = if index + 1 < bytes.len() {
|
||||||
let third = if index + 2 < bytes.len() { bytes[index + 2] } else { 0 };
|
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 >> 2) as usize] as char);
|
||||||
encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
|
encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ struct HttpResponse {
|
|||||||
fn root_and_web_assets_target_the_versioned_api_contract() {
|
fn root_and_web_assets_target_the_versioned_api_contract() {
|
||||||
let server = start_server();
|
let server = start_server();
|
||||||
let html = send_http_request(server.local_addr(), "GET", "/", None);
|
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 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_eq!(html.status_code, 200);
|
||||||
assert!(html
|
assert!(html
|
||||||
@@ -45,15 +48,123 @@ fn root_and_web_assets_target_the_versioned_api_contract() {
|
|||||||
.get("content-type")
|
.get("content-type")
|
||||||
.expect("content-type header")
|
.expect("content-type header")
|
||||||
.starts_with("text/html"));
|
.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("Creative Snapshots"));
|
||||||
assert!(html.body.contains("Event Stream"));
|
assert!(html.body.contains("Selected Tile"));
|
||||||
|
assert!(html.body.contains("Utilities"));
|
||||||
|
assert!(html.body.contains("View & Output"));
|
||||||
|
assert!(html.body.contains("Pending Transition"));
|
||||||
|
assert!(html.body.contains("Trigger Transition"));
|
||||||
|
assert!(html.body.contains("session-scope-label"));
|
||||||
|
assert!(html.body.contains("Fade Go"));
|
||||||
|
assert!(html.body.contains("Status & Eventfeed"));
|
||||||
|
assert!(html.body.contains("Mapping Settings"));
|
||||||
|
assert!(!html.body.contains("<h2>Groups</h2>"));
|
||||||
|
assert!(html.body.contains("work-mode-select"));
|
||||||
|
assert!(html.body.contains("LEDs Only"));
|
||||||
|
assert!(!html.body.contains("preview-mode-select"));
|
||||||
|
|
||||||
|
assert_eq!(technical_html.status_code, 200);
|
||||||
|
assert!(technical_html
|
||||||
|
.body
|
||||||
|
.contains("Infinity Vis Mapping Settings"));
|
||||||
|
assert!(technical_html.body.contains("Backend & Output"));
|
||||||
|
assert!(technical_html.body.contains("Node / IP Discovery"));
|
||||||
|
assert!(technical_html.body.contains("Discover / Scan"));
|
||||||
|
assert!(technical_html.body.contains("Node Targets"));
|
||||||
|
assert!(technical_html.body.contains("Panel Mapping"));
|
||||||
|
assert!(technical_html.body.contains("DDP (WLED)"));
|
||||||
|
assert!(technical_html.body.contains("Preview Only"));
|
||||||
|
assert!(technical_html.body.contains("Creative Surface"));
|
||||||
|
|
||||||
assert_eq!(app_js.status_code, 200);
|
assert_eq!(app_js.status_code, 200);
|
||||||
assert!(app_js.body.contains("/api/v1/state"));
|
assert!(app_js.body.contains("/api/v1/state"));
|
||||||
assert!(app_js.body.contains("/api/v1/preview"));
|
assert!(app_js.body.contains("/api/v1/preview"));
|
||||||
assert!(app_js.body.contains("save_preset"));
|
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("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();
|
server.shutdown();
|
||||||
}
|
}
|
||||||
@@ -73,13 +184,37 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
|
|||||||
assert_eq!(state_body["api_version"], "v1");
|
assert_eq!(state_body["api_version"], "v1");
|
||||||
assert!(state_body.get("state").is_some());
|
assert!(state_body.get("state").is_some());
|
||||||
assert!(state_body.get("preview").is_none());
|
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.status_code, 200);
|
||||||
assert_eq!(preview_body["api_version"], "v1");
|
assert_eq!(preview_body["api_version"], "v1");
|
||||||
assert!(preview_body.get("preview").is_some());
|
assert!(preview_body.get("preview").is_some());
|
||||||
assert!(preview_body.get("state").is_none());
|
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.status_code, 200);
|
||||||
assert_eq!(snapshot_body["api_version"], "v1");
|
assert_eq!(snapshot_body["api_version"], "v1");
|
||||||
@@ -89,6 +224,144 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
|
|||||||
server.shutdown();
|
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]
|
#[test]
|
||||||
fn command_flow_updates_group_parameters_transition_and_blackout() {
|
fn command_flow_updates_group_parameters_transition_and_blackout() {
|
||||||
let server = start_server();
|
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 = 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 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_style"], "chase");
|
||||||
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
|
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"]
|
assert!(state_body["state"]["active_scene"]["parameters"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("parameter array")
|
.expect("parameter array")
|
||||||
@@ -191,14 +470,18 @@ fn presets_and_creative_snapshots_persist_across_restart() {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.expect("preset array")
|
.expect("preset array")
|
||||||
.iter()
|
.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"]
|
assert!(catalog_body["creative_snapshots"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("snapshot array")
|
.expect("snapshot array")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
|
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
|
||||||
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
|
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"]
|
assert!(state_body["state"]["active_scene"]["parameters"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("parameter array")
|
.expect("parameter array")
|
||||||
@@ -209,6 +492,237 @@ fn presets_and_creative_snapshots_persist_across_restart() {
|
|||||||
let _ = std::fs::remove_file(runtime_state_path);
|
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]
|
#[test]
|
||||||
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
|
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
|
||||||
let server = start_server();
|
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");
|
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
|
||||||
|
|
||||||
let mut saw_warning = false;
|
let mut saw_warning = false;
|
||||||
for _ in 0..8 {
|
for _ in 0..12 {
|
||||||
let frame = read_websocket_text_frame(&mut stream);
|
let frame = read_websocket_text_frame(&mut stream);
|
||||||
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
|
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;
|
saw_warning = true;
|
||||||
assert_eq!(payload["message"]["payload"]["kind"], "warning");
|
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"]
|
assert!(payload["message"]["payload"]["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("event message")
|
.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() {
|
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
|
||||||
let server = start_server();
|
let server = start_server();
|
||||||
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
|
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 {
|
for index in 0..80 {
|
||||||
let pattern = patterns[index % patterns.len()];
|
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 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");
|
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!(
|
||||||
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
|
state_body["state"]["panels"].as_array().map(Vec::len),
|
||||||
assert!(catalog_body["patterns"].as_array().map(Vec::len).unwrap_or_default() >= 5);
|
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();
|
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))
|
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 body = body.unwrap_or("");
|
||||||
let request = format!(
|
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{}",
|
"{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",
|
"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
|
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 = read_until_header_end(&mut stream);
|
||||||
let header_text = String::from_utf8(header).expect("handshake utf8");
|
let header_text = String::from_utf8(header).expect("handshake utf8");
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
|
|||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let blackout = snapshot.global.blackout;
|
let blackout = snapshot.global.blackout;
|
||||||
let blackout_button = egui::Button::new(if 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 {
|
} else {
|
||||||
RichText::new("Blackout").strong()
|
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;
|
let mut brightness = snapshot.global.master_brightness;
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(egui::Slider::new(&mut brightness, 0.0..=1.0).text("Master Brightness"))
|
||||||
egui::Slider::new(&mut brightness, 0.0..=1.0)
|
|
||||||
.text("Master Brightness"),
|
|
||||||
)
|
|
||||||
.changed()
|
.changed()
|
||||||
{
|
{
|
||||||
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
|
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 {
|
if let Some(error) = &node.error_status {
|
||||||
ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140)));
|
ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140)));
|
||||||
} else {
|
} 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(
|
fn draw_panel_mapping(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
|
||||||
ui: &mut egui::Ui,
|
|
||||||
snapshot: &HostSnapshot,
|
|
||||||
service: &Arc<dyn HostUiPort>,
|
|
||||||
) {
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.heading("Panel Mapping");
|
ui.heading("Panel Mapping");
|
||||||
ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology.");
|
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.vertical(|ui| {
|
||||||
ui.label(connection_badge(panel.connection));
|
ui.label(connection_badge(panel.connection));
|
||||||
if let Some(last_test_ms) = panel.last_test_trigger_ms {
|
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 {
|
if let Some(error) = &panel.error_status {
|
||||||
ui.label(
|
ui.label(
|
||||||
@@ -254,7 +254,9 @@ fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
|
|||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.heading("Recent Events");
|
ui.heading("Recent Events");
|
||||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(220.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
for event in &snapshot.recent_events {
|
for event in &snapshot.recent_events {
|
||||||
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
|
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,4 +203,3 @@ mod tests {
|
|||||||
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
|
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ The current delivery order is intentionally software-first:
|
|||||||
- Logic tick target: 120 Hz
|
- Logic tick target: 120 Hz
|
||||||
- Frame synthesis target: 60 Hz
|
- Frame synthesis target: 60 Hz
|
||||||
- Network send target: 40-60 Hz, profile dependent
|
- 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.
|
Preview and telemetry are explicitly degradable. Realtime output is not.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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,4 +1,3 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "stable"
|
channel = "stable"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
|
|
||||||
|
|||||||
Executable
+48
@@ -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
|
||||||
Executable
+49
@@ -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"
|
||||||
+2186
-388
File diff suppressed because it is too large
Load Diff
+220
-140
@@ -3,55 +3,48 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-shell">
|
<div class="app-shell">
|
||||||
<header class="hero">
|
<header class="topbar topbar-creative">
|
||||||
<div class="hero-copy">
|
<div class="topbar-actions">
|
||||||
<p class="eyebrow">Infinity Vis / Creative Surface</p>
|
<a href="/technical" class="toolbar-button toolbar-link">Mapping Settings</a>
|
||||||
<h1 id="project-name">Loading project...</h1>
|
|
||||||
<p id="topology-label" class="hero-subtitle">
|
<label class="toolbar-control toolbar-control-inline">
|
||||||
Shared host API bootstrap in progress.
|
<span class="toolbar-label">Tempo</span>
|
||||||
</p>
|
<input id="tempo-bpm-input" type="number" min="10" max="300" step="1" />
|
||||||
</div>
|
<strong id="tempo-bpm-label">120 BPM</strong>
|
||||||
<div class="hero-status">
|
</label>
|
||||||
<div class="status-card">
|
|
||||||
<span class="status-label">API stream</span>
|
<label class="toolbar-control toolbar-control-inline">
|
||||||
<span id="connection-pill" class="pill pill-offline">connecting</span>
|
<span class="toolbar-label">Mode</span>
|
||||||
</div>
|
<select id="work-mode-select">
|
||||||
<div class="status-card">
|
<option value="test_edit">Test/Edit</option>
|
||||||
<span class="status-label">Preview refresh</span>
|
<option value="show_event">Show/Event</option>
|
||||||
<span id="preview-updated">waiting for data</span>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
<button id="refresh-button" class="ghost-button" type="button">
|
|
||||||
Refresh snapshot
|
<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>
|
</button>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
<label class="toolbar-control toolbar-control-inline toolbar-control-fade">
|
||||||
<section class="panel controls-panel">
|
<span class="toolbar-label">Fade</span>
|
||||||
<div class="section-heading">
|
<input id="transition-seconds-input" type="number" min="0.1" max="30" step="0.1" />
|
||||||
<h2>Global Look</h2>
|
<strong id="transition-seconds-label">2.0 s</strong>
|
||||||
<p>Pattern, preset, group and transition control against the shared host API.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span>Pattern</span>
|
|
||||||
<select id="pattern-select"></select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="toolbar-control toolbar-control-inline">
|
||||||
<span>Transition Duration</span>
|
<span class="toolbar-label">Style</span>
|
||||||
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
|
|
||||||
<strong id="transition-value">0 ms</strong>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span>Transition Style</span>
|
|
||||||
<select id="transition-style-select">
|
<select id="transition-style-select">
|
||||||
<option value="snap">Snap</option>
|
<option value="snap">Snap</option>
|
||||||
<option value="crossfade">Crossfade</option>
|
<option value="crossfade">Crossfade</option>
|
||||||
@@ -59,118 +52,79 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<span id="edit-context-label" class="status-chip status-chip-edit">Edit: Live</span>
|
||||||
<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">
|
<button id="blackout-button" class="toolbar-button toolbar-button-alert" type="button">
|
||||||
<span>Blackout</span>
|
Blackout
|
||||||
<button id="blackout-button" class="danger-button" type="button">
|
|
||||||
Enable blackout
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="subsection">
|
<main class="workspace">
|
||||||
<div class="subsection-heading">
|
<aside class="workspace-rail workspace-rail-left">
|
||||||
<h3>Presets</h3>
|
<section class="dock-section">
|
||||||
<p>Recall look snapshots without leaving the creative console.</p>
|
<div class="dock-header">
|
||||||
|
<h2>Pattern</h2>
|
||||||
|
<p>Old-tool style mode access on top of the stable show-control primitives.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="preset-list" class="pill-row"></div>
|
<label class="control-field">
|
||||||
</div>
|
<span>Pattern</span>
|
||||||
|
<select id="pattern-select"></select>
|
||||||
<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>
|
||||||
<label class="field inline-checkbox">
|
|
||||||
<span>Overwrite Existing</span>
|
|
||||||
<input id="preset-overwrite-input" type="checkbox" />
|
|
||||||
</label>
|
|
||||||
<button id="save-preset-button" class="ghost-button" type="button">
|
|
||||||
Save Current Scene As Preset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subsection">
|
|
||||||
<div class="subsection-heading">
|
|
||||||
<h3>Groups</h3>
|
|
||||||
<p>Focus looks on a subset while keeping the core scene model shared.</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 class="subsection">
|
|
||||||
<div class="subsection-heading">
|
|
||||||
<h3>Creative Snapshots</h3>
|
|
||||||
<p>Capture exploratory variants without replacing curated presets.</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>
|
|
||||||
<input id="snapshot-overwrite-input" type="checkbox" />
|
|
||||||
</label>
|
|
||||||
<button id="save-snapshot-button" class="ghost-button" type="button">
|
|
||||||
Save Creative Snapshot
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="snapshot-list" class="snapshot-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subsection">
|
|
||||||
<div class="subsection-heading">
|
|
||||||
<h3>Scene Parameters</h3>
|
|
||||||
<p>Rendered from the active scene schema, not hardcoded per frontend.</p>
|
|
||||||
</div>
|
|
||||||
<div id="scene-params" class="parameter-grid"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel preview-panel">
|
<section class="dock-section">
|
||||||
<div class="section-heading">
|
<div class="dock-header">
|
||||||
|
<h2>Look & Motion</h2>
|
||||||
|
<p>Pattern behavior and movement parameters.</p>
|
||||||
|
</div>
|
||||||
|
<div id="motion-params" class="parameter-stack"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
<h2>Preview</h2>
|
||||||
<p>Live panel previews from the host snapshot and stream feed.</p>
|
<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>
|
</div>
|
||||||
<div id="preview-grid" class="preview-grid"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel summary-panel">
|
<section class="stage-panel stage-panel-events">
|
||||||
<div class="section-heading">
|
<div class="stage-header">
|
||||||
<h2>Snapshot</h2>
|
<div>
|
||||||
<p>Operator-friendly scene state with a raw API view underneath.</p>
|
<h2>Status & Eventfeed</h2>
|
||||||
|
<p>Live status messages stay hot without rebuilding the whole workbench.</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-chip-row">
|
||||||
|
<span id="connection-pill" class="status-chip status-chip-warning">connecting</span>
|
||||||
|
<span id="control-mode-pill" class="status-chip status-chip-live">Test/Edit</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div class="event-filter-bar">
|
||||||
<select id="event-kind-filter">
|
<select id="event-kind-filter">
|
||||||
<option value="all">All kinds</option>
|
<option value="all">All kinds</option>
|
||||||
@@ -187,6 +141,132 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="event-list" class="event-list"></div>
|
<div id="event-list" class="event-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="workspace-rail workspace-rail-right">
|
||||||
|
<section class="dock-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<h2>Pending Transition</h2>
|
||||||
|
<p id="pending-panel-description">Stage direct edits locally and commit them consciously.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pending-status-row">
|
||||||
|
<div class="mini-status">
|
||||||
|
<span class="toolbar-label">Scope</span>
|
||||||
|
<strong id="session-scope-label">local browser session</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mini-status">
|
||||||
|
<span class="toolbar-label">Buffer</span>
|
||||||
|
<strong id="pending-compact-label">empty</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mini-status">
|
||||||
|
<span class="toolbar-label">Commit</span>
|
||||||
|
<span id="pending-commit-pill" class="status-chip status-chip-idle">idle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||||
|
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||||
|
<div class="button-stack">
|
||||||
|
<button id="trigger-transition-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||||
|
Trigger Transition
|
||||||
|
</button>
|
||||||
|
<button id="clear-staged-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||||
|
Clear Staged
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dock-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<h2>Selected Tile</h2>
|
||||||
|
<p>Stable tile focus for operator actions and diagnostics.</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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="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="toolbar-button toolbar-button-ghost" type="button">
|
||||||
|
Save Snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="snapshot-list" class="list-stack"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dock-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<h2>View & Output</h2>
|
||||||
|
<p>Read-only state and output context for live operation.</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+991
-474
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Infinity Vis Mapping Settings</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="technical-shell">
|
||||||
|
<header class="topbar topbar-technical">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<div class="brand-title">Infinity Vis Mapping Settings</div>
|
||||||
|
<div id="technical-project-name" class="brand-project">Loading technical surface...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-strip">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<span class="toolbar-label">Backend</span>
|
||||||
|
<span id="technical-backend-pill" class="status-chip status-chip-warning">loading</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<span class="toolbar-label">Output</span>
|
||||||
|
<span id="technical-output-pill" class="status-chip status-chip-warning">loading</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<span class="toolbar-label">Nodes</span>
|
||||||
|
<span id="technical-nodes-pill" class="status-chip status-chip-warning">0/0 online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<a href="/" class="toolbar-button toolbar-link">Creative Surface</a>
|
||||||
|
<button id="technical-refresh-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="technical-workspace">
|
||||||
|
<section class="technical-stack">
|
||||||
|
<section class="dock-section technical-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<div>
|
||||||
|
<h2>Backend & Output</h2>
|
||||||
|
<p>Preview Only and DDP (WLED) stay explicit and honest.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="technical-summary-grid" class="technical-summary-grid"></div>
|
||||||
|
|
||||||
|
<div class="technical-form-grid">
|
||||||
|
<label class="technical-field">
|
||||||
|
<span class="toolbar-label">Backend Mode</span>
|
||||||
|
<select id="backend-mode-select">
|
||||||
|
<option value="preview_only">Preview Only</option>
|
||||||
|
<option value="ddp_wled">DDP (WLED)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="technical-field">
|
||||||
|
<span class="toolbar-label">Output FPS</span>
|
||||||
|
<input id="output-fps-input" type="number" min="1" max="240" step="1" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="technical-checkbox technical-field-wide">
|
||||||
|
<input id="output-enabled-input" type="checkbox" />
|
||||||
|
<span>Output Enabled</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="technical-field technical-field-wide">
|
||||||
|
<span class="toolbar-label">Live Status</span>
|
||||||
|
<div id="technical-live-status" class="status-banner">Waiting for state...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technical-field technical-field-wide">
|
||||||
|
<span class="toolbar-label">Semantics</span>
|
||||||
|
<div id="technical-backend-semantics" class="technical-note">
|
||||||
|
Preview Only means no live output. DDP (WLED) is armed only when explicitly enabled.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="save-output-settings-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||||
|
Apply Output Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="technical-feedback-banner" class="status-banner hidden"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dock-section technical-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<div>
|
||||||
|
<h2>Node / IP Discovery</h2>
|
||||||
|
<p>Scan subnet ranges and assign discovered IPs explicitly to node slots.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technical-form-grid">
|
||||||
|
<label class="technical-field">
|
||||||
|
<span class="toolbar-label">Subnet</span>
|
||||||
|
<input id="discovery-subnet-input" type="text" value="192.168.40.0/24" />
|
||||||
|
</label>
|
||||||
|
<div class="technical-field">
|
||||||
|
<span class="toolbar-label">Discovery</span>
|
||||||
|
<button id="discovery-scan-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||||
|
Discover / Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="discovery-summary" class="technical-note">
|
||||||
|
No scan executed yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technical-table-wrap">
|
||||||
|
<table class="technical-table technical-table-dense">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Reachable</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Hostname / mDNS</th>
|
||||||
|
<th>Assign to Node Slot</th>
|
||||||
|
<th>Apply</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="discovery-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dock-section technical-section">
|
||||||
|
<div class="dock-header">
|
||||||
|
<div>
|
||||||
|
<h2>Node Targets</h2>
|
||||||
|
<p>Reserved IPs and honest connection status per node.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technical-table-wrap">
|
||||||
|
<table class="technical-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node</th>
|
||||||
|
<th>Display</th>
|
||||||
|
<th>Reserved IP</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Live Note</th>
|
||||||
|
<th>Apply</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="node-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="technical-stack technical-stack-wide">
|
||||||
|
<section class="stage-panel technical-section">
|
||||||
|
<div class="stage-header">
|
||||||
|
<div>
|
||||||
|
<h2>Panel Mapping</h2>
|
||||||
|
<p>Real output slots with backend-facing routing details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technical-table-wrap technical-table-wrap-grow">
|
||||||
|
<table class="technical-table technical-table-dense">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node</th>
|
||||||
|
<th>Panel</th>
|
||||||
|
<th>Output</th>
|
||||||
|
<th>Driver Kind</th>
|
||||||
|
<th>GPIO / Channel</th>
|
||||||
|
<th>LEDs</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th>Color</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Validation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Apply</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="panel-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stage-panel technical-section technical-events-panel">
|
||||||
|
<div class="stage-header">
|
||||||
|
<div>
|
||||||
|
<h2>Recent Events</h2>
|
||||||
|
<p>Live backend and mapping changes without fake node traffic.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="technical-event-list" class="event-list technical-event-list"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/technical.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1003
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user