From a37a3c5cbeaf2366bd0697cd8e1ef23a23a2cecf Mon Sep 17 00:00:00 2001 From: JFly02 Date: Fri, 17 Apr 2026 11:58:07 +0200 Subject: [PATCH] =?UTF-8?q?Der=20n=C3=A4chste=20Layer=20ist=20jetzt=20als?= =?UTF-8?q?=20echte=20gemeinsame=20Au=C3=9Fenkante=20umgesetzt.=20Das=20ne?= =?UTF-8?q?ue=20API-Crate=20in=20[server.rs](),=20[dto.rs]()=20und?= =?UTF-8?q?=20[main.rs]()=20stellt=20d?= =?UTF-8?q?ie=20geforderten=20versionierten=20Endpunkte=20bereit:=20`GET?= =?UTF-8?q?=20/api/v1/snapshot`,=20`GET=20/api/v1/catalog`,=20`GET=20/api/?= =?UTF-8?q?v1/presets`,=20`GET=20/api/v1/groups`,=20`POST=20/api/v1/comman?= =?UTF-8?q?d`=20und=20`WS=20/api/v1/stream`.=20Die=20API=20trennt=20jetzt?= =?UTF-8?q?=20sauber=20zwischen=20Command-,=20State-,=20Preview-=20und=20E?= =?UTF-8?q?vent-Modell,=20inklusive=20stabiler=20Au=C3=9Fen-Enums=20und=20?= =?UTF-8?q?dokumentierten=20Fehlerobjekten=20statt=20eines=20unreflektiert?= =?UTF-8?q?en=201:1-Core-Leaks.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Darauf sitzt die erste Web-UI V1 direkt gegen diese API in [index.html](), [app.js]() und [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](), und die API ist mit Beispiel-Responses in [docs/host_api.md]() dokumentiert. Zusätzlich habe ich [README.md](), [docs/build_and_deploy.md]() und [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. --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 8 +- crates/infinity_host_api/Cargo.toml | 14 + crates/infinity_host_api/src/dto.rs | 745 +++++++++++++++++++++ crates/infinity_host_api/src/lib.rs | 6 + crates/infinity_host_api/src/main.rs | 32 + crates/infinity_host_api/src/server.rs | 396 +++++++++++ crates/infinity_host_api/src/websocket.rs | 141 ++++ crates/infinity_host_api/tests/contract.rs | 295 ++++++++ docs/architecture.md | 7 +- docs/build_and_deploy.md | 5 +- docs/host_api.md | 358 ++++++++-- web/v1/app.js | 591 ++++++++++++++++ web/v1/index.html | 121 ++++ web/v1/styles.css | 490 ++++++++++++++ 16 files changed, 3166 insertions(+), 55 deletions(-) create mode 100644 crates/infinity_host_api/Cargo.toml create mode 100644 crates/infinity_host_api/src/dto.rs create mode 100644 crates/infinity_host_api/src/lib.rs create mode 100644 crates/infinity_host_api/src/main.rs create mode 100644 crates/infinity_host_api/src/server.rs create mode 100644 crates/infinity_host_api/src/websocket.rs create mode 100644 crates/infinity_host_api/tests/contract.rs create mode 100644 web/v1/app.js create mode 100644 web/v1/index.html create mode 100644 web/v1/styles.css diff --git a/Cargo.lock b/Cargo.lock index 53ed0d7..ac3a8c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,6 +1708,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "infinity_host_api" +version = "0.1.0" +dependencies = [ + "clap", + "infinity_config", + "infinity_host", + "serde", + "serde_json", +] + [[package]] name = "infinity_host_ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dfde743..6c9991c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/infinity_config", "crates/infinity_protocol", "crates/infinity_host", + "crates/infinity_host_api", "crates/infinity_host_ui", ] resolver = "2" diff --git a/README.md b/README.md index 1522888..4fa46b8 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Current software priority: - stable host-core first - shared host API for every surface - simulation and mock-first creative workflow +- web UI as the primary creative surface - 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 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). 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`. -5. Start the engineering GUI with `cargo run -p infinity_host_ui`. -6. Use the host CLI to validate the project config before attempting activation. +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. 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 diff --git a/crates/infinity_host_api/Cargo.toml b/crates/infinity_host_api/Cargo.toml new file mode 100644 index 0000000..ecb4a3d --- /dev/null +++ b/crates/infinity_host_api/Cargo.toml @@ -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" } + diff --git a/crates/infinity_host_api/src/dto.rs b/crates/infinity_host_api/src/dto.rs new file mode 100644 index 0000000..802ac4f --- /dev/null +++ b/crates/infinity_host_api/src/dto.rs @@ -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, + pub presets: Vec, + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPresetListResponse { + pub api_version: &'static str, + pub presets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiGroupListResponse { + pub api_version: &'static str, + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCommandRequest { + #[serde(default)] + pub request_id: Option, + pub command: ApiCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCommandResponse { + pub api_version: &'static str, + pub accepted: bool, + pub request_id: Option, + 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, + pub panels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPreviewSnapshot { + pub generated_at_millis: u64, + pub panels: Vec, +} + +#[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, + 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, +} + +#[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, + pub pattern_id: String, + pub seed: u64, + pub palette: Vec, + pub parameters: Vec, + pub target_group: Option, + 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, + pub max_scalar: Option, + pub step: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPatternCatalogEntry { + pub pattern_id: String, + pub display_name: String, + pub description: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPatternParameter { + pub key: String, + pub label: String, + pub kind: ApiParameterKind, + pub min_scalar: Option, + pub max_scalar: Option, + pub step: Option, + 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, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiNodeStatus { + pub node_id: String, + pub display_name: String, + pub reserved_ip: Option, + pub connection: ApiConnectionState, + pub last_contact_ms: u64, + pub error_status: Option, + 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, + pub error_status: Option, +} + +#[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, + 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, + }, + 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(¶meter.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(¶meter.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 { + 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, message: impl Into) -> Self { + Self { + api_version: API_VERSION, + error: ApiErrorBody { + code: code.into(), + message: message.into(), + }, + } + } +} diff --git a/crates/infinity_host_api/src/lib.rs b/crates/infinity_host_api/src/lib.rs new file mode 100644 index 0000000..774078a --- /dev/null +++ b/crates/infinity_host_api/src/lib.rs @@ -0,0 +1,6 @@ +mod dto; +mod server; +mod websocket; + +pub use dto::*; +pub use server::*; diff --git a/crates/infinity_host_api/src/main.rs b/crates/infinity_host_api/src/main.rs new file mode 100644 index 0000000..77154f1 --- /dev/null +++ b/crates/infinity_host_api/src/main.rs @@ -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> { + let cli = Cli::parse(); + let project = load_project(&cli.config)?; + let service: Arc = 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> { + Ok(load_project_from_path(path)?) +} diff --git a/crates/infinity_host_api/src/server.rs b/crates/infinity_host_api/src/server.rs new file mode 100644 index 0000000..9f9ce51 --- /dev/null +++ b/crates/infinity_host_api/src/server.rs @@ -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, + accept_thread: Option>, +} + +impl HostApiServer { + pub fn bind(bind: &str, service: Arc) -> io::Result { + 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, shutdown: Arc) { + 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) -> 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, +) -> io::Result<()> { + let parsed = serde_json::from_slice::(&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, +) -> 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::>(); + 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( + 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, + message: impl Into, +) -> 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, + body: Vec, +} + +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 { + 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 { + 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::().ok() + } else { + None + } + }) + }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") +} diff --git a/crates/infinity_host_api/src/websocket.rs b/crates/infinity_host_api/src/websocket.rs new file mode 100644 index 0000000..36115b9 --- /dev/null +++ b/crates/infinity_host_api/src/websocket.rs @@ -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="); + } +} diff --git a/crates/infinity_host_api/tests/contract.rs b/crates/infinity_host_api/tests/contract.rs new file mode 100644 index 0000000..1cd7eff --- /dev/null +++ b/crates/infinity_host_api/tests/contract.rs @@ -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 = SimulationHostService::spawn_shared(sample_project()); + HostApiServer::bind("127.0.0.1:0", service).expect("server must bind") +} + +struct HttpResponse { + status_code: u16, + headers: HashMap, + 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::() + .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::>(); + + 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 { + 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") +} diff --git a/docs/architecture.md b/docs/architecture.md index 9bab649..00e7377 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,7 +101,8 @@ The codebase deliberately blocks activation when these remain unresolved: ## Planned Next Steps -1. Add a network-facing adapter for the shared host API and start the web UI -2. Expand scene authoring and preset editing on top of the existing simulation core +1. Expand creative authoring on top of the now-versioned host API and web UI +2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin 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 diff --git a/docs/build_and_deploy.md b/docs/build_and_deploy.md index 126ddbc..481abb3 100644 --- a/docs/build_and_deploy.md +++ b/docs/build_and_deploy.md @@ -12,12 +12,15 @@ Suggested commands: ```powershell cargo test 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 -- 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 ``` -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: diff --git a/docs/host_api.md b/docs/host_api.md index da6340d..8af29c2 100644 --- a/docs/host_api.md +++ b/docs/host_api.md @@ -2,79 +2,341 @@ ## 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 -- 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 core rule stays unchanged: + +- 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 -The API lives in: +Runtime pieces: - `crates/infinity_host/src/control.rs` - `crates/infinity_host/src/scene.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 -- global controls -- 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 +```text +http://127.0.0.1:9001/ +``` -This makes it suitable as the shared read model for: +## Versioning -- engineering GUI -- upcoming web UI -- CLI inspection -- later external control bridges +- HTTP and WebSocket routes are versioned under `/api/v1` +- responses include `api_version: "v1"` +- the external DTOs are intentionally not a direct 1:1 dump of internal core structs -## 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 - preset recall - group selection -- scene parameter changes -- transition duration changes -- per-panel test triggers +- global brightness +- blackout +- 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 -- preset development -- parameter tuning -- future web UI integration -- API contract testing before hardware activation +The API contract is currently verified in: -It simulates: +- `crates/infinity_host_api/tests/contract.rs` -- active scene state -- pattern rendering previews -- group gating -- transitions -- node connectivity status -- per-panel mapping tests +Covered paths: -## 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. -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. -4. Add grandMA later as an external show-control adapter against this API. +## Future Direction + +Next adapters should be built on this boundary instead of reaching into the host core directly. + +That includes: + +- a richer web authoring surface +- remote operator clients +- a grandMA bridge that translates external show control into host API commands diff --git a/web/v1/app.js b/web/v1/app.js new file mode 100644 index 0000000..272cb90 --- /dev/null +++ b/web/v1/app.js @@ -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 = '
No presets available.
'; + return; + } + + presets.forEach((preset) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "preset-button"; + button.classList.toggle("active", scene.preset_id === preset.preset_id); + button.innerHTML = ` + ${preset.preset_id} +
${preset.pattern_id} / ${preset.transition_duration_ms} ms
+ `; + 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 = "all_panels
global target
"; + 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 = ` + ${group.group_id} +
${group.member_count} members
+ `; + 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 = + '
This pattern has no exposed scene parameters.
'; + 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 = ` + + `; + 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 = ` + + `; + 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 = ` + + `; + 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 = + '
Preview stream is waiting for panel snapshots.
'; + 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 = ` +
+
+

${panel.node_id}

+
${panel.panel_position} / ${panel.source}
+
+ ${panel.energy_percent}% +
+
+
+ ${panel.sample_led_hex + .map( + (hex) => + `` + ) + .join("")} +
+ `; + 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) => ` +
+ ${card.value} + ${card.label} +
${card.detail}
+
+ ` + ) + .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 = '
No websocket notices yet.
'; + return; + } + + dom.eventList.innerHTML = apiState.events + .map( + (entry) => ` +
+
${entry.at}
+ ${entry.message} +
+ ` + ) + .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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + } + + init(); +})(); diff --git a/web/v1/index.html b/web/v1/index.html new file mode 100644 index 0000000..c60a733 --- /dev/null +++ b/web/v1/index.html @@ -0,0 +1,121 @@ + + + + + + Infinity Vis Creative Console + + + +
+
+
+

Infinity Vis / Creative Surface

+

Loading project...

+

+ Shared host API bootstrap in progress. +

+
+
+
+ API stream + connecting +
+
+ Preview refresh + waiting for data +
+ +
+
+ +
+
+
+

Global Look

+

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

+
+ +
+ + + + + + +
+ Blackout + +
+
+ +
+
+

Presets

+

Recall look snapshots without leaving the creative console.

+
+
+
+ +
+
+

Groups

+

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

+
+
+
+ +
+
+

Scene Parameters

+

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

+
+
+
+
+ +
+
+

Preview

+

Live panel previews from the host snapshot and stream feed.

+
+
+
+ +
+
+

Snapshot

+

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

+
+
+

+        
+ +
+
+

Event Stream

+

Recent notices from the websocket feed.

+
+
+
+
+
+ + + + diff --git a/web/v1/styles.css b/web/v1/styles.css new file mode 100644 index 0000000..d7d99ea --- /dev/null +++ b/web/v1/styles.css @@ -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; + } +}