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:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/infinity_config",
|
||||
"crates/infinity_protocol",
|
||||
"crates/infinity_host",
|
||||
"crates/infinity_host_api",
|
||||
"crates/infinity_host_ui",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
crates/infinity_host_api/Cargo.toml
Normal file
14
crates/infinity_host_api/Cargo.toml
Normal 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" }
|
||||
|
||||
745
crates/infinity_host_api/src/dto.rs
Normal file
745
crates/infinity_host_api/src/dto.rs
Normal 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(¶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<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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
6
crates/infinity_host_api/src/lib.rs
Normal file
6
crates/infinity_host_api/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod dto;
|
||||
mod server;
|
||||
mod websocket;
|
||||
|
||||
pub use dto::*;
|
||||
pub use server::*;
|
||||
32
crates/infinity_host_api/src/main.rs
Normal file
32
crates/infinity_host_api/src/main.rs
Normal 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)?)
|
||||
}
|
||||
396
crates/infinity_host_api/src/server.rs
Normal file
396
crates/infinity_host_api/src/server.rs
Normal 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")
|
||||
}
|
||||
141
crates/infinity_host_api/src/websocket.rs
Normal file
141
crates/infinity_host_api/src/websocket.rs
Normal 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=");
|
||||
}
|
||||
}
|
||||
295
crates/infinity_host_api/tests/contract.rs
Normal file
295
crates/infinity_host_api/tests/contract.rs
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
358
docs/host_api.md
358
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
|
||||
|
||||
591
web/v1/app.js
Normal file
591
web/v1/app.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
121
web/v1/index.html
Normal file
121
web/v1/index.html
Normal 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
490
web/v1/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user