Der nächste Layer ist jetzt als echte gemeinsame Außenkante umgesetzt. Das neue API-Crate in [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>), [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [main.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/main.rs>) stellt die geforderten versionierten Endpunkte bereit: GET /api/v1/snapshot, GET /api/v1/catalog, GET /api/v1/presets, GET /api/v1/groups, POST /api/v1/command und WS /api/v1/stream. Die API trennt jetzt sauber zwischen Command-, State-, Preview- und Event-Modell, inklusive stabiler Außen-Enums und dokumentierten Fehlerobjekten statt eines unreflektierten 1:1-Core-Leaks.

Darauf sitzt die erste Web-UI V1 direkt gegen diese API in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>). Sie enthält Pattern-/Preset-Auswahl, globale Regler, Gruppenauswahl, Übergänge, Preview, Snapshot-Anzeige und Event-Feed. Die technische Desktop-GUI bleibt bewusst die Engineering-/Diagnoseoberfläche; die kreative Web-UI baut nicht an einer Parallelarchitektur vorbei.

Contract-Tests liegen in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>), und die API ist mit Beispiel-Responses in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>) dokumentiert. Zusätzlich habe ich [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>) auf den neuen Stand gebracht.

Verifiziert habe ich:
- `cargo check -p infinity_host_api`
- `cargo test -p infinity_host_api`
- `cargo test -q`

Nicht verifiziert habe ich eine separate JS-Syntaxprüfung mit `node --check`, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
2026-04-17 11:58:07 +02:00
parent 9457666fd6
commit a37a3c5cbe
16 changed files with 3166 additions and 55 deletions

11
Cargo.lock generated
View File

@@ -1708,6 +1708,17 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "infinity_host_api"
version = "0.1.0"
dependencies = [
"clap",
"infinity_config",
"infinity_host",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "infinity_host_ui" name = "infinity_host_ui"
version = "0.1.0" version = "0.1.0"

View File

@@ -3,6 +3,7 @@ members = [
"crates/infinity_config", "crates/infinity_config",
"crates/infinity_protocol", "crates/infinity_protocol",
"crates/infinity_host", "crates/infinity_host",
"crates/infinity_host_api",
"crates/infinity_host_ui", "crates/infinity_host_ui",
] ]
resolver = "2" resolver = "2"

View File

@@ -22,8 +22,8 @@ Current software priority:
- stable host-core first - stable host-core first
- shared host API for every surface - shared host API for every surface
- simulation and mock-first creative workflow - simulation and mock-first creative workflow
- web UI as the primary creative surface
- engineering GUI for technical operation - engineering GUI for technical operation
- web UI to follow as the primary creative surface
- grandMA planned later as an external show-control adapter, not as the system core - grandMA planned later as an external show-control adapter, not as the system core
The current baseline is intentionally strict about unresolved hardware facts. `UART 6`, `UART 5`, and `UART 4` are treated as unvalidated labels until the real electrical meaning is confirmed. The current baseline is intentionally strict about unresolved hardware facts. `UART 6`, `UART 5`, and `UART 4` are treated as unvalidated labels until the real electrical meaning is confirmed.
@@ -34,8 +34,10 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md). 2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md).
3. Start from [config/project.example.toml](config/project.example.toml). 3. Start from [config/project.example.toml](config/project.example.toml).
4. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`. 4. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`.
5. Start the engineering GUI with `cargo run -p infinity_host_ui`. 5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001`.
6. Use the host CLI to validate the project config before attempting activation. 6. Open `http://127.0.0.1:9001/` for the creative surface.
7. Start the engineering GUI with `cargo run -p infinity_host_ui`.
8. Use the host CLI to validate the project config before attempting activation.
## Docs ## Docs

View File

@@ -0,0 +1,14 @@
[package]
name = "infinity_host_api"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
infinity_config = { path = "../infinity_config" }
infinity_host = { path = "../infinity_host" }

View File

@@ -0,0 +1,745 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, SceneParameterKind,
SceneParameterValue, SceneTransitionStyle, TestPatternKind,
};
use serde::{Deserialize, Serialize};
pub const API_VERSION: &str = "v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSnapshotResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse {
pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPresetListResponse {
pub api_version: &'static str,
pub presets: Vec<ApiPresetSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGroupListResponse {
pub api_version: &'static str,
pub groups: Vec<ApiGroupSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCommandRequest {
#[serde(default)]
pub request_id: Option<String>,
pub command: ApiCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCommandResponse {
pub api_version: &'static str,
pub accepted: bool,
pub request_id: Option<String>,
pub generated_at_millis: u64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorResponse {
pub api_version: &'static str,
pub error: ApiErrorBody,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorBody {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateSnapshot {
pub system: ApiSystemInfo,
pub global: ApiGlobalState,
pub engine: ApiEngineState,
pub active_scene: ApiActiveScene,
pub nodes: Vec<ApiNodeStatus>,
pub panels: Vec<ApiPanelStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewSnapshot {
pub generated_at_millis: u64,
pub panels: Vec<ApiPreviewPanel>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStreamEnvelope {
pub api_version: &'static str,
pub sequence: u64,
pub generated_at_millis: u64,
pub message: ApiStreamMessage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ApiStreamMessage {
Snapshot(ApiStateSnapshot),
Preview(ApiPreviewSnapshot),
Event(ApiEventNotice),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice {
pub kind: ApiEventKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiEventKind {
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSystemInfo {
pub project_name: String,
pub schema_version: u32,
pub topology_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGlobalState {
pub blackout: bool,
pub master_brightness: f32,
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEngineState {
pub logic_hz: u16,
pub frame_hz: u16,
pub preview_hz: u16,
pub uptime_ms: u64,
pub frame_index: u64,
pub dropped_frames: u64,
pub active_transition: Option<ApiTransitionState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiTransitionState {
pub style: ApiTransitionStyle,
pub from_pattern_id: String,
pub to_pattern_id: String,
pub duration_ms: u32,
pub progress: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiTransitionStyle {
Snap,
Crossfade,
Chase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiActiveScene {
pub preset_id: Option<String>,
pub pattern_id: String,
pub seed: u64,
pub palette: Vec<String>,
pub parameters: Vec<ApiSceneParameter>,
pub target_group: Option<String>,
pub blackout: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSceneParameter {
pub key: String,
pub label: String,
pub kind: ApiParameterKind,
pub value: ApiParameterValue,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPatternCatalogEntry {
pub pattern_id: String,
pub display_name: String,
pub description: String,
pub parameters: Vec<ApiPatternParameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPatternParameter {
pub key: String,
pub label: String,
pub kind: ApiParameterKind,
pub min_scalar: Option<f32>,
pub max_scalar: Option<f32>,
pub step: Option<f32>,
pub default_value: ApiParameterValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPresetSummary {
pub preset_id: String,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiGroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiNodeStatus {
pub node_id: String,
pub display_name: String,
pub reserved_ip: Option<String>,
pub connection: ApiConnectionState,
pub last_contact_ms: u64,
pub error_status: Option<String>,
pub panel_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPanelStatus {
pub node_id: String,
pub panel_position: ApiPanelPosition,
pub physical_output_name: String,
pub driver_reference: String,
pub led_count: u16,
pub direction: ApiLedDirection,
pub color_order: ApiColorOrder,
pub enabled: bool,
pub validation_state: ApiValidationState,
pub connection: ApiConnectionState,
pub last_test_trigger_ms: Option<u64>,
pub error_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewPanel {
pub node_id: String,
pub panel_position: ApiPanelPosition,
pub representative_color_hex: String,
pub sample_led_hex: Vec<String>,
pub energy_percent: u8,
pub source: ApiPreviewSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiPanelPosition {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiConnectionState {
Online,
Degraded,
Offline,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiPreviewSource {
Scene,
Transition,
PanelTest,
Blackout,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiParameterKind {
Scalar,
Toggle,
Text,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiLedDirection {
Forward,
Reverse,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiColorOrder {
Rgb,
Rbg,
Grb,
Gbr,
Brg,
Bgr,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiValidationState {
PendingHardwareValidation,
Validated,
Retired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum ApiParameterValue {
Scalar(f32),
Toggle(bool),
Text(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
pub enum ApiCommand {
SetBlackout {
enabled: bool,
},
SetMasterBrightness {
value: f32,
},
SelectPattern {
pattern_id: String,
},
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: ApiParameterValue,
},
SetTransitionDurationMs {
duration_ms: u32,
},
TriggerPanelTest {
node_id: String,
panel_position: ApiPanelPosition,
pattern: ApiTestPattern,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiTestPattern {
WalkingPixel106,
}
impl ApiSnapshotResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
let state = ApiStateSnapshot::from_snapshot(snapshot);
let preview = ApiPreviewSnapshot::from_snapshot(snapshot);
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state,
preview,
}
}
}
impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
patterns: snapshot
.catalog
.patterns
.iter()
.map(|pattern| ApiPatternCatalogEntry {
pattern_id: pattern.pattern_id.clone(),
display_name: pattern.display_name.clone(),
description: pattern.description.clone(),
parameters: pattern
.parameters
.iter()
.map(|parameter| ApiPatternParameter {
key: parameter.key.clone(),
label: parameter.label.clone(),
kind: map_parameter_kind(parameter.kind),
min_scalar: parameter.min_scalar,
max_scalar: parameter.max_scalar,
step: parameter.step,
default_value: map_parameter_value(&parameter.default_value),
})
.collect(),
})
.collect(),
presets: snapshot
.catalog
.presets
.iter()
.map(|preset| ApiPresetSummary {
preset_id: preset.preset_id.clone(),
pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
})
.collect(),
groups: snapshot
.catalog
.groups
.iter()
.map(|group| ApiGroupSummary {
group_id: group.group_id.clone(),
member_count: group.member_count,
tags: group.tags.clone(),
})
.collect(),
}
}
}
impl ApiPresetListResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
presets: ApiCatalogResponse::from_snapshot(snapshot).presets,
}
}
}
impl ApiGroupListResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
groups: ApiCatalogResponse::from_snapshot(snapshot).groups,
}
}
}
impl ApiStateSnapshot {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
system: ApiSystemInfo {
project_name: snapshot.system.project_name.clone(),
schema_version: snapshot.system.schema_version,
topology_label: snapshot.system.topology_label.clone(),
},
global: ApiGlobalState {
blackout: snapshot.global.blackout,
master_brightness: snapshot.global.master_brightness,
selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms,
},
engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz,
frame_hz: snapshot.engine.frame_hz,
preview_hz: snapshot.engine.preview_hz,
uptime_ms: snapshot.engine.uptime_ms,
frame_index: snapshot.engine.frame_index,
dropped_frames: snapshot.engine.dropped_frames,
active_transition: snapshot.engine.active_transition.as_ref().map(|transition| {
ApiTransitionState {
style: map_transition_style(transition.style),
from_pattern_id: transition.from_pattern_id.clone(),
to_pattern_id: transition.to_pattern_id.clone(),
duration_ms: transition.duration_ms,
progress: transition.progress,
}
}),
},
active_scene: ApiActiveScene {
preset_id: snapshot.active_scene.preset_id.clone(),
pattern_id: snapshot.active_scene.pattern_id.clone(),
seed: snapshot.active_scene.seed,
palette: snapshot.active_scene.palette.clone(),
parameters: snapshot
.active_scene
.parameters
.iter()
.map(|parameter| ApiSceneParameter {
key: parameter.key.clone(),
label: parameter.label.clone(),
kind: map_parameter_kind(parameter.kind),
value: map_parameter_value(&parameter.value),
min_scalar: parameter.min_scalar,
max_scalar: parameter.max_scalar,
step: parameter.step,
})
.collect(),
target_group: snapshot.active_scene.target_group.clone(),
blackout: snapshot.active_scene.blackout,
},
nodes: snapshot
.nodes
.iter()
.map(|node| ApiNodeStatus {
node_id: node.node_id.clone(),
display_name: node.display_name.clone(),
reserved_ip: node.reserved_ip.clone(),
connection: map_connection_state(node.connection),
last_contact_ms: node.last_contact_ms,
error_status: node.error_status.clone(),
panel_count: node.panel_count,
})
.collect(),
panels: snapshot
.panels
.iter()
.map(|panel| ApiPanelStatus {
node_id: panel.target.node_id.clone(),
panel_position: map_panel_position(&panel.target.panel_position),
physical_output_name: panel.physical_output_name.clone(),
driver_reference: panel.driver_reference.clone(),
led_count: panel.led_count,
direction: map_led_direction(panel.direction.clone()),
color_order: map_color_order(panel.color_order.clone()),
enabled: panel.enabled,
validation_state: map_validation_state(panel.validation_state.clone()),
connection: map_connection_state(panel.connection),
last_test_trigger_ms: panel.last_test_trigger_ms,
error_status: panel.error_status.clone(),
})
.collect(),
}
}
}
impl ApiPreviewSnapshot {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
generated_at_millis: snapshot.generated_at_millis,
panels: snapshot
.preview
.panels
.iter()
.map(|panel| ApiPreviewPanel {
node_id: panel.target.node_id.clone(),
panel_position: map_panel_position(&panel.target.panel_position),
representative_color_hex: panel.representative_color_hex.clone(),
sample_led_hex: panel.sample_led_hex.clone(),
energy_percent: panel.energy_percent,
source: map_preview_source(panel.preview_source),
})
.collect(),
}
}
}
impl ApiCommandRequest {
pub fn into_host_command(self) -> Result<HostCommand, String> {
match self.command {
ApiCommand::SetBlackout { enabled } => Ok(HostCommand::SetBlackout(enabled)),
ApiCommand::SetMasterBrightness { value } => {
Ok(HostCommand::SetMasterBrightness(value))
}
ApiCommand::SelectPattern { pattern_id } => {
Ok(HostCommand::SelectPattern(pattern_id))
}
ApiCommand::RecallPreset { preset_id } => {
Ok(HostCommand::RecallPreset { preset_id })
}
ApiCommand::SelectGroup { group_id } => {
Ok(HostCommand::SelectGroup { group_id })
}
ApiCommand::SetSceneParameter { key, value } => Ok(HostCommand::SetSceneParameter {
key,
value: map_command_parameter_value(value),
}),
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
pattern,
} => Ok(HostCommand::TriggerPanelTest {
target: infinity_host::PanelTarget {
node_id,
panel_position: map_command_panel_position(panel_position),
},
pattern: match pattern {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106,
},
}),
}
}
pub fn summary(&self) -> String {
self.command.summary()
}
}
fn map_panel_position(position: &PanelPosition) -> ApiPanelPosition {
match position {
PanelPosition::Top => ApiPanelPosition::Top,
PanelPosition::Middle => ApiPanelPosition::Middle,
PanelPosition::Bottom => ApiPanelPosition::Bottom,
}
}
fn map_command_panel_position(position: ApiPanelPosition) -> PanelPosition {
match position {
ApiPanelPosition::Top => PanelPosition::Top,
ApiPanelPosition::Middle => PanelPosition::Middle,
ApiPanelPosition::Bottom => PanelPosition::Bottom,
}
}
fn map_connection_state(state: NodeConnectionState) -> ApiConnectionState {
match state {
NodeConnectionState::Online => ApiConnectionState::Online,
NodeConnectionState::Degraded => ApiConnectionState::Degraded,
NodeConnectionState::Offline => ApiConnectionState::Offline,
}
}
fn map_led_direction(direction: LedDirection) -> ApiLedDirection {
match direction {
LedDirection::Forward => ApiLedDirection::Forward,
LedDirection::Reverse => ApiLedDirection::Reverse,
}
}
fn map_color_order(color_order: ColorOrder) -> ApiColorOrder {
match color_order {
ColorOrder::Rgb => ApiColorOrder::Rgb,
ColorOrder::Rbg => ApiColorOrder::Rbg,
ColorOrder::Grb => ApiColorOrder::Grb,
ColorOrder::Gbr => ApiColorOrder::Gbr,
ColorOrder::Brg => ApiColorOrder::Brg,
ColorOrder::Bgr => ApiColorOrder::Bgr,
}
}
fn map_validation_state(state: ValidationState) -> ApiValidationState {
match state {
ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation,
ValidationState::Validated => ApiValidationState::Validated,
ValidationState::Retired => ApiValidationState::Retired,
}
}
fn map_preview_source(source: PreviewSource) -> ApiPreviewSource {
match source {
PreviewSource::Scene => ApiPreviewSource::Scene,
PreviewSource::Transition => ApiPreviewSource::Transition,
PreviewSource::PanelTest => ApiPreviewSource::PanelTest,
PreviewSource::Blackout => ApiPreviewSource::Blackout,
}
}
fn map_transition_style(style: SceneTransitionStyle) -> ApiTransitionStyle {
match style {
SceneTransitionStyle::Snap => ApiTransitionStyle::Snap,
SceneTransitionStyle::Crossfade => ApiTransitionStyle::Crossfade,
SceneTransitionStyle::Chase => ApiTransitionStyle::Chase,
}
}
fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar,
SceneParameterKind::Toggle => ApiParameterKind::Toggle,
SceneParameterKind::Text => ApiParameterKind::Text,
}
}
fn map_parameter_value(value: &SceneParameterValue) -> ApiParameterValue {
match value {
SceneParameterValue::Scalar(value) => ApiParameterValue::Scalar(*value),
SceneParameterValue::Toggle(value) => ApiParameterValue::Toggle(*value),
SceneParameterValue::Text(value) => ApiParameterValue::Text(value.clone()),
}
}
fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue {
match value {
ApiParameterValue::Scalar(value) => SceneParameterValue::Scalar(value),
ApiParameterValue::Toggle(value) => SceneParameterValue::Toggle(value),
ApiParameterValue::Text(value) => SceneParameterValue::Text(value),
}
}
impl ApiCommand {
pub fn summary(&self) -> String {
match self {
Self::SetBlackout { enabled } => {
if *enabled {
"blackout enabled".to_string()
} else {
"blackout released".to_string()
}
}
Self::SetMasterBrightness { value } => {
format!("master brightness set to {:.0}%", value.clamp(0.0, 1.0) * 100.0)
}
Self::SelectPattern { pattern_id } => format!("pattern selected: {pattern_id}"),
Self::RecallPreset { preset_id } => format!("preset recalled: {preset_id}"),
Self::SelectGroup { group_id } => format!(
"group selected: {}",
group_id.as_deref().unwrap_or("all_panels")
),
Self::SetSceneParameter { key, .. } => format!("scene parameter updated: {key}"),
Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms")
}
Self::TriggerPanelTest {
node_id,
panel_position,
pattern,
} => format!(
"panel test {} on {}:{}",
pattern.label(),
node_id,
panel_position.label()
),
}
}
}
impl ApiPanelPosition {
pub fn label(self) -> &'static str {
match self {
Self::Top => "top",
Self::Middle => "middle",
Self::Bottom => "bottom",
}
}
}
impl ApiTestPattern {
pub fn label(self) -> &'static str {
match self {
Self::WalkingPixel106 => "walking_pixel_106",
}
}
}
impl ApiErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
api_version: API_VERSION,
error: ApiErrorBody {
code: code.into(),
message: message.into(),
},
}
}
}

View File

@@ -0,0 +1,6 @@
mod dto;
mod server;
mod websocket;
pub use dto::*;
pub use server::*;

View File

@@ -0,0 +1,32 @@
use clap::Parser;
use infinity_config::{load_project_from_path, ProjectConfig};
use infinity_host::{HostApiPort, SimulationHostService};
use infinity_host_api::HostApiServer;
use std::{path::PathBuf, sync::Arc, thread, time::Duration};
#[derive(Debug, Parser)]
#[command(author, version, about = "Infinity Vis host API server")]
struct Cli {
#[arg(long, default_value = "config/project.example.toml")]
config: PathBuf,
#[arg(long, default_value = "127.0.0.1:9001")]
bind: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let project = load_project(&cli.config)?;
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(project);
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());
loop {
thread::sleep(Duration::from_secs(60));
}
}
fn load_project(path: &std::path::Path) -> Result<ProjectConfig, Box<dyn std::error::Error>> {
Ok(load_project_from_path(path)?)
}

View File

@@ -0,0 +1,396 @@
use crate::dto::{
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse, ApiEventKind,
ApiEventNotice, ApiGroupListResponse, ApiPresetListResponse, ApiSnapshotResponse,
ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
};
use crate::websocket::{websocket_accept_value, write_text_frame};
use infinity_host::HostApiPort;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct HostApiServer {
local_addr: SocketAddr,
shutdown: Arc<AtomicBool>,
accept_thread: Option<JoinHandle<()>>,
}
impl HostApiServer {
pub fn bind(bind: &str, service: Arc<dyn HostApiPort>) -> io::Result<Self> {
let listener = TcpListener::bind(bind)?;
listener.set_nonblocking(true)?;
let local_addr = listener.local_addr()?;
let shutdown = Arc::new(AtomicBool::new(false));
let thread_shutdown = Arc::clone(&shutdown);
let accept_thread = thread::spawn(move || accept_loop(listener, service, thread_shutdown));
Ok(Self {
local_addr,
shutdown,
accept_thread: Some(accept_thread),
})
}
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
}
pub fn shutdown(mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.accept_thread.take() {
let _ = handle.join();
}
}
}
impl Drop for HostApiServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.accept_thread.take() {
let _ = handle.join();
}
}
}
fn accept_loop(listener: TcpListener, service: Arc<dyn HostApiPort>, shutdown: Arc<AtomicBool>) {
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let service = Arc::clone(&service);
thread::spawn(move || {
let _ = handle_connection(stream, service);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
}
fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let request = read_request(&mut stream)?;
if request.path == "/api/v1/stream" && request.is_websocket() {
return handle_websocket(stream, request, service);
}
match (request.method.as_str(), request.path.as_str()) {
("GET", "/api/v1/snapshot") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiSnapshotResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiCatalogResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/presets") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPresetListResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/groups") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiGroupListResponse::from_snapshot(&snapshot))
}
("POST", "/api/v1/command") => match handle_command_post(&mut stream, request, service) {
Ok(()) => Ok(()),
Err(error) => respond_error(
&mut stream,
400,
"invalid_command",
format!("command request was rejected: {error}"),
),
},
("GET", "/") => respond_text(
&mut stream,
200,
"text/html; charset=utf-8",
include_str!("../../../web/v1/index.html"),
),
("GET", "/index.html") => respond_text(
&mut stream,
200,
"text/html; charset=utf-8",
include_str!("../../../web/v1/index.html"),
),
("GET", "/app.js") => respond_text(
&mut stream,
200,
"application/javascript; charset=utf-8",
include_str!("../../../web/v1/app.js"),
),
("GET", "/styles.css") => respond_text(
&mut stream,
200,
"text/css; charset=utf-8",
include_str!("../../../web/v1/styles.css"),
),
_ => respond_text(
&mut stream,
404,
"application/json; charset=utf-8",
&serde_json::to_string_pretty(&ApiErrorResponse::new(
"not_found",
format!("no route registered for {} {}", request.method, request.path),
))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
),
}
}
fn handle_command_post(
stream: &mut TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let request_id = parsed.request_id.clone();
let summary = parsed.summary();
let command = parsed
.into_host_command()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
service.send_command(command);
let snapshot = service.snapshot();
respond_json(
stream,
200,
&ApiCommandResponse {
api_version: API_VERSION,
accepted: true,
request_id,
generated_at_millis: snapshot.generated_at_millis,
summary,
},
)
}
fn handle_websocket(
mut stream: TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
let Some(key) = request.header("sec-websocket-key") else {
return respond_error(
&mut stream,
400,
"missing_websocket_key",
"websocket upgrade requires sec-websocket-key",
);
};
let accept = websocket_accept_value(key);
let response = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n"
);
stream.write_all(response.as_bytes())?;
let mut sequence = 1u64;
let mut last_event_millis = 0u64;
loop {
let snapshot = service.snapshot();
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
let mut new_events = snapshot
.recent_events
.iter()
.filter(|event| event.at_millis > last_event_millis)
.cloned()
.collect::<Vec<_>>();
new_events.sort_by_key(|event| event.at_millis);
for event in new_events {
last_event_millis = event.at_millis;
send_stream_message(
&mut stream,
sequence,
event.at_millis,
ApiStreamMessage::Event(ApiEventNotice {
kind: ApiEventKind::Info,
message: event.message,
}),
)?;
sequence += 1;
}
thread::sleep(Duration::from_millis(250));
}
}
fn send_stream_message(
stream: &mut TcpStream,
sequence: u64,
generated_at_millis: u64,
message: ApiStreamMessage,
) -> io::Result<()> {
let payload = serde_json::to_string(&ApiStreamEnvelope {
api_version: API_VERSION,
sequence,
generated_at_millis,
message,
})
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
write_text_frame(stream, &payload)
}
fn respond_json<T: serde::Serialize>(
stream: &mut TcpStream,
status: u16,
body: &T,
) -> io::Result<()> {
let payload = serde_json::to_string_pretty(body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
respond_text(stream, status, "application/json; charset=utf-8", &payload)
}
fn respond_error(
stream: &mut TcpStream,
status: u16,
code: impl Into<String>,
message: impl Into<String>,
) -> io::Result<()> {
respond_json(stream, status, &ApiErrorResponse::new(code, message))
}
fn respond_text(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &str,
) -> io::Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
_ => "OK",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(response.as_bytes())
}
#[derive(Debug)]
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}
impl HttpRequest {
fn header(&self, key: &str) -> Option<&str> {
self.headers.get(&key.to_ascii_lowercase()).map(|value| value.as_str())
}
fn is_websocket(&self) -> bool {
self.header("upgrade")
.map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false)
}
}
fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
let mut buffer = Vec::new();
let mut temp = [0u8; 4096];
let mut header_end = None;
let mut expected_len = None;
loop {
let read = stream.read(&mut temp)?;
if read == 0 {
break;
}
buffer.extend_from_slice(&temp[..read]);
if header_end.is_none() {
header_end = find_header_end(&buffer);
if let Some(end) = header_end {
let header_text = String::from_utf8_lossy(&buffer[..end]);
expected_len = parse_content_length(&header_text);
if expected_len == Some(0) || expected_len.is_none() {
break;
}
}
}
if let (Some(end), Some(content_len)) = (header_end, expected_len) {
if buffer.len() >= end + 4 + content_len {
break;
}
}
}
let header_end = header_end.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing header end"))?;
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.lines();
let request_line = lines
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
.to_string();
let path = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
.split('?')
.next()
.unwrap_or("/")
.to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.insert(
key.trim().to_ascii_lowercase(),
value.trim().to_string(),
);
}
}
let body_start = header_end + 4;
let body = buffer.get(body_start..).unwrap_or_default().to_vec();
Ok(HttpRequest {
method,
path,
headers,
body,
})
}
fn parse_content_length(header_text: &str) -> Option<usize> {
header_text.lines().find_map(|line| {
line.split_once(':').and_then(|(key, value)| {
if key.trim().eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
})
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
}

View File

@@ -0,0 +1,141 @@
use std::io::{self, Write};
use std::net::TcpStream;
const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
pub fn websocket_accept_value(key: &str) -> String {
let digest = sha1(format!("{key}{WEBSOCKET_GUID}").as_bytes());
base64_encode(&digest)
}
pub fn write_text_frame(stream: &mut TcpStream, payload: &str) -> io::Result<()> {
let payload = payload.as_bytes();
let mut frame = Vec::with_capacity(payload.len() + 10);
frame.push(0x81);
match payload.len() {
0..=125 => frame.push(payload.len() as u8),
126..=65535 => {
frame.push(126);
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
}
_ => {
frame.push(127);
frame.extend_from_slice(&(payload.len() as u64).to_be_bytes());
}
}
frame.extend_from_slice(payload);
stream.write_all(&frame)
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::new();
let mut index = 0;
while index < bytes.len() {
let first = bytes[index];
let second = if index + 1 < bytes.len() { bytes[index + 1] } else { 0 };
let third = if index + 2 < bytes.len() { bytes[index + 2] } else { 0 };
encoded.push(TABLE[(first >> 2) as usize] as char);
encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
if index + 1 < bytes.len() {
encoded.push(TABLE[((second & 0b0000_1111) << 2 | (third >> 6)) as usize] as char);
} else {
encoded.push('=');
}
if index + 2 < bytes.len() {
encoded.push(TABLE[(third & 0b0011_1111) as usize] as char);
} else {
encoded.push('=');
}
index += 3;
}
encoded
}
fn sha1(bytes: &[u8]) -> [u8; 20] {
let mut h0: u32 = 0x67452301;
let mut h1: u32 = 0xEFCDAB89;
let mut h2: u32 = 0x98BADCFE;
let mut h3: u32 = 0x10325476;
let mut h4: u32 = 0xC3D2E1F0;
let mut message = bytes.to_vec();
let bit_len = (message.len() as u64) * 8;
message.push(0x80);
while (message.len() % 64) != 56 {
message.push(0x00);
}
message.extend_from_slice(&bit_len.to_be_bytes());
for chunk in message.chunks(64) {
let mut words = [0u32; 80];
for index in 0..16 {
let start = index * 4;
words[index] = u32::from_be_bytes([
chunk[start],
chunk[start + 1],
chunk[start + 2],
chunk[start + 3],
]);
}
for index in 16..80 {
words[index] =
(words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16])
.rotate_left(1);
}
let mut a = h0;
let mut b = h1;
let mut c = h2;
let mut d = h3;
let mut e = h4;
for index in 0..80 {
let (f, k) = match index {
0..=19 => (((b & c) | ((!b) & d)), 0x5A827999),
20..=39 => ((b ^ c ^ d), 0x6ED9EBA1),
40..=59 => (((b & c) | (b & d) | (c & d)), 0x8F1BBCDC),
_ => ((b ^ c ^ d), 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(words[index]);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h0 = h0.wrapping_add(a);
h1 = h1.wrapping_add(b);
h2 = h2.wrapping_add(c);
h3 = h3.wrapping_add(d);
h4 = h4.wrapping_add(e);
}
let mut digest = [0u8; 20];
digest[0..4].copy_from_slice(&h0.to_be_bytes());
digest[4..8].copy_from_slice(&h1.to_be_bytes());
digest[8..12].copy_from_slice(&h2.to_be_bytes());
digest[12..16].copy_from_slice(&h3.to_be_bytes());
digest[16..20].copy_from_slice(&h4.to_be_bytes());
digest
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn websocket_accept_matches_rfc_example() {
let accept = websocket_accept_value("dGhlIHNhbXBsZSBub25jZQ==");
assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
}
}

View File

@@ -0,0 +1,295 @@
use infinity_config::ProjectConfig;
use infinity_host::{HostApiPort, SimulationHostService};
use infinity_host_api::HostApiServer;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::Duration;
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
struct HttpResponse {
status_code: u16,
headers: HashMap<String, String>,
body: String,
}
#[test]
fn root_serves_creative_console_shell() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/", None);
assert_eq!(response.status_code, 200);
assert!(response
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(response.body.contains("Infinity Vis / Creative Surface"));
assert!(response.body.contains("/app.js"));
server.shutdown();
}
#[test]
fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse");
assert_eq!(response.status_code, 200);
assert_eq!(body["api_version"], "v1");
assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis");
assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6));
assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(body["state"]["active_scene"]["pattern_id"].is_string());
server.shutdown();
}
#[test]
fn catalog_presets_and_groups_endpoints_return_expected_lists() {
let server = start_server();
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None);
let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json");
let group_body: Value = serde_json::from_str(&groups.body).expect("group json");
assert_eq!(catalog.status_code, 200);
assert!(catalog_body["patterns"]
.as_array()
.expect("patterns array")
.iter()
.any(|pattern| pattern["pattern_id"] == "walking_pixel"));
assert!(preset_body["presets"]
.as_array()
.expect("presets array")
.iter()
.any(|preset| preset["preset_id"] == "ocean_gradient"));
assert!(group_body["groups"]
.as_array()
.expect("groups array")
.iter()
.any(|group| group["group_id"] == "top_panels"));
server.shutdown();
}
#[test]
fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() {
let server = start_server();
let response = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-blackout",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
);
let response_body: Value =
serde_json::from_str(&response.body).expect("command response must parse");
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(response.status_code, 200);
assert_eq!(response_body["accepted"], true);
assert_eq!(response_body["request_id"], "contract-blackout");
assert_eq!(snapshot_body["state"]["global"]["blackout"], true);
let invalid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#),
);
let invalid_body: Value =
serde_json::from_str(&invalid.body).expect("invalid response must parse");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["api_version"], "v1");
assert_eq!(invalid_body["error"]["code"], "invalid_command");
server.shutdown();
}
#[test]
fn websocket_stream_emits_snapshot_preview_and_event_messages() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
let first_frame = read_websocket_text_frame(&mut stream);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
let second_frame = read_websocket_text_frame(&mut stream);
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-event",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
);
let mut saw_event = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws event frame");
if payload["message"]["type"] == "event" {
saw_event = true;
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
.contains("blackout"));
break;
}
}
assert!(saw_event, "expected websocket event after command");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.as_bytes().len(),
body,
host = addr
);
let mut stream = TcpStream::connect(addr).expect("http connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
stream.write_all(request.as_bytes()).expect("write request");
stream.shutdown(Shutdown::Write).expect("shutdown write");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).expect("read response");
parse_http_response(&raw)
}
fn parse_http_response(raw: &[u8]) -> HttpResponse {
let delimiter = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("http header delimiter");
let header_text = String::from_utf8(raw[..delimiter].to_vec()).expect("header utf8");
let body = String::from_utf8(raw[delimiter + 4..].to_vec()).expect("body utf8");
let mut lines = header_text.lines();
let status_line = lines.next().expect("status line");
let status_code = status_line
.split_whitespace()
.nth(1)
.expect("status code")
.parse::<u16>()
.expect("valid status code");
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect::<HashMap<_, _>>();
HttpResponse {
status_code,
headers,
body,
}
}
fn open_websocket(addr: SocketAddr) -> TcpStream {
let mut stream = TcpStream::connect(addr).expect("websocket connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
let request = format!(
"GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
host = addr
);
stream.write_all(request.as_bytes()).expect("write handshake");
let header = read_until_header_end(&mut stream);
let header_text = String::from_utf8(header).expect("handshake utf8");
assert!(header_text.starts_with("HTTP/1.1 101"));
assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
stream
}
fn read_until_header_end(stream: &mut TcpStream) -> Vec<u8> {
let mut buffer = Vec::new();
loop {
let mut byte = [0u8; 1];
let read = stream.read(&mut byte).expect("read handshake");
assert!(read > 0, "unexpected eof while reading handshake");
buffer.push(byte[0]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
let end = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("header end")
+ 4;
return buffer[..end].to_vec();
}
}
}
fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
let mut header = [0u8; 2];
stream.read_exact(&mut header).expect("frame header");
assert_eq!(header[0] & 0x0f, 0x1, "expected text frame");
let payload_len = match header[1] & 0x7f {
len @ 0..=125 => len as usize,
126 => {
let mut extended = [0u8; 2];
stream.read_exact(&mut extended).expect("extended payload");
u16::from_be_bytes(extended) as usize
}
127 => {
let mut extended = [0u8; 8];
stream.read_exact(&mut extended).expect("extended payload");
u64::from_be_bytes(extended) as usize
}
_ => unreachable!("masked length bit should already be stripped"),
};
let mut payload = vec![0u8; payload_len];
stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8")
}

View File

@@ -101,7 +101,8 @@ The codebase deliberately blocks activation when these remain unresolved:
## Planned Next Steps ## Planned Next Steps
1. Add a network-facing adapter for the shared host API and start the web UI 1. Expand creative authoring on top of the now-versioned host API and web UI
2. Expand scene authoring and preset editing on top of the existing simulation core 2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin
3. Implement transport adapters without coupling them to any single frontend 3. Implement transport adapters without coupling them to any single frontend
4. Keep hardware activation behind explicit later validation gates 4. Add future external show-control bridges such as grandMA on the same API boundary
5. Keep hardware activation behind explicit later validation gates

View File

@@ -12,12 +12,15 @@ Suggested commands:
```powershell ```powershell
cargo test cargo test
cargo run -p infinity_host -- snapshot --config config/project.example.toml cargo run -p infinity_host -- snapshot --config config/project.example.toml
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001
cargo run -p infinity_host_ui cargo run -p infinity_host_ui
cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural
cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue
``` ```
The native engineering UI and the CLI snapshot currently run against the simulation-backed host API so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete. The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`.
The native engineering UI and the CLI snapshot continue to run against the same simulation-backed host core so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete.
Before any live activation, run: Before any live activation, run:

View File

@@ -2,79 +2,341 @@
## Purpose ## Purpose
The host API is the stable boundary that all operator surfaces and later external show-control adapters must use. The host API is the stable external boundary for:
Current rule: - the creative web UI
- the existing engineering GUI
- future external show-control adapters such as grandMA
- no UI is allowed to become the realtime clock The core rule stays unchanged:
- no frontend-specific assumptions are allowed to leak into scene simulation or transport planning
- future grandMA support must land as an adapter on this API, not as a special-case core path - the API is a control and observation layer
- the realtime engine remains the timing authority
- no surface is allowed to become the LED clock
## Current Implementation ## Current Implementation
The API lives in: Runtime pieces:
- `crates/infinity_host/src/control.rs` - `crates/infinity_host/src/control.rs`
- `crates/infinity_host/src/scene.rs` - `crates/infinity_host/src/scene.rs`
- `crates/infinity_host/src/simulation.rs` - `crates/infinity_host/src/simulation.rs`
- `crates/infinity_host_api/src/dto.rs`
- `crates/infinity_host_api/src/server.rs`
The engineering GUI already consumes this API through the `HostApiPort` / `HostUiPort` trait boundary. The network-facing server is started with:
## Snapshot Model ```powershell
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001
```
`HostSnapshot` currently exposes: Creative web UI V1 is served by the same process at:
- system metadata ```text
- global controls http://127.0.0.1:9001/
- engine timing and transition state ```
- catalog of patterns, presets, and groups
- active scene with parameter values
- simulated preview panels
- node and panel status
- recent event log
This makes it suitable as the shared read model for: ## Versioning
- engineering GUI - HTTP and WebSocket routes are versioned under `/api/v1`
- upcoming web UI - responses include `api_version: "v1"`
- CLI inspection - the external DTOs are intentionally not a direct 1:1 dump of internal core structs
- later external control bridges
## Command Model ## Endpoint Contract
`HostCommand` currently supports: ### GET `/api/v1/snapshot`
Returns the current state and preview in one response.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 241,
"state": {
"system": {
"project_name": "Infinity Vis",
"schema_version": 1,
"topology_label": "6 nodes / 18 outputs / 106 LEDs"
},
"global": {
"blackout": false,
"master_brightness": 0.2,
"selected_pattern": "solid_color",
"selected_group": null,
"transition_duration_ms": 150
},
"engine": {
"logic_hz": 120,
"frame_hz": 60,
"preview_hz": 15,
"uptime_ms": 241,
"frame_index": 14,
"dropped_frames": 0,
"active_transition": null
},
"active_scene": {
"preset_id": null,
"pattern_id": "solid_color",
"seed": 100,
"palette": [
"#ffffff"
],
"parameters": [],
"target_group": null,
"blackout": false
}
},
"preview": {
"generated_at_millis": 241,
"panels": [
{
"node_id": "node-01",
"panel_position": "top",
"representative_color_hex": "#33CCFF",
"sample_led_hex": [
"#33CCFF",
"#28A3CC",
"#1E7A99"
],
"energy_percent": 28,
"source": "scene"
}
]
}
}
```
### GET `/api/v1/catalog`
Returns the creative catalog:
- patterns
- presets
- groups
### GET `/api/v1/presets`
Returns only preset summaries.
Example:
```json
{
"api_version": "v1",
"presets": [
{
"preset_id": "ocean_gradient",
"pattern_id": "gradient",
"target_group": null,
"transition_duration_ms": 320
}
]
}
```
### GET `/api/v1/groups`
Returns only group summaries.
Example:
```json
{
"api_version": "v1",
"groups": [
{
"group_id": "top_panels",
"member_count": 6,
"tags": [
"row",
"top"
]
}
]
}
```
### POST `/api/v1/command`
Accepts a versioned command envelope.
Example request:
```json
{
"request_id": "web-1713352662000",
"command": {
"type": "set_master_brightness",
"payload": {
"value": 0.42
}
}
}
```
Example response:
```json
{
"api_version": "v1",
"accepted": true,
"request_id": "web-1713352662000",
"generated_at_millis": 522,
"summary": "master brightness set to 42%"
}
```
Errors use a stable error object:
```json
{
"api_version": "v1",
"error": {
"code": "invalid_command",
"message": "command request was rejected: missing field `enabled`"
}
}
```
### WS `/api/v1/stream`
The WebSocket stream emits envelopes with a monotonic sequence and a typed payload.
Stream message types:
- `snapshot`
- `preview`
- `event`
Example snapshot envelope:
```json
{
"api_version": "v1",
"sequence": 17,
"generated_at_millis": 875,
"message": {
"type": "snapshot",
"payload": {
"global": {
"blackout": false,
"master_brightness": 0.35,
"selected_pattern": "gradient",
"selected_group": "top_panels",
"transition_duration_ms": 320
}
}
}
}
```
Example preview envelope:
```json
{
"api_version": "v1",
"sequence": 18,
"generated_at_millis": 875,
"message": {
"type": "preview",
"payload": {
"generated_at_millis": 875,
"panels": [
{
"node_id": "node-01",
"panel_position": "top",
"representative_color_hex": "#FF8A5B",
"sample_led_hex": [
"#FF8A5B",
"#F36E43",
"#D85A2F"
],
"energy_percent": 47,
"source": "transition"
}
]
}
}
}
```
Example event envelope:
```json
{
"api_version": "v1",
"sequence": 19,
"generated_at_millis": 880,
"message": {
"type": "event",
"payload": {
"kind": "info",
"message": "preset recalled: ocean_gradient"
}
}
}
```
## Supported Commands
The current API command set covers:
- `set_blackout`
- `set_master_brightness`
- `select_pattern`
- `recall_preset`
- `select_group`
- `set_scene_parameter`
- `set_transition_duration_ms`
- `trigger_panel_test`
This is intentionally enough for:
- creative look development in the web UI
- engineering test triggers in the native GUI
- future external show-control translation layers
## Web UI V1
The first creative web UI is intentionally limited to:
- blackout
- master brightness
- pattern selection - pattern selection
- preset recall - preset recall
- group selection - group selection
- scene parameter changes - global brightness
- transition duration changes - blackout
- per-panel test triggers - transition duration
- scene parameter controls driven from the API schema
- panel preview
- snapshot display
- event feed
## Simulation Layer It does not absorb mapping, topology, or hardware-diagnostic workflows. Those stay in the native engineering UI.
The current `SimulationHostService` is not a throwaway mock. It is the software-first runtime for: ## Contract Tests
- look exploration The API contract is currently verified in:
- preset development
- parameter tuning
- future web UI integration
- API contract testing before hardware activation
It simulates: - `crates/infinity_host_api/tests/contract.rs`
- active scene state Covered paths:
- pattern rendering previews
- group gating
- transitions
- node connectivity status
- per-panel mapping tests
## Near-Term Direction - root web shell
- `GET /api/v1/snapshot`
- `GET /api/v1/catalog`
- `GET /api/v1/presets`
- `GET /api/v1/groups`
- `POST /api/v1/command`
- `WS /api/v1/stream`
1. Keep extending this API instead of adding surface-specific data paths. ## Future Direction
2. Add a network-facing adapter for the same API when the web UI starts.
3. Keep engineering GUI focused on topology, mapping, diagnostics, and admin. Next adapters should be built on this boundary instead of reaching into the host core directly.
4. Add grandMA later as an external show-control adapter against this API.
That includes:
- a richer web authoring surface
- remote operator clients
- a grandMA bridge that translates external show control into host API commands

591
web/v1/app.js Normal file
View File

@@ -0,0 +1,591 @@
(function () {
const apiState = {
snapshot: null,
catalog: null,
presets: [],
groups: [],
events: [],
ws: null,
commandTimers: new Map(),
};
const dom = {
projectName: document.getElementById("project-name"),
topologyLabel: document.getElementById("topology-label"),
connectionPill: document.getElementById("connection-pill"),
previewUpdated: document.getElementById("preview-updated"),
refreshButton: document.getElementById("refresh-button"),
patternSelect: document.getElementById("pattern-select"),
transitionSlider: document.getElementById("transition-slider"),
transitionValue: document.getElementById("transition-value"),
brightnessSlider: document.getElementById("brightness-slider"),
brightnessValue: document.getElementById("brightness-value"),
blackoutButton: document.getElementById("blackout-button"),
presetList: document.getElementById("preset-list"),
groupList: document.getElementById("group-list"),
sceneParams: document.getElementById("scene-params"),
previewGrid: document.getElementById("preview-grid"),
summaryCards: document.getElementById("summary-cards"),
snapshotJson: document.getElementById("snapshot-json"),
eventList: document.getElementById("event-list"),
};
function init() {
bindControls();
refreshAll();
connectStream();
}
function bindControls() {
dom.refreshButton.addEventListener("click", () => refreshAll());
dom.patternSelect.addEventListener("change", (event) => {
sendCommand({
type: "select_pattern",
payload: { pattern_id: event.target.value },
});
});
dom.transitionSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.transitionValue.textContent = `${value} ms`;
debounceCommand("transition", {
type: "set_transition_duration_ms",
payload: { duration_ms: value },
});
});
dom.brightnessSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.brightnessValue.textContent = `${Math.round(value * 100)}%`;
debounceCommand("brightness", {
type: "set_master_brightness",
payload: { value },
});
});
dom.blackoutButton.addEventListener("click", () => {
const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false);
sendCommand({
type: "set_blackout",
payload: { enabled },
});
});
}
async function refreshAll() {
setConnectionState("connecting", "loading");
try {
const [snapshot, catalog, presets, groups] = await Promise.all([
fetchJson("/api/v1/snapshot"),
fetchJson("/api/v1/catalog"),
fetchJson("/api/v1/presets"),
fetchJson("/api/v1/groups"),
]);
apiState.snapshot = snapshot;
apiState.catalog = catalog;
apiState.presets = presets.presets || catalog.presets || [];
apiState.groups = groups.groups || catalog.groups || [];
renderAll();
setConnectionState("online", "HTTP snapshot synced");
} catch (error) {
console.error(error);
setConnectionState("offline", "snapshot fetch failed");
pushEvent({
at: new Date().toLocaleTimeString(),
message: `HTTP refresh failed: ${error.message}`,
});
}
}
function connectStream() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/api/v1/stream`;
const socket = new WebSocket(url);
apiState.ws = socket;
socket.addEventListener("open", () => {
setConnectionState("online", "stream connected");
pushEvent({
at: new Date().toLocaleTimeString(),
message: "WebSocket stream connected",
});
});
socket.addEventListener("message", (event) => {
const envelope = JSON.parse(event.data);
handleStreamEnvelope(envelope);
});
socket.addEventListener("close", () => {
setConnectionState("offline", "stream disconnected");
pushEvent({
at: new Date().toLocaleTimeString(),
message: "WebSocket stream closed, retrying",
});
window.setTimeout(connectStream, 1500);
});
socket.addEventListener("error", () => {
setConnectionState("warning", "stream error");
});
}
function handleStreamEnvelope(envelope) {
const message = envelope.message;
if (!message) {
return;
}
if (!apiState.snapshot) {
apiState.snapshot = {
api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
state: null,
preview: null,
};
}
if (message.type === "snapshot") {
apiState.snapshot.api_version = envelope.api_version;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
apiState.snapshot.state = message.payload;
renderState();
return;
}
if (message.type === "preview") {
apiState.snapshot.preview = message.payload;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
renderPreview();
renderSnapshotJson();
return;
}
if (message.type === "event") {
pushEvent({
at: `${envelope.generated_at_millis} ms`,
message: message.payload.message,
});
}
}
async function sendCommand(command) {
try {
const response = await fetchJson("/api/v1/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: `web-${Date.now()}`,
command,
}),
});
pushEvent({
at: new Date().toLocaleTimeString(),
message: response.summary,
});
await refreshAll();
} catch (error) {
console.error(error);
pushEvent({
at: new Date().toLocaleTimeString(),
message: `Command failed: ${error.message}`,
});
}
}
function debounceCommand(key, command) {
const existing = apiState.commandTimers.get(key);
if (existing) {
window.clearTimeout(existing);
}
const timeoutId = window.setTimeout(() => {
sendCommand(command);
apiState.commandTimers.delete(key);
}, 120);
apiState.commandTimers.set(key, timeoutId);
}
async function fetchJson(url, options) {
const response = await window.fetch(url, options);
const body = await response.text();
let payload = null;
try {
payload = body ? JSON.parse(body) : null;
} catch (error) {
throw new Error(`Invalid JSON from ${url}`);
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || response.statusText;
throw new Error(message);
}
return payload;
}
function renderAll() {
renderState();
renderPreview();
renderEvents();
}
function renderState() {
if (!apiState.snapshot?.state) {
return;
}
const snapshot = apiState.snapshot;
const state = snapshot.state;
const global = state.global;
const scene = state.active_scene;
dom.projectName.textContent = state.system.project_name;
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`;
dom.patternSelect.innerHTML = "";
(apiState.catalog?.patterns || []).forEach((pattern) => {
const option = document.createElement("option");
option.value = pattern.pattern_id;
option.textContent = `${pattern.display_name} (${pattern.pattern_id})`;
option.selected = pattern.pattern_id === global.selected_pattern;
dom.patternSelect.appendChild(option);
});
dom.transitionSlider.value = String(global.transition_duration_ms);
dom.transitionValue.textContent = `${global.transition_duration_ms} ms`;
dom.brightnessSlider.value = String(global.master_brightness);
dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`;
dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout";
dom.blackoutButton.classList.toggle("is-active", global.blackout);
renderPresetButtons(scene, global);
renderGroupButtons(global);
renderSceneParameters(scene);
renderSummaryCards(state, snapshot.generated_at_millis);
renderSnapshotJson();
dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`;
}
function renderPresetButtons(scene) {
dom.presetList.innerHTML = "";
const presets = apiState.presets || [];
if (!presets.length) {
dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>';
return;
}
presets.forEach((preset) => {
const button = document.createElement("button");
button.type = "button";
button.className = "preset-button";
button.classList.toggle("active", scene.preset_id === preset.preset_id);
button.innerHTML = `
<strong>${preset.preset_id}</strong>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_duration_ms} ms</div>
`;
button.addEventListener("click", () =>
sendCommand({
type: "recall_preset",
payload: { preset_id: preset.preset_id },
})
);
dom.presetList.appendChild(button);
});
}
function renderGroupButtons(global) {
dom.groupList.innerHTML = "";
const allButton = document.createElement("button");
allButton.type = "button";
allButton.className = "group-button";
allButton.classList.toggle("active", !global.selected_group);
allButton.innerHTML = "<strong>all_panels</strong><div class=\"pill-subtext\">global target</div>";
allButton.addEventListener("click", () =>
sendCommand({
type: "select_group",
payload: { group_id: null },
})
);
dom.groupList.appendChild(allButton);
(apiState.groups || []).forEach((group) => {
const button = document.createElement("button");
button.type = "button";
button.className = "group-button";
button.classList.toggle("active", group.group_id === global.selected_group);
button.innerHTML = `
<strong>${group.group_id}</strong>
<div class="pill-subtext">${group.member_count} members</div>
`;
button.addEventListener("click", () =>
sendCommand({
type: "select_group",
payload: { group_id: group.group_id },
})
);
dom.groupList.appendChild(button);
});
}
function renderSceneParameters(scene) {
dom.sceneParams.innerHTML = "";
const parameters = scene.parameters || [];
if (!parameters.length) {
dom.sceneParams.innerHTML =
'<div class="empty-state">This pattern has no exposed scene parameters.</div>';
return;
}
parameters.forEach((parameter) => {
const card = document.createElement("div");
card.className = "parameter-card";
if (parameter.kind === "scalar") {
const currentValue = Number(parameter.value.value || 0);
card.innerHTML = `
<label>
<strong>${parameter.label}</strong>
<span>${parameter.key}</span>
<input
type="range"
min="${parameter.min_scalar ?? 0}"
max="${parameter.max_scalar ?? 1}"
step="${parameter.step ?? 0.01}"
value="${currentValue}"
/>
<span>${currentValue.toFixed(2)}</span>
</label>
`;
const slider = card.querySelector("input");
const readout = card.querySelector("span:last-of-type");
slider.addEventListener("input", (event) => {
const value = Number(event.target.value);
readout.textContent = value.toFixed(2);
debounceCommand(`param:${parameter.key}`, {
type: "set_scene_parameter",
payload: {
key: parameter.key,
value: { kind: "scalar", value },
},
});
});
} else if (parameter.kind === "toggle") {
const checked = Boolean(parameter.value.value);
card.innerHTML = `
<label>
<strong>${parameter.label}</strong>
<span>${parameter.key}</span>
<input type="checkbox" ${checked ? "checked" : ""} />
</label>
`;
const checkbox = card.querySelector("input");
checkbox.addEventListener("change", (event) =>
sendCommand({
type: "set_scene_parameter",
payload: {
key: parameter.key,
value: { kind: "toggle", value: event.target.checked },
},
})
);
} else {
const currentValue = parameter.value.value || "";
card.innerHTML = `
<label>
<strong>${parameter.label}</strong>
<span>${parameter.key}</span>
<input type="text" value="${escapeHtml(currentValue)}" />
</label>
`;
const input = card.querySelector("input");
input.addEventListener("change", (event) =>
sendCommand({
type: "set_scene_parameter",
payload: {
key: parameter.key,
value: { kind: "text", value: event.target.value },
},
})
);
}
dom.sceneParams.appendChild(card);
});
}
function renderPreview() {
const preview = apiState.snapshot?.preview;
dom.previewGrid.innerHTML = "";
if (!preview?.panels?.length) {
dom.previewGrid.innerHTML =
'<div class="empty-state">Preview stream is waiting for panel snapshots.</div>';
return;
}
const panels = [...preview.panels].sort(comparePreviewPanels);
panels.forEach((panel) => {
const card = document.createElement("article");
card.className = "preview-card";
card.style.setProperty("--preview-color", panel.representative_color_hex);
card.innerHTML = `
<div class="preview-card-header">
<div>
<h3>${panel.node_id}</h3>
<div class="preview-meta">${panel.panel_position} / ${panel.source}</div>
</div>
<strong>${panel.energy_percent}%</strong>
</div>
<div class="preview-swatch"></div>
<div class="sample-row">
${panel.sample_led_hex
.map(
(hex) =>
`<span class="sample-dot" style="--sample-color: ${hex}" title="${hex}"></span>`
)
.join("")}
</div>
`;
dom.previewGrid.appendChild(card);
});
}
function renderSummaryCards(state, generatedAtMillis) {
const scene = state.active_scene;
const global = state.global;
const engine = state.engine;
const nodeStats = summarizeNodes(state.nodes || []);
const cards = [
{
label: "Active Pattern",
value: scene.pattern_id,
detail: scene.preset_id ? `Preset ${scene.preset_id}` : "live scene",
},
{
label: "Group Target",
value: scene.target_group || "all_panels",
detail: `${(apiState.groups || []).length} groups available`,
},
{
label: "Transition",
value: `${global.transition_duration_ms} ms`,
detail: engine.active_transition
? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%`
: "idle",
},
{
label: "Brightness",
value: `${Math.round(global.master_brightness * 100)}%`,
detail: global.blackout ? "blackout active" : "output live",
},
{
label: "Engine",
value: `${engine.frame_hz} fps target`,
detail: `${engine.logic_hz} hz logic / frame ${engine.frame_index}`,
},
{
label: "Nodes",
value: `${nodeStats.online}/${state.nodes.length} online`,
detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`,
},
{
label: "Preview Timestamp",
value: `${generatedAtMillis} ms`,
detail: `${state.system.schema_version} schema`,
},
];
dom.summaryCards.innerHTML = cards
.map(
(card) => `
<div class="summary-card">
<strong>${card.value}</strong>
<span>${card.label}</span>
<div class="preview-meta">${card.detail}</div>
</div>
`
)
.join("");
}
function renderSnapshotJson() {
dom.snapshotJson.textContent = apiState.snapshot
? JSON.stringify(apiState.snapshot, null, 2)
: "No snapshot loaded.";
}
function pushEvent(entry) {
apiState.events.unshift(entry);
apiState.events = apiState.events.slice(0, 12);
renderEvents();
}
function renderEvents() {
if (!apiState.events.length) {
dom.eventList.innerHTML = '<div class="empty-state">No websocket notices yet.</div>';
return;
}
dom.eventList.innerHTML = apiState.events
.map(
(entry) => `
<article class="event-item">
<div class="event-meta">${entry.at}</div>
<strong>${entry.message}</strong>
</article>
`
)
.join("");
}
function setConnectionState(kind, message) {
dom.connectionPill.textContent = message;
dom.connectionPill.className =
kind === "online"
? "pill pill-online"
: kind === "warning"
? "pill pill-warning"
: "pill pill-offline";
}
function summarizeNodes(nodes) {
return nodes.reduce(
(summary, node) => {
summary[node.connection] += 1;
return summary;
},
{ online: 0, degraded: 0, offline: 0 }
);
}
function comparePreviewPanels(left, right) {
const leftNode = left.node_id.localeCompare(right.node_id);
if (leftNode !== 0) {
return leftNode;
}
return panelPositionRank(left.panel_position) - panelPositionRank(right.panel_position);
}
function panelPositionRank(position) {
if (position === "top") {
return 0;
}
if (position === "middle") {
return 1;
}
return 2;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
init();
})();

121
web/v1/index.html Normal file
View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Infinity Vis Creative Console</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Infinity Vis / Creative Surface</p>
<h1 id="project-name">Loading project...</h1>
<p id="topology-label" class="hero-subtitle">
Shared host API bootstrap in progress.
</p>
</div>
<div class="hero-status">
<div class="status-card">
<span class="status-label">API stream</span>
<span id="connection-pill" class="pill pill-offline">connecting</span>
</div>
<div class="status-card">
<span class="status-label">Preview refresh</span>
<span id="preview-updated">waiting for data</span>
</div>
<button id="refresh-button" class="ghost-button" type="button">
Refresh snapshot
</button>
</div>
</header>
<main class="layout">
<section class="panel controls-panel">
<div class="section-heading">
<h2>Global Look</h2>
<p>Pattern, preset, group and transition control against the shared host API.</p>
</div>
<div class="control-grid">
<label class="field">
<span>Pattern</span>
<select id="pattern-select"></select>
</label>
<label class="field">
<span>Transition</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>Master Brightness</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
<strong id="brightness-value">0%</strong>
</label>
<div class="field">
<span>Blackout</span>
<button id="blackout-button" class="danger-button" type="button">
Enable blackout
</button>
</div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Presets</h3>
<p>Recall look snapshots without leaving the creative console.</p>
</div>
<div id="preset-list" class="pill-row"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Groups</h3>
<p>Focus looks on a subset while keeping the core scene model shared.</p>
</div>
<div id="group-list" class="pill-row"></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 class="panel preview-panel">
<div class="section-heading">
<h2>Preview</h2>
<p>Live panel previews from the host snapshot and stream feed.</p>
</div>
<div id="preview-grid" class="preview-grid"></div>
</section>
<section class="panel summary-panel">
<div class="section-heading">
<h2>Snapshot</h2>
<p>Operator-friendly scene state with a raw API view underneath.</p>
</div>
<div id="summary-cards" class="summary-cards"></div>
<pre id="snapshot-json" class="snapshot-json"></pre>
</section>
<section class="panel event-panel">
<div class="section-heading">
<h2>Event Stream</h2>
<p>Recent notices from the websocket feed.</p>
</div>
<div id="event-list" class="event-list"></div>
</section>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

490
web/v1/styles.css Normal file
View File

@@ -0,0 +1,490 @@
:root {
--bg: #f3ede2;
--bg-secondary: #efe2cd;
--surface: rgba(255, 251, 244, 0.82);
--surface-strong: rgba(255, 248, 238, 0.94);
--line: rgba(56, 63, 61, 0.12);
--text: #1f2424;
--muted: #596463;
--accent: #ea6a36;
--accent-strong: #c34d1c;
--accent-cool: #198c8f;
--danger: #bc2f2f;
--shadow: 0 24px 60px rgba(91, 63, 38, 0.12);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--radius-sm: 12px;
--font-sans: "Segoe UI Variable", "Aptos", "Trebuchet MS", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background:
radial-gradient(circle at 10% 15%, rgba(234, 106, 54, 0.22), transparent 28%),
radial-gradient(circle at 88% 12%, rgba(25, 140, 143, 0.16), transparent 24%),
radial-gradient(circle at 84% 78%, rgba(239, 202, 130, 0.24), transparent 22%),
linear-gradient(160deg, var(--bg) 0%, var(--bg-secondary) 52%, #f6f2ea 100%);
color: var(--text);
font-family: var(--font-sans);
}
body::before,
body::after {
content: "";
position: fixed;
inset: auto;
pointer-events: none;
border-radius: 999px;
filter: blur(32px);
opacity: 0.55;
}
body::before {
width: 280px;
height: 280px;
top: 18%;
right: -80px;
background: rgba(234, 106, 54, 0.16);
}
body::after {
width: 320px;
height: 320px;
bottom: -120px;
left: -60px;
background: rgba(25, 140, 143, 0.12);
}
.page-shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.hero,
.panel {
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr);
gap: 24px;
padding: 28px;
border-radius: var(--radius-xl);
animation: rise-in 520ms ease-out;
}
.hero-copy h1,
.section-heading h2,
.subsection-heading h3 {
margin: 0;
letter-spacing: -0.03em;
}
.hero-copy h1 {
font-size: clamp(2rem, 3vw, 3.6rem);
line-height: 0.95;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent-strong);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.hero-subtitle,
.section-heading p,
.subsection-heading p,
.field span,
.event-meta,
.status-label,
.preview-meta,
.pill-subtext {
color: var(--muted);
}
.hero-status {
display: grid;
gap: 14px;
align-content: start;
}
.status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--surface-strong);
border-radius: var(--radius-md);
border: 1px solid rgba(56, 63, 61, 0.08);
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 98px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pill-online {
background: rgba(25, 140, 143, 0.14);
color: #0f6c6d;
}
.pill-offline {
background: rgba(188, 47, 47, 0.14);
color: var(--danger);
}
.pill-warning {
background: rgba(234, 106, 54, 0.14);
color: var(--accent-strong);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 20px;
margin-top: 22px;
}
.panel {
border-radius: var(--radius-lg);
padding: 22px;
}
.controls-panel,
.summary-panel {
grid-column: span 1;
}
.preview-panel,
.event-panel {
grid-column: span 1;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: end;
gap: 18px;
margin-bottom: 18px;
}
.section-heading p,
.subsection-heading p {
margin: 0;
max-width: 34rem;
}
.control-grid,
.parameter-grid,
.summary-cards {
display: grid;
gap: 14px;
}
.control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.parameter-grid,
.summary-cards {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.field,
.parameter-card,
.summary-card,
.preview-card,
.event-item {
background: var(--surface-strong);
border: 1px solid rgba(56, 63, 61, 0.08);
border-radius: var(--radius-md);
}
.field {
display: grid;
gap: 10px;
padding: 14px;
}
.field strong {
color: var(--accent-strong);
font-size: 1rem;
}
.subsection {
margin-top: 24px;
}
.subsection-heading {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: baseline;
margin-bottom: 12px;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button,
select,
input,
textarea {
font: inherit;
}
button,
select {
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
button {
cursor: pointer;
transition:
transform 140ms ease,
box-shadow 140ms ease,
background-color 140ms ease,
border-color 140ms ease;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
select,
input[type="text"] {
width: 100%;
padding: 12px 14px;
background: #fffdfa;
border: 1px solid rgba(56, 63, 61, 0.14);
color: var(--text);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.ghost-button,
.preset-button,
.group-button {
padding: 11px 14px;
background: #fff9f1;
border-color: rgba(56, 63, 61, 0.12);
color: var(--text);
}
.preset-button.active,
.group-button.active {
background: linear-gradient(135deg, rgba(234, 106, 54, 0.16), rgba(25, 140, 143, 0.16));
border-color: rgba(234, 106, 54, 0.35);
}
.danger-button {
padding: 12px 16px;
background: rgba(188, 47, 47, 0.1);
color: var(--danger);
border-color: rgba(188, 47, 47, 0.18);
}
.danger-button.is-active {
background: var(--danger);
color: #fff8f5;
box-shadow: 0 16px 30px rgba(188, 47, 47, 0.24);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.preview-card {
padding: 14px;
position: relative;
overflow: hidden;
animation: rise-in 380ms ease-out;
}
.preview-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 8px;
background: var(--preview-color, #999999);
}
.preview-card-header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.preview-card h3 {
margin: 0;
font-size: 1rem;
}
.preview-meta {
margin-top: 2px;
font-size: 0.86rem;
}
.preview-swatch {
height: 56px;
margin-top: 14px;
border-radius: var(--radius-sm);
background: var(--preview-color, #999999);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
}
.sample-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.sample-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--sample-color, #999999);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.48);
}
.parameter-card,
.summary-card,
.event-item {
padding: 14px;
}
.parameter-card label {
display: grid;
gap: 10px;
}
.parameter-card strong,
.summary-card strong {
display: block;
margin-bottom: 4px;
font-size: 1.04rem;
}
.summary-card span {
color: var(--muted);
font-size: 0.9rem;
}
.snapshot-json {
margin: 18px 0 0;
max-height: 360px;
overflow: auto;
padding: 18px;
border-radius: var(--radius-md);
background: #1d2222;
color: #e8eceb;
font-size: 0.86rem;
line-height: 1.5;
}
.event-list {
display: grid;
gap: 12px;
}
.event-item {
display: grid;
gap: 8px;
}
.event-item strong {
color: var(--accent-strong);
}
.empty-state {
padding: 18px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.42);
color: var(--muted);
border: 1px dashed rgba(56, 63, 61, 0.16);
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1080px) {
.layout,
.hero,
.control-grid {
grid-template-columns: 1fr;
}
.section-heading,
.subsection-heading {
align-items: start;
flex-direction: column;
}
}
@media (max-width: 720px) {
.page-shell {
width: min(100vw - 18px, 100%);
padding-top: 18px;
}
.hero,
.panel {
padding: 18px;
}
.preview-grid,
.parameter-grid,
.summary-cards {
grid-template-columns: 1fr;
}
}