Software-only show-control readiness baseline
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
4521
Cargo.lock
generated
Normal file
4521
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/infinity_config",
|
||||||
|
"crates/infinity_protocol",
|
||||||
|
"crates/infinity_host",
|
||||||
|
"crates/infinity_host_api",
|
||||||
|
"crates/infinity_host_ui",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Proprietary"
|
||||||
|
authors = ["Jan", "OpenAI Codex"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
eframe = "0.29"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Infinity Vis Rust
|
||||||
|
|
||||||
|
Production-oriented greenfield architecture for a low-latency LED control system that targets:
|
||||||
|
|
||||||
|
- 6 ESP32-N16R8 nodes
|
||||||
|
- 3 physical LED outputs per node
|
||||||
|
- 106 LEDs per output
|
||||||
|
- 18 logical panels and 1908 LEDs total
|
||||||
|
|
||||||
|
The repository is intentionally structured around hard separation of concerns:
|
||||||
|
|
||||||
|
- `crates/infinity_config`: versioned project configuration and validation
|
||||||
|
- `crates/infinity_protocol`: shared control and realtime protocol model
|
||||||
|
- `crates/infinity_host`: host-side core library, simulation engine, scene/preset API, and CLI
|
||||||
|
- `crates/infinity_host_ui`: native Rust desktop engineering GUI for mapping, diagnostics, and admin
|
||||||
|
- `firmware/esp32_node`: ESP-IDF firmware skeleton with explicit driver abstraction
|
||||||
|
- `docs/`: architecture, protocol, validation, deployment, testing, and acceptance artifacts
|
||||||
|
- `config/`: example configuration files
|
||||||
|
|
||||||
|
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
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Install a current Rust toolchain.
|
||||||
|
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 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 --runtime-state data/runtime_state.json`.
|
||||||
|
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
|
||||||
|
|
||||||
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Host API](docs/host_api.md)
|
||||||
|
- [Local Software-Only Runbook](docs/local_software_only_runbook.md)
|
||||||
|
- [Show-Control Primitives](docs/show_control_primitives.md)
|
||||||
|
- [Protocol](docs/protocol.md)
|
||||||
|
- [Config Schema](docs/config_schema.md)
|
||||||
|
- [Build and Deploy](docs/build_and_deploy.md)
|
||||||
|
- [Testing](docs/testing.md)
|
||||||
|
- [Acceptance Template](docs/acceptance_template.md)
|
||||||
|
- [Legacy XML Reference](docs/legacy_xml_reference.md)
|
||||||
396
config/project.example.toml
Normal file
396
config/project.example.toml
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
[metadata]
|
||||||
|
project_name = "Infinity Vis"
|
||||||
|
schema_version = 1
|
||||||
|
default_transport_profile = "scene_default"
|
||||||
|
default_safety_profile = "live_safe"
|
||||||
|
|
||||||
|
[topology]
|
||||||
|
expected_node_count = 6
|
||||||
|
outputs_per_node = 3
|
||||||
|
leds_per_output = 106
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-01"
|
||||||
|
display_name = "ESP32 Node 01"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.101"
|
||||||
|
telemetry_label = "rig-01"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-01-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-01-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-01-bottom"
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-02"
|
||||||
|
display_name = "ESP32 Node 02"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.102"
|
||||||
|
telemetry_label = "rig-02"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-02-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-02-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-02-bottom"
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-03"
|
||||||
|
display_name = "ESP32 Node 03"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.103"
|
||||||
|
telemetry_label = "rig-03"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-03-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-03-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-03-bottom"
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-04"
|
||||||
|
display_name = "ESP32 Node 04"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.104"
|
||||||
|
telemetry_label = "rig-04"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-04-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-04-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-04-bottom"
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-05"
|
||||||
|
display_name = "ESP32 Node 05"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.105"
|
||||||
|
telemetry_label = "rig-05"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-05-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-05-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-05-bottom"
|
||||||
|
|
||||||
|
[[topology.nodes]]
|
||||||
|
node_id = "node-06"
|
||||||
|
display_name = "ESP32 Node 06"
|
||||||
|
[topology.nodes.network]
|
||||||
|
reserved_ip = "192.168.40.106"
|
||||||
|
telemetry_label = "rig-06"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "top"
|
||||||
|
physical_output_name = "UART 6"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 6" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-06-top"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "middle"
|
||||||
|
physical_output_name = "UART 5"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 5" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-06-middle"
|
||||||
|
|
||||||
|
[[topology.nodes.outputs]]
|
||||||
|
panel_position = "bottom"
|
||||||
|
physical_output_name = "UART 4"
|
||||||
|
driver_channel = { kind = "pending_validation", reference = "UART 4" }
|
||||||
|
led_count = 106
|
||||||
|
direction = "forward"
|
||||||
|
color_order = "grb"
|
||||||
|
enabled = true
|
||||||
|
validation_state = "pending_hardware_validation"
|
||||||
|
logical_panel_name = "node-06-bottom"
|
||||||
|
|
||||||
|
[[topology.groups]]
|
||||||
|
group_id = "all_panels"
|
||||||
|
tags = ["global", "default"]
|
||||||
|
members = [
|
||||||
|
{ node_id = "node-01", panel_position = "top" },
|
||||||
|
{ node_id = "node-01", panel_position = "middle" },
|
||||||
|
{ node_id = "node-01", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-02", panel_position = "top" },
|
||||||
|
{ node_id = "node-02", panel_position = "middle" },
|
||||||
|
{ node_id = "node-02", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-03", panel_position = "top" },
|
||||||
|
{ node_id = "node-03", panel_position = "middle" },
|
||||||
|
{ node_id = "node-03", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-04", panel_position = "top" },
|
||||||
|
{ node_id = "node-04", panel_position = "middle" },
|
||||||
|
{ node_id = "node-04", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-05", panel_position = "top" },
|
||||||
|
{ node_id = "node-05", panel_position = "middle" },
|
||||||
|
{ node_id = "node-05", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-06", panel_position = "top" },
|
||||||
|
{ node_id = "node-06", panel_position = "middle" },
|
||||||
|
{ node_id = "node-06", panel_position = "bottom" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[topology.groups]]
|
||||||
|
group_id = "top_panels"
|
||||||
|
tags = ["row_like", "hardware_safe"]
|
||||||
|
members = [
|
||||||
|
{ node_id = "node-01", panel_position = "top" },
|
||||||
|
{ node_id = "node-02", panel_position = "top" },
|
||||||
|
{ node_id = "node-03", panel_position = "top" },
|
||||||
|
{ node_id = "node-04", panel_position = "top" },
|
||||||
|
{ node_id = "node-05", panel_position = "top" },
|
||||||
|
{ node_id = "node-06", panel_position = "top" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[topology.groups]]
|
||||||
|
group_id = "middle_panels"
|
||||||
|
tags = ["row_like", "hardware_safe"]
|
||||||
|
members = [
|
||||||
|
{ node_id = "node-01", panel_position = "middle" },
|
||||||
|
{ node_id = "node-02", panel_position = "middle" },
|
||||||
|
{ node_id = "node-03", panel_position = "middle" },
|
||||||
|
{ node_id = "node-04", panel_position = "middle" },
|
||||||
|
{ node_id = "node-05", panel_position = "middle" },
|
||||||
|
{ node_id = "node-06", panel_position = "middle" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[topology.groups]]
|
||||||
|
group_id = "bottom_panels"
|
||||||
|
tags = ["row_like", "hardware_safe"]
|
||||||
|
members = [
|
||||||
|
{ node_id = "node-01", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-02", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-03", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-04", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-05", panel_position = "bottom" },
|
||||||
|
{ node_id = "node-06", panel_position = "bottom" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[transport_profiles]]
|
||||||
|
profile_id = "scene_default"
|
||||||
|
mode = "distributed_scene"
|
||||||
|
logic_hz = 120
|
||||||
|
network_send_hz = 60
|
||||||
|
preview_hz = 15
|
||||||
|
heartbeat_hz = 5
|
||||||
|
ddp_compatibility = false
|
||||||
|
|
||||||
|
[[transport_profiles]]
|
||||||
|
profile_id = "frame_debug"
|
||||||
|
mode = "frame_streaming"
|
||||||
|
logic_hz = 120
|
||||||
|
network_send_hz = 40
|
||||||
|
preview_hz = 10
|
||||||
|
heartbeat_hz = 5
|
||||||
|
ddp_compatibility = true
|
||||||
|
|
||||||
|
[[safety_profiles]]
|
||||||
|
profile_id = "live_safe"
|
||||||
|
master_brightness_limit = 0.35
|
||||||
|
default_start_brightness = 0.20
|
||||||
|
allow_strobe = false
|
||||||
|
hold_last_frame_ms = 1500
|
||||||
|
fallback_preset_id = "safe_static_blue"
|
||||||
|
|
||||||
|
[[presets]]
|
||||||
|
preset_id = "safe_static_blue"
|
||||||
|
target_group = "all_panels"
|
||||||
|
transition_ms = 150
|
||||||
|
[presets.scene]
|
||||||
|
effect = "solid_color"
|
||||||
|
seed = 7
|
||||||
|
palette = ["#003bff"]
|
||||||
|
speed = 0.0
|
||||||
|
intensity = 1.0
|
||||||
|
blackout = false
|
||||||
|
|
||||||
|
[[presets]]
|
||||||
|
preset_id = "mapping_walk_test"
|
||||||
|
target_group = "all_panels"
|
||||||
|
transition_ms = 50
|
||||||
|
[presets.scene]
|
||||||
|
effect = "walking_pixel"
|
||||||
|
seed = 42
|
||||||
|
palette = ["#ffffff", "#000000"]
|
||||||
|
speed = 1.0
|
||||||
|
intensity = 1.0
|
||||||
|
blackout = false
|
||||||
|
|
||||||
|
[[presets]]
|
||||||
|
preset_id = "ocean_gradient"
|
||||||
|
target_group = "all_panels"
|
||||||
|
transition_ms = 500
|
||||||
|
[presets.scene]
|
||||||
|
effect = "gradient"
|
||||||
|
seed = 1337
|
||||||
|
palette = ["#041F4A", "#0F8AA6", "#7FFFD4"]
|
||||||
|
speed = 0.35
|
||||||
|
intensity = 0.82
|
||||||
|
blackout = false
|
||||||
|
|
||||||
|
[[presets]]
|
||||||
|
preset_id = "amber_chase_top"
|
||||||
|
target_group = "top_panels"
|
||||||
|
transition_ms = 120
|
||||||
|
[presets.scene]
|
||||||
|
effect = "chase"
|
||||||
|
seed = 2026
|
||||||
|
palette = ["#FF7A00", "#FFD166"]
|
||||||
|
speed = 1.40
|
||||||
|
intensity = 1.0
|
||||||
|
blackout = false
|
||||||
|
|
||||||
|
[[presets]]
|
||||||
|
preset_id = "deep_pulse_bottom"
|
||||||
|
target_group = "bottom_panels"
|
||||||
|
transition_ms = 380
|
||||||
|
[presets.scene]
|
||||||
|
effect = "pulse"
|
||||||
|
seed = 404
|
||||||
|
palette = ["#240046", "#5A189A", "#9D4EDD"]
|
||||||
|
speed = 0.80
|
||||||
|
intensity = 0.88
|
||||||
|
blackout = false
|
||||||
12
crates/infinity_config/Cargo.toml
Normal file
12
crates/infinity_config/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "infinity_config"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
|
||||||
26
crates/infinity_config/src/lib.rs
Normal file
26
crates/infinity_config/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
mod model;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
pub use model::*;
|
||||||
|
pub use validation::*;
|
||||||
|
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProjectLoadError {
|
||||||
|
#[error("failed to read project config: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("failed to parse project config: {0}")]
|
||||||
|
Parse(#[from] toml::de::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectConfig {
|
||||||
|
pub fn from_toml_str(raw: &str) -> Result<Self, toml::de::Error> {
|
||||||
|
toml::from_str(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, ProjectLoadError> {
|
||||||
|
let raw = fs::read_to_string(path)?;
|
||||||
|
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
|
||||||
|
}
|
||||||
261
crates/infinity_config/src/model.rs
Normal file
261
crates/infinity_config/src/model.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const REQUIRED_NODE_COUNT: usize = 6;
|
||||||
|
pub const REQUIRED_OUTPUTS_PER_NODE: usize = 3;
|
||||||
|
pub const REQUIRED_LED_COUNT_PER_OUTPUT: u16 = 106;
|
||||||
|
pub const REQUIRED_TOTAL_OUTPUTS: usize = REQUIRED_NODE_COUNT * REQUIRED_OUTPUTS_PER_NODE;
|
||||||
|
|
||||||
|
fn default_expected_node_count() -> usize {
|
||||||
|
REQUIRED_NODE_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_outputs_per_node() -> usize {
|
||||||
|
REQUIRED_OUTPUTS_PER_NODE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_led_count_per_output() -> u16 {
|
||||||
|
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_transition_ms() -> u32 {
|
||||||
|
150
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_network_send_hz() -> u16 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_logic_hz() -> u16 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_preview_hz() -> u16 {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_heartbeat_hz() -> u16 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_hold_last_frame_ms() -> u32 {
|
||||||
|
1500
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_brightness_limit() -> f32 {
|
||||||
|
0.35
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_start_brightness() -> f32 {
|
||||||
|
0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProjectConfig {
|
||||||
|
pub metadata: ProjectMetadata,
|
||||||
|
pub topology: TopologyConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub transport_profiles: Vec<TransportProfileConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub safety_profiles: Vec<SafetyProfileConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub presets: Vec<PresetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProjectMetadata {
|
||||||
|
pub project_name: String,
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub default_transport_profile: String,
|
||||||
|
pub default_safety_profile: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TopologyConfig {
|
||||||
|
#[serde(default = "default_expected_node_count")]
|
||||||
|
pub expected_node_count: usize,
|
||||||
|
#[serde(default = "default_outputs_per_node")]
|
||||||
|
pub outputs_per_node: usize,
|
||||||
|
#[serde(default = "default_led_count_per_output")]
|
||||||
|
pub leds_per_output: u16,
|
||||||
|
pub nodes: Vec<NodeConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout_panels: Vec<LayoutPanelConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups: Vec<GroupConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NodeConfig {
|
||||||
|
pub node_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub network: NodeNetworkConfig,
|
||||||
|
pub outputs: Vec<PanelOutputConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NodeNetworkConfig {
|
||||||
|
pub reserved_ip: Option<String>,
|
||||||
|
pub telemetry_label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PanelOutputConfig {
|
||||||
|
pub panel_position: PanelPosition,
|
||||||
|
pub physical_output_name: String,
|
||||||
|
pub driver_channel: DriverChannelRef,
|
||||||
|
pub led_count: u16,
|
||||||
|
pub direction: LedDirection,
|
||||||
|
pub color_order: ColorOrder,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
pub validation_state: ValidationState,
|
||||||
|
pub logical_panel_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DriverChannelRef {
|
||||||
|
pub kind: DriverKind,
|
||||||
|
pub reference: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PanelPosition {
|
||||||
|
Top,
|
||||||
|
Middle,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LedDirection {
|
||||||
|
Forward,
|
||||||
|
Reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ColorOrder {
|
||||||
|
Rgb,
|
||||||
|
Rbg,
|
||||||
|
Grb,
|
||||||
|
Gbr,
|
||||||
|
Brg,
|
||||||
|
Bgr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriverKind {
|
||||||
|
PendingValidation,
|
||||||
|
Gpio,
|
||||||
|
RmtChannel,
|
||||||
|
I2sLane,
|
||||||
|
UartPort,
|
||||||
|
SpiBus,
|
||||||
|
ExternalDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ValidationState {
|
||||||
|
PendingHardwareValidation,
|
||||||
|
Validated,
|
||||||
|
Retired,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct PanelRef {
|
||||||
|
pub node_id: String,
|
||||||
|
pub panel_position: PanelPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LayoutPanelConfig {
|
||||||
|
pub node_id: String,
|
||||||
|
pub panel_position: PanelPosition,
|
||||||
|
pub row: usize,
|
||||||
|
pub column: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rotation_degrees: i16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mirror_x: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mirror_y: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GroupConfig {
|
||||||
|
pub group_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub members: Vec<PanelRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TransportProfileConfig {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub mode: TransportMode,
|
||||||
|
#[serde(default = "default_logic_hz")]
|
||||||
|
pub logic_hz: u16,
|
||||||
|
#[serde(default = "default_network_send_hz")]
|
||||||
|
pub network_send_hz: u16,
|
||||||
|
#[serde(default = "default_preview_hz")]
|
||||||
|
pub preview_hz: u16,
|
||||||
|
#[serde(default = "default_heartbeat_hz")]
|
||||||
|
pub heartbeat_hz: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ddp_compatibility: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TransportMode {
|
||||||
|
DistributedScene,
|
||||||
|
FrameStreaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SafetyProfileConfig {
|
||||||
|
pub profile_id: String,
|
||||||
|
#[serde(default = "default_brightness_limit")]
|
||||||
|
pub master_brightness_limit: f32,
|
||||||
|
#[serde(default = "default_start_brightness")]
|
||||||
|
pub default_start_brightness: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_strobe: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_strobe_hz: Option<f32>,
|
||||||
|
#[serde(default = "default_hold_last_frame_ms")]
|
||||||
|
pub hold_last_frame_ms: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fallback_preset_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PresetConfig {
|
||||||
|
pub preset_id: String,
|
||||||
|
pub scene: SceneConfig,
|
||||||
|
#[serde(default = "default_transition_ms")]
|
||||||
|
pub transition_ms: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SceneConfig {
|
||||||
|
pub effect: String,
|
||||||
|
pub seed: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub palette: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub speed: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub intensity: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blackout: bool,
|
||||||
|
}
|
||||||
673
crates/infinity_config/src/validation.rs
Normal file
673
crates/infinity_config/src/validation.rs
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
use crate::{
|
||||||
|
DriverKind, GroupConfig, PanelPosition, PanelRef, ProjectConfig, ValidationState,
|
||||||
|
REQUIRED_LED_COUNT_PER_OUTPUT, REQUIRED_NODE_COUNT, REQUIRED_OUTPUTS_PER_NODE,
|
||||||
|
REQUIRED_TOTAL_OUTPUTS,
|
||||||
|
};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ValidationMode {
|
||||||
|
Structural,
|
||||||
|
Activation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ValidationSeverity {
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidationIssue {
|
||||||
|
pub severity: ValidationSeverity,
|
||||||
|
pub code: &'static str,
|
||||||
|
pub path: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ValidationReport {
|
||||||
|
pub issues: Vec<ValidationIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationReport {
|
||||||
|
pub fn is_ok(&self) -> bool {
|
||||||
|
self.error_count() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_count(&self) -> usize {
|
||||||
|
self.issues
|
||||||
|
.iter()
|
||||||
|
.filter(|issue| issue.severity == ValidationSeverity::Error)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning_count(&self) -> usize {
|
||||||
|
self.issues
|
||||||
|
.iter()
|
||||||
|
.filter(|issue| issue.severity == ValidationSeverity::Warning)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(
|
||||||
|
&mut self,
|
||||||
|
severity: ValidationSeverity,
|
||||||
|
code: &'static str,
|
||||||
|
path: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) {
|
||||||
|
self.issues.push(ValidationIssue {
|
||||||
|
severity,
|
||||||
|
code,
|
||||||
|
path: path.into(),
|
||||||
|
message: message.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectConfig {
|
||||||
|
pub fn validate(&self, mode: ValidationMode) -> ValidationReport {
|
||||||
|
let mut report = ValidationReport::default();
|
||||||
|
self.validate_profiles(&mut report);
|
||||||
|
self.validate_topology(mode, &mut report);
|
||||||
|
self.validate_groups(&mut report);
|
||||||
|
self.validate_safety_profiles(&mut report);
|
||||||
|
self.validate_presets(&mut report);
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_profiles(&self, report: &mut ValidationReport) {
|
||||||
|
let transport_profiles: BTreeSet<_> = self
|
||||||
|
.transport_profiles
|
||||||
|
.iter()
|
||||||
|
.map(|profile| profile.profile_id.as_str())
|
||||||
|
.collect();
|
||||||
|
let safety_profiles: BTreeSet<_> = self
|
||||||
|
.safety_profiles
|
||||||
|
.iter()
|
||||||
|
.map(|profile| profile.profile_id.as_str())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !transport_profiles.contains(self.metadata.default_transport_profile.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"missing_default_transport_profile",
|
||||||
|
"metadata.default_transport_profile",
|
||||||
|
format!(
|
||||||
|
"default transport profile '{}' does not exist",
|
||||||
|
self.metadata.default_transport_profile
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !safety_profiles.contains(self.metadata.default_safety_profile.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"missing_default_safety_profile",
|
||||||
|
"metadata.default_safety_profile",
|
||||||
|
format!(
|
||||||
|
"default safety profile '{}' does not exist",
|
||||||
|
self.metadata.default_safety_profile
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_topology(&self, mode: ValidationMode, report: &mut ValidationReport) {
|
||||||
|
let topology = &self.topology;
|
||||||
|
if topology.expected_node_count != REQUIRED_NODE_COUNT {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Warning,
|
||||||
|
"expected_node_count_override",
|
||||||
|
"topology.expected_node_count",
|
||||||
|
format!(
|
||||||
|
"expected node count is {}, project overrides to {}",
|
||||||
|
REQUIRED_NODE_COUNT, topology.expected_node_count
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if topology.outputs_per_node != REQUIRED_OUTPUTS_PER_NODE {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Warning,
|
||||||
|
"outputs_per_node_override",
|
||||||
|
"topology.outputs_per_node",
|
||||||
|
format!(
|
||||||
|
"expected outputs per node is {}, project overrides to {}",
|
||||||
|
REQUIRED_OUTPUTS_PER_NODE, topology.outputs_per_node
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if topology.leds_per_output != REQUIRED_LED_COUNT_PER_OUTPUT {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"invalid_leds_per_output",
|
||||||
|
"topology.leds_per_output",
|
||||||
|
format!(
|
||||||
|
"fixed system requirement is {} LEDs per output",
|
||||||
|
REQUIRED_LED_COUNT_PER_OUTPUT
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if topology.nodes.len() != topology.expected_node_count {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unexpected_node_count",
|
||||||
|
"topology.nodes",
|
||||||
|
format!(
|
||||||
|
"expected {} nodes, found {}",
|
||||||
|
topology.expected_node_count,
|
||||||
|
topology.nodes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut node_ids = BTreeSet::new();
|
||||||
|
let mut panel_refs = BTreeSet::new();
|
||||||
|
let mut output_count = 0usize;
|
||||||
|
|
||||||
|
for (node_index, node) in topology.nodes.iter().enumerate() {
|
||||||
|
let node_path = format!("topology.nodes[{node_index}]");
|
||||||
|
if !node_ids.insert(node.node_id.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_node_id",
|
||||||
|
format!("{node_path}.node_id"),
|
||||||
|
format!("duplicate node id '{}'", node.node_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.outputs.len() != topology.outputs_per_node {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unexpected_output_count",
|
||||||
|
format!("{node_path}.outputs"),
|
||||||
|
format!(
|
||||||
|
"expected {} outputs for node '{}', found {}",
|
||||||
|
topology.outputs_per_node,
|
||||||
|
node.node_id,
|
||||||
|
node.outputs.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut panel_positions = BTreeSet::new();
|
||||||
|
let mut driver_refs = BTreeSet::new();
|
||||||
|
|
||||||
|
for (output_index, output) in node.outputs.iter().enumerate() {
|
||||||
|
output_count += 1;
|
||||||
|
let output_path = format!("{node_path}.outputs[{output_index}]");
|
||||||
|
panel_positions.insert(output.panel_position.clone());
|
||||||
|
let panel_ref = PanelRef {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
panel_position: output.panel_position.clone(),
|
||||||
|
};
|
||||||
|
if !panel_refs.insert(panel_ref) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_panel_position",
|
||||||
|
format!("{output_path}.panel_position"),
|
||||||
|
format!(
|
||||||
|
"node '{}' has multiple '{}' outputs",
|
||||||
|
node.node_id,
|
||||||
|
display_panel_position(&output.panel_position)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.led_count != topology.leds_per_output {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"invalid_led_count",
|
||||||
|
format!("{output_path}.led_count"),
|
||||||
|
format!(
|
||||||
|
"expected {} LEDs, found {}",
|
||||||
|
topology.leds_per_output, output.led_count
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let driver_key = format!(
|
||||||
|
"{}::{}",
|
||||||
|
output.driver_channel.kind_label(),
|
||||||
|
output.driver_channel.reference
|
||||||
|
);
|
||||||
|
if !driver_refs.insert(driver_key.clone()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_driver_reference",
|
||||||
|
format!("{output_path}.driver_channel.reference"),
|
||||||
|
format!(
|
||||||
|
"node '{}' reuses driver reference '{}'",
|
||||||
|
node.node_id, driver_key
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == ValidationMode::Activation && output.enabled {
|
||||||
|
if output.driver_channel.kind == DriverKind::PendingValidation {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"pending_driver_validation",
|
||||||
|
format!("{output_path}.driver_channel.kind"),
|
||||||
|
format!(
|
||||||
|
"output '{}' cannot be activated while driver kind is pending validation",
|
||||||
|
output.physical_output_name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.validation_state != ValidationState::Validated {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"pending_output_validation",
|
||||||
|
format!("{output_path}.validation_state"),
|
||||||
|
format!(
|
||||||
|
"output '{}' is not hardware-validated yet",
|
||||||
|
output.physical_output_name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for required_position in [
|
||||||
|
PanelPosition::Top,
|
||||||
|
PanelPosition::Middle,
|
||||||
|
PanelPosition::Bottom,
|
||||||
|
] {
|
||||||
|
if !panel_positions.contains(&required_position) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"missing_panel_position",
|
||||||
|
format!("{node_path}.outputs"),
|
||||||
|
format!(
|
||||||
|
"node '{}' is missing the '{}' output",
|
||||||
|
node.node_id,
|
||||||
|
display_panel_position(&required_position)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if output_count != REQUIRED_TOTAL_OUTPUTS {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Warning,
|
||||||
|
"unexpected_total_output_count",
|
||||||
|
"topology.nodes",
|
||||||
|
format!(
|
||||||
|
"system target is {} total outputs, current config defines {}",
|
||||||
|
REQUIRED_TOTAL_OUTPUTS, output_count
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let known_panels = panel_refs;
|
||||||
|
let mut layout_refs = BTreeSet::new();
|
||||||
|
for (index, panel) in topology.layout_panels.iter().enumerate() {
|
||||||
|
let panel_ref = PanelRef {
|
||||||
|
node_id: panel.node_id.clone(),
|
||||||
|
panel_position: panel.panel_position.clone(),
|
||||||
|
};
|
||||||
|
if !known_panels.contains(&panel_ref) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unknown_layout_panel_reference",
|
||||||
|
format!("topology.layout_panels[{index}]"),
|
||||||
|
format!(
|
||||||
|
"layout references unknown panel '{}:{}'",
|
||||||
|
panel.node_id,
|
||||||
|
display_panel_position(&panel.panel_position)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !layout_refs.insert((panel.node_id.as_str(), &panel.panel_position)) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_layout_panel_reference",
|
||||||
|
format!("topology.layout_panels[{index}]"),
|
||||||
|
format!(
|
||||||
|
"layout contains a duplicate reference for '{}:{}'",
|
||||||
|
panel.node_id,
|
||||||
|
display_panel_position(&panel.panel_position)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_groups(&self, report: &mut ValidationReport) {
|
||||||
|
let known_panels: BTreeSet<_> = self
|
||||||
|
.topology
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|node| {
|
||||||
|
node.outputs.iter().map(move |output| PanelRef {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
panel_position: output.panel_position.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut group_ids = BTreeSet::new();
|
||||||
|
for (group_index, group) in self.topology.groups.iter().enumerate() {
|
||||||
|
if !group_ids.insert(group.group_id.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_group_id",
|
||||||
|
format!("topology.groups[{group_index}].group_id"),
|
||||||
|
format!("duplicate group id '{}'", group.group_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_group_members(group, group_index, &known_panels, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
|
||||||
|
let preset_ids: BTreeSet<_> = self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.map(|preset| preset.preset_id.as_str())
|
||||||
|
.collect();
|
||||||
|
for (index, profile) in self.safety_profiles.iter().enumerate() {
|
||||||
|
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"invalid_brightness_limit",
|
||||||
|
format!("safety_profiles[{index}].master_brightness_limit"),
|
||||||
|
"master brightness limit must be between 0.0 and 1.0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness)
|
||||||
|
{
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"invalid_start_brightness",
|
||||||
|
format!("safety_profiles[{index}].default_start_brightness"),
|
||||||
|
"default start brightness must not exceed the master brightness limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(preset_id) = &profile.fallback_preset_id {
|
||||||
|
if !preset_ids.contains(preset_id.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unknown_fallback_preset",
|
||||||
|
format!("safety_profiles[{index}].fallback_preset_id"),
|
||||||
|
format!("fallback preset '{}' does not exist", preset_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_presets(&self, report: &mut ValidationReport) {
|
||||||
|
let group_ids: BTreeSet<_> = self
|
||||||
|
.topology
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| group.group_id.as_str())
|
||||||
|
.collect();
|
||||||
|
let mut preset_ids = BTreeSet::new();
|
||||||
|
|
||||||
|
for (index, preset) in self.presets.iter().enumerate() {
|
||||||
|
if !preset_ids.insert(preset.preset_id.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_preset_id",
|
||||||
|
format!("presets[{index}].preset_id"),
|
||||||
|
format!("duplicate preset id '{}'", preset.preset_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group_id) = &preset.target_group {
|
||||||
|
if !group_ids.contains(group_id.as_str()) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unknown_target_group",
|
||||||
|
format!("presets[{index}].target_group"),
|
||||||
|
format!("preset references unknown group '{}'", group_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::DriverChannelRef {
|
||||||
|
fn kind_label(&self) -> &'static str {
|
||||||
|
match self.kind {
|
||||||
|
DriverKind::PendingValidation => "pending_validation",
|
||||||
|
DriverKind::Gpio => "gpio",
|
||||||
|
DriverKind::RmtChannel => "rmt_channel",
|
||||||
|
DriverKind::I2sLane => "i2s_lane",
|
||||||
|
DriverKind::UartPort => "uart_port",
|
||||||
|
DriverKind::SpiBus => "spi_bus",
|
||||||
|
DriverKind::ExternalDriver => "external_driver",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_group_members(
|
||||||
|
group: &GroupConfig,
|
||||||
|
group_index: usize,
|
||||||
|
known_panels: &BTreeSet<PanelRef>,
|
||||||
|
report: &mut ValidationReport,
|
||||||
|
) {
|
||||||
|
let mut unique_members = BTreeSet::new();
|
||||||
|
for (member_index, member) in group.members.iter().enumerate() {
|
||||||
|
if !known_panels.contains(member) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"unknown_group_member",
|
||||||
|
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||||
|
format!(
|
||||||
|
"group '{}' references unknown panel '{}:{}'",
|
||||||
|
group.group_id,
|
||||||
|
member.node_id,
|
||||||
|
display_panel_position(&member.panel_position)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !unique_members.insert((member.node_id.as_str(), &member.panel_position)) {
|
||||||
|
report.push(
|
||||||
|
ValidationSeverity::Error,
|
||||||
|
"duplicate_group_member",
|
||||||
|
format!("topology.groups[{group_index}].members[{member_index}]"),
|
||||||
|
format!(
|
||||||
|
"group '{}' contains '{}' more than once",
|
||||||
|
group.group_id,
|
||||||
|
panel_ref_label(member)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_ref_label(panel: &PanelRef) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
panel.node_id,
|
||||||
|
display_panel_position(&panel.panel_position)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_panel_position(position: &PanelPosition) -> &'static str {
|
||||||
|
match position {
|
||||||
|
PanelPosition::Top => "top",
|
||||||
|
PanelPosition::Middle => "middle",
|
||||||
|
PanelPosition::Bottom => "bottom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
ColorOrder, DriverChannelRef, LedDirection, NodeConfig, NodeNetworkConfig,
|
||||||
|
PanelOutputConfig, PanelPosition, PresetConfig, ProjectMetadata, SafetyProfileConfig,
|
||||||
|
SceneConfig, TopologyConfig, TransportMode, TransportProfileConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn build_output(
|
||||||
|
position: PanelPosition,
|
||||||
|
label: &str,
|
||||||
|
driver_kind: DriverKind,
|
||||||
|
) -> PanelOutputConfig {
|
||||||
|
PanelOutputConfig {
|
||||||
|
panel_position: position,
|
||||||
|
physical_output_name: label.to_string(),
|
||||||
|
driver_channel: DriverChannelRef {
|
||||||
|
kind: driver_kind,
|
||||||
|
reference: label.to_string(),
|
||||||
|
},
|
||||||
|
led_count: 106,
|
||||||
|
direction: LedDirection::Forward,
|
||||||
|
color_order: ColorOrder::Grb,
|
||||||
|
enabled: true,
|
||||||
|
validation_state: ValidationState::Validated,
|
||||||
|
logical_panel_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_project() -> ProjectConfig {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
for index in 1..=6 {
|
||||||
|
nodes.push(NodeConfig {
|
||||||
|
node_id: format!("node-{index:02}"),
|
||||||
|
display_name: format!("Node {index:02}"),
|
||||||
|
network: NodeNetworkConfig {
|
||||||
|
reserved_ip: Some(format!("192.168.40.1{index:02}")),
|
||||||
|
telemetry_label: None,
|
||||||
|
},
|
||||||
|
outputs: vec![
|
||||||
|
build_output(PanelPosition::Top, "GPIO_1", DriverKind::Gpio),
|
||||||
|
build_output(PanelPosition::Middle, "GPIO_2", DriverKind::Gpio),
|
||||||
|
build_output(PanelPosition::Bottom, "GPIO_3", DriverKind::Gpio),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectConfig {
|
||||||
|
metadata: ProjectMetadata {
|
||||||
|
project_name: "Test".to_string(),
|
||||||
|
schema_version: 1,
|
||||||
|
default_transport_profile: "default-scene".to_string(),
|
||||||
|
default_safety_profile: "live-safe".to_string(),
|
||||||
|
},
|
||||||
|
topology: TopologyConfig {
|
||||||
|
expected_node_count: 6,
|
||||||
|
outputs_per_node: 3,
|
||||||
|
leds_per_output: 106,
|
||||||
|
nodes,
|
||||||
|
layout_panels: Vec::new(),
|
||||||
|
groups: vec![GroupConfig {
|
||||||
|
group_id: "all".to_string(),
|
||||||
|
tags: vec!["global".to_string()],
|
||||||
|
members: vec![
|
||||||
|
PanelRef {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
panel_position: PanelPosition::Top,
|
||||||
|
},
|
||||||
|
PanelRef {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
panel_position: PanelPosition::Middle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
transport_profiles: vec![TransportProfileConfig {
|
||||||
|
profile_id: "default-scene".to_string(),
|
||||||
|
mode: TransportMode::DistributedScene,
|
||||||
|
logic_hz: 120,
|
||||||
|
network_send_hz: 60,
|
||||||
|
preview_hz: 15,
|
||||||
|
heartbeat_hz: 5,
|
||||||
|
ddp_compatibility: false,
|
||||||
|
}],
|
||||||
|
safety_profiles: vec![SafetyProfileConfig {
|
||||||
|
profile_id: "live-safe".to_string(),
|
||||||
|
master_brightness_limit: 0.3,
|
||||||
|
default_start_brightness: 0.2,
|
||||||
|
allow_strobe: false,
|
||||||
|
max_strobe_hz: None,
|
||||||
|
hold_last_frame_ms: 1200,
|
||||||
|
fallback_preset_id: Some("safe-blue".to_string()),
|
||||||
|
}],
|
||||||
|
presets: vec![PresetConfig {
|
||||||
|
preset_id: "safe-blue".to_string(),
|
||||||
|
scene: SceneConfig {
|
||||||
|
effect: "solid_color".to_string(),
|
||||||
|
seed: 7,
|
||||||
|
palette: vec!["#003bff".to_string()],
|
||||||
|
speed: 0.0,
|
||||||
|
intensity: 1.0,
|
||||||
|
blackout: false,
|
||||||
|
},
|
||||||
|
transition_ms: 150,
|
||||||
|
target_group: None,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_structurally_valid_project() {
|
||||||
|
let project = build_project();
|
||||||
|
let report = project.validate(ValidationMode::Structural);
|
||||||
|
assert!(report.is_ok(), "{report:#?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_led_count() {
|
||||||
|
let mut project = build_project();
|
||||||
|
project.topology.nodes[0].outputs[0].led_count = 105;
|
||||||
|
let report = project.validate(ValidationMode::Structural);
|
||||||
|
assert!(!report.is_ok());
|
||||||
|
assert!(report
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.code == "invalid_led_count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_driver_refs() {
|
||||||
|
let mut project = build_project();
|
||||||
|
project.topology.nodes[0].outputs[1]
|
||||||
|
.driver_channel
|
||||||
|
.reference = project.topology.nodes[0].outputs[0]
|
||||||
|
.driver_channel
|
||||||
|
.reference
|
||||||
|
.clone();
|
||||||
|
let report = project.validate(ValidationMode::Structural);
|
||||||
|
assert!(!report.is_ok());
|
||||||
|
assert!(report
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.code == "duplicate_driver_reference"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_requires_hardware_validation() {
|
||||||
|
let mut project = build_project();
|
||||||
|
project.topology.nodes[0].outputs[0].driver_channel.kind = DriverKind::PendingValidation;
|
||||||
|
project.topology.nodes[0].outputs[0].validation_state =
|
||||||
|
ValidationState::PendingHardwareValidation;
|
||||||
|
let report = project.validate(ValidationMode::Activation);
|
||||||
|
assert!(!report.is_ok());
|
||||||
|
assert!(report
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.code == "pending_driver_validation"));
|
||||||
|
assert!(report
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.code == "pending_output_validation"));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/infinity_host/Cargo.toml
Normal file
14
crates/infinity_host/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "infinity_host"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
infinity_config = { path = "../infinity_config" }
|
||||||
|
infinity_protocol = { path = "../infinity_protocol" }
|
||||||
391
crates/infinity_host/src/control.rs
Normal file
391
crates/infinity_host/src/control.rs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const HOST_API_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct HostSnapshot {
|
||||||
|
pub api_version: u16,
|
||||||
|
pub backend_label: String,
|
||||||
|
pub generated_at_millis: u64,
|
||||||
|
pub system: SystemSnapshot,
|
||||||
|
pub global: GlobalControlSnapshot,
|
||||||
|
pub engine: EngineSnapshot,
|
||||||
|
pub catalog: CatalogSnapshot,
|
||||||
|
pub active_scene: ActiveSceneSnapshot,
|
||||||
|
pub preview: PreviewSnapshot,
|
||||||
|
pub available_patterns: Vec<String>,
|
||||||
|
pub nodes: Vec<NodeSnapshot>,
|
||||||
|
pub panels: Vec<PanelSnapshot>,
|
||||||
|
pub recent_events: Vec<StatusEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SystemSnapshot {
|
||||||
|
pub project_name: String,
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub topology_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct GlobalControlSnapshot {
|
||||||
|
pub blackout: bool,
|
||||||
|
pub master_brightness: f32,
|
||||||
|
pub selected_pattern: String,
|
||||||
|
pub selected_group: Option<String>,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EngineSnapshot {
|
||||||
|
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<TransitionSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CatalogSnapshot {
|
||||||
|
pub patterns: Vec<PatternDefinition>,
|
||||||
|
pub presets: Vec<PresetSummary>,
|
||||||
|
pub groups: Vec<GroupSummary>,
|
||||||
|
pub creative_snapshots: Vec<CreativeSnapshotSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PatternDefinition {
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parameters: Vec<SceneParameterSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CatalogSource {
|
||||||
|
BuiltIn,
|
||||||
|
RuntimeUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PresetSummary {
|
||||||
|
pub preset_id: String,
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
pub source: CatalogSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GroupSummary {
|
||||||
|
pub group_id: String,
|
||||||
|
pub member_count: usize,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub source: CatalogSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CreativeSnapshotSummary {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
pub saved_at_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ActiveSceneSnapshot {
|
||||||
|
pub preset_id: Option<String>,
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub seed: u64,
|
||||||
|
pub palette: Vec<String>,
|
||||||
|
pub parameters: Vec<SceneParameterState>,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub blackout: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SceneParameterState {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: SceneParameterKind,
|
||||||
|
pub value: SceneParameterValue,
|
||||||
|
pub min_scalar: Option<f32>,
|
||||||
|
pub max_scalar: Option<f32>,
|
||||||
|
pub step: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SceneParameterSpec {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: SceneParameterKind,
|
||||||
|
pub min_scalar: Option<f32>,
|
||||||
|
pub max_scalar: Option<f32>,
|
||||||
|
pub step: Option<f32>,
|
||||||
|
pub default_value: SceneParameterValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SceneParameterKind {
|
||||||
|
Scalar,
|
||||||
|
Toggle,
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
|
||||||
|
pub enum SceneParameterValue {
|
||||||
|
Scalar(f32),
|
||||||
|
Toggle(bool),
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PreviewSnapshot {
|
||||||
|
pub panels: Vec<PreviewPanelSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PreviewPanelSnapshot {
|
||||||
|
pub target: PanelTarget,
|
||||||
|
pub representative_color_hex: String,
|
||||||
|
pub sample_led_hex: Vec<String>,
|
||||||
|
pub energy_percent: u8,
|
||||||
|
pub preview_source: PreviewSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PreviewSource {
|
||||||
|
Scene,
|
||||||
|
Transition,
|
||||||
|
PanelTest,
|
||||||
|
Blackout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TransitionSnapshot {
|
||||||
|
pub style: SceneTransitionStyle,
|
||||||
|
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 SceneTransitionStyle {
|
||||||
|
Snap,
|
||||||
|
Crossfade,
|
||||||
|
Chase,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NodeSnapshot {
|
||||||
|
pub node_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub reserved_ip: Option<String>,
|
||||||
|
pub connection: NodeConnectionState,
|
||||||
|
pub last_contact_ms: u64,
|
||||||
|
pub error_status: Option<String>,
|
||||||
|
pub panel_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PanelSnapshot {
|
||||||
|
pub target: PanelTarget,
|
||||||
|
pub physical_output_name: String,
|
||||||
|
pub driver_reference: String,
|
||||||
|
pub led_count: u16,
|
||||||
|
pub direction: LedDirection,
|
||||||
|
pub color_order: ColorOrder,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub validation_state: ValidationState,
|
||||||
|
pub connection: NodeConnectionState,
|
||||||
|
pub last_test_trigger_ms: Option<u64>,
|
||||||
|
pub error_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct StatusEvent {
|
||||||
|
pub at_millis: u64,
|
||||||
|
pub kind: StatusEventKind,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StatusEventKind {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NodeConnectionState {
|
||||||
|
Online,
|
||||||
|
Degraded,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct PanelTarget {
|
||||||
|
pub node_id: String,
|
||||||
|
pub panel_position: PanelPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "command", content = "payload")]
|
||||||
|
pub enum HostCommand {
|
||||||
|
SetBlackout(bool),
|
||||||
|
SetMasterBrightness(f32),
|
||||||
|
SelectPattern(String),
|
||||||
|
RecallPreset {
|
||||||
|
preset_id: String,
|
||||||
|
},
|
||||||
|
SelectGroup {
|
||||||
|
group_id: Option<String>,
|
||||||
|
},
|
||||||
|
SetSceneParameter {
|
||||||
|
key: String,
|
||||||
|
value: SceneParameterValue,
|
||||||
|
},
|
||||||
|
SetTransitionDurationMs(u32),
|
||||||
|
SetTransitionStyle(SceneTransitionStyle),
|
||||||
|
TriggerPanelTest {
|
||||||
|
target: PanelTarget,
|
||||||
|
pattern: TestPatternKind,
|
||||||
|
},
|
||||||
|
SavePreset {
|
||||||
|
preset_id: String,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
SaveCreativeSnapshot {
|
||||||
|
snapshot_id: String,
|
||||||
|
label: Option<String>,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
RecallCreativeSnapshot {
|
||||||
|
snapshot_id: String,
|
||||||
|
},
|
||||||
|
UpsertGroup {
|
||||||
|
group_id: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
members: Vec<PanelTarget>,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TestPatternKind {
|
||||||
|
WalkingPixel106,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CommandOutcome {
|
||||||
|
pub generated_at_millis: u64,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct HostCommandError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HostApiPort: Send + Sync {
|
||||||
|
fn snapshot(&self) -> HostSnapshot;
|
||||||
|
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HostUiPort: HostApiPort {}
|
||||||
|
|
||||||
|
impl<T: HostApiPort + ?Sized> HostUiPort for T {}
|
||||||
|
|
||||||
|
impl NodeConnectionState {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Online => "online",
|
||||||
|
Self::Degraded => "degraded",
|
||||||
|
Self::Offline => "offline",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneTransitionStyle {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Snap => "snap",
|
||||||
|
Self::Crossfade => "crossfade",
|
||||||
|
Self::Chase => "chase",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CatalogSource {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BuiltIn => "built_in",
|
||||||
|
Self::RuntimeUser => "runtime_user",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusEventKind {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Info => "info",
|
||||||
|
Self::Warning => "warning",
|
||||||
|
Self::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestPatternKind {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::WalkingPixel106 => "walking_pixel_106",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneParameterValue {
|
||||||
|
pub fn as_scalar(&self) -> Option<f32> {
|
||||||
|
match self {
|
||||||
|
Self::Scalar(value) => Some(*value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_toggle(&self) -> Option<bool> {
|
||||||
|
match self {
|
||||||
|
Self::Toggle(value) => Some(*value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(value: impl Into<String>) -> Self {
|
||||||
|
Self::Text(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostCommandError {
|
||||||
|
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
699
crates/infinity_host/src/external_control.rs
Normal file
699
crates/infinity_host/src/external_control.rs
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
use crate::{
|
||||||
|
CommandOutcome, HostApiPort, HostCommand, HostCommandError, HostSnapshot, PanelTarget,
|
||||||
|
SceneParameterValue, SceneTransitionStyle,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "primitive", content = "payload")]
|
||||||
|
pub enum ShowControlPrimitive {
|
||||||
|
Blackout {
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
|
RecallPreset {
|
||||||
|
preset_id: String,
|
||||||
|
},
|
||||||
|
RecallCreativeSnapshot {
|
||||||
|
snapshot_id: String,
|
||||||
|
},
|
||||||
|
SetMasterBrightness {
|
||||||
|
value: f32,
|
||||||
|
},
|
||||||
|
SetPattern {
|
||||||
|
pattern_id: String,
|
||||||
|
},
|
||||||
|
SetGroupParameter {
|
||||||
|
group_id: Option<String>,
|
||||||
|
key: String,
|
||||||
|
value: SceneParameterValue,
|
||||||
|
},
|
||||||
|
UpsertGroup {
|
||||||
|
group_id: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
members: Vec<PanelTarget>,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
SetTransitionStyle {
|
||||||
|
style: SceneTransitionStyle,
|
||||||
|
duration_ms: Option<u32>,
|
||||||
|
},
|
||||||
|
TriggerTransition,
|
||||||
|
RequestSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ExternalAdapterCapabilities {
|
||||||
|
pub supports_blackout: bool,
|
||||||
|
pub supports_preset_recall: bool,
|
||||||
|
pub supports_creative_snapshot_recall: bool,
|
||||||
|
pub supports_master_brightness: bool,
|
||||||
|
pub supports_pattern_staging: bool,
|
||||||
|
pub supports_group_parameter_staging: bool,
|
||||||
|
pub supports_group_upsert: bool,
|
||||||
|
pub supports_transition_staging: bool,
|
||||||
|
pub supports_explicit_trigger: bool,
|
||||||
|
pub supports_snapshot_request: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExternalAdapterCapabilities {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
supports_blackout: true,
|
||||||
|
supports_preset_recall: true,
|
||||||
|
supports_creative_snapshot_recall: true,
|
||||||
|
supports_master_brightness: true,
|
||||||
|
supports_pattern_staging: true,
|
||||||
|
supports_group_parameter_staging: true,
|
||||||
|
supports_group_upsert: true,
|
||||||
|
supports_transition_staging: true,
|
||||||
|
supports_explicit_trigger: true,
|
||||||
|
supports_snapshot_request: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ShowControlPrimitiveOutcome {
|
||||||
|
Buffered { summary: String },
|
||||||
|
Command(CommandOutcome),
|
||||||
|
Snapshot(HostSnapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct ShowControlSession {
|
||||||
|
pending_pattern_id: Option<String>,
|
||||||
|
pending_group_id: Option<Option<String>>,
|
||||||
|
pending_parameters: BTreeMap<String, SceneParameterValue>,
|
||||||
|
pending_transition_style: Option<SceneTransitionStyle>,
|
||||||
|
pending_transition_duration_ms: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ExternalShowControlPort: Send + Sync {
|
||||||
|
fn snapshot(&self) -> HostSnapshot;
|
||||||
|
fn execute_primitive(
|
||||||
|
&self,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ExternalShowControlAdapter: Send {
|
||||||
|
fn adapter_id(&self) -> &str;
|
||||||
|
fn capabilities(&self) -> ExternalAdapterCapabilities {
|
||||||
|
ExternalAdapterCapabilities::default()
|
||||||
|
}
|
||||||
|
fn apply_primitive(
|
||||||
|
&mut self,
|
||||||
|
port: &dyn HostApiPort,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowControlSession {
|
||||||
|
pub fn apply(
|
||||||
|
&mut self,
|
||||||
|
port: &dyn HostApiPort,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
|
match primitive {
|
||||||
|
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
port.send_command(HostCommand::SetBlackout(enabled))?,
|
||||||
|
)),
|
||||||
|
ShowControlPrimitive::RecallPreset { preset_id } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
port.send_command(HostCommand::RecallPreset { preset_id })?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
|
||||||
|
HostCommand::RecallCreativeSnapshot { snapshot_id },
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::SetMasterBrightness { value } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
port.send_command(HostCommand::SetMasterBrightness(value))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::SetPattern { pattern_id } => {
|
||||||
|
if pattern_id.trim().is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_pattern_id",
|
||||||
|
"pattern_id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.pending_pattern_id = Some(pattern_id.clone());
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Buffered {
|
||||||
|
summary: format!("pattern staged: {pattern_id}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::SetGroupParameter {
|
||||||
|
group_id,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
} => {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_group_parameter_key",
|
||||||
|
"group parameter key must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.pending_group_id = Some(group_id.clone());
|
||||||
|
self.pending_parameters.insert(key.clone(), value);
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Buffered {
|
||||||
|
summary: format!(
|
||||||
|
"group parameter staged: {} for {}",
|
||||||
|
key,
|
||||||
|
group_id.as_deref().unwrap_or("current_group")
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
overwrite,
|
||||||
|
} => Ok(ShowControlPrimitiveOutcome::Command(port.send_command(
|
||||||
|
HostCommand::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
overwrite,
|
||||||
|
},
|
||||||
|
)?)),
|
||||||
|
ShowControlPrimitive::SetTransitionStyle { style, duration_ms } => {
|
||||||
|
self.pending_transition_style = Some(style);
|
||||||
|
if let Some(duration_ms) = duration_ms {
|
||||||
|
self.pending_transition_duration_ms = Some(duration_ms);
|
||||||
|
}
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Buffered {
|
||||||
|
summary: format!("transition style staged: {}", style.label()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::TriggerTransition => self.trigger_transition(port),
|
||||||
|
ShowControlPrimitive::RequestSnapshot => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Snapshot(port.snapshot()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger_transition(
|
||||||
|
&mut self,
|
||||||
|
port: &dyn HostApiPort,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
|
let Some(pattern_id) = self.pending_pattern_id.clone() else {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"transition_pattern_required",
|
||||||
|
"trigger_transition requires a staged pattern",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(group_id) = self.pending_group_id.clone() {
|
||||||
|
port.send_command(HostCommand::SelectGroup { group_id })?;
|
||||||
|
}
|
||||||
|
if let Some(duration_ms) = self.pending_transition_duration_ms {
|
||||||
|
port.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
|
||||||
|
}
|
||||||
|
if let Some(style) = self.pending_transition_style {
|
||||||
|
port.send_command(HostCommand::SetTransitionStyle(style))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut outcome = port.send_command(HostCommand::SelectPattern(pattern_id.clone()))?;
|
||||||
|
for (key, value) in self.pending_parameters.clone() {
|
||||||
|
outcome = port.send_command(HostCommand::SetSceneParameter { key, value })?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = if let Some(group_id) = self.pending_group_id.as_ref() {
|
||||||
|
format!(
|
||||||
|
"transition triggered: {} on {}",
|
||||||
|
pattern_id,
|
||||||
|
group_id.as_deref().unwrap_or("all_panels")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("transition triggered: {pattern_id}")
|
||||||
|
};
|
||||||
|
|
||||||
|
self.clear_transition_buffer();
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(CommandOutcome {
|
||||||
|
generated_at_millis: outcome.generated_at_millis,
|
||||||
|
summary,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_transition_buffer(&mut self) {
|
||||||
|
self.pending_pattern_id = None;
|
||||||
|
self.pending_group_id = None;
|
||||||
|
self.pending_parameters.clear();
|
||||||
|
self.pending_transition_style = None;
|
||||||
|
self.pending_transition_duration_ms = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
|
||||||
|
fn snapshot(&self) -> HostSnapshot {
|
||||||
|
HostApiPort::snapshot(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_primitive(
|
||||||
|
&self,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
|
match primitive {
|
||||||
|
ShowControlPrimitive::Blackout { enabled } => Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
self.send_command(HostCommand::SetBlackout(enabled))?,
|
||||||
|
)),
|
||||||
|
ShowControlPrimitive::RecallPreset { preset_id } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
self.send_command(HostCommand::RecallPreset { preset_id })?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::RecallCreativeSnapshot { snapshot_id } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
|
||||||
|
HostCommand::RecallCreativeSnapshot { snapshot_id },
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::SetMasterBrightness { value } => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Command(
|
||||||
|
self.send_command(HostCommand::SetMasterBrightness(value))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
overwrite,
|
||||||
|
} => Ok(ShowControlPrimitiveOutcome::Command(self.send_command(
|
||||||
|
HostCommand::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
overwrite,
|
||||||
|
},
|
||||||
|
)?)),
|
||||||
|
ShowControlPrimitive::RequestSnapshot => {
|
||||||
|
Ok(ShowControlPrimitiveOutcome::Snapshot(self.snapshot()))
|
||||||
|
}
|
||||||
|
ShowControlPrimitive::SetPattern { .. }
|
||||||
|
| ShowControlPrimitive::SetGroupParameter { .. }
|
||||||
|
| ShowControlPrimitive::SetTransitionStyle { .. }
|
||||||
|
| ShowControlPrimitive::TriggerTransition => Err(HostCommandError::new(
|
||||||
|
"show_control_session_required",
|
||||||
|
"staged show-control primitives require a stateful ShowControlSession or adapter",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct BufferedShowControlAdapter {
|
||||||
|
session: ShowControlSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferedShowControlAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session(&self) -> &ShowControlSession {
|
||||||
|
&self.session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalShowControlAdapter for BufferedShowControlAdapter {
|
||||||
|
fn adapter_id(&self) -> &str {
|
||||||
|
"buffered_show_control"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_primitive(
|
||||||
|
&mut self,
|
||||||
|
port: &dyn HostApiPort,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
|
self.session.apply(port, primitive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReferenceShowControlMode {
|
||||||
|
StatefulSession,
|
||||||
|
StatelessPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReferenceShowControlClient<P> {
|
||||||
|
port: P,
|
||||||
|
mode: ReferenceShowControlMode,
|
||||||
|
adapter: BufferedShowControlAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: HostApiPort> ReferenceShowControlClient<P> {
|
||||||
|
pub fn stateful(port: P) -> Self {
|
||||||
|
Self {
|
||||||
|
port,
|
||||||
|
mode: ReferenceShowControlMode::StatefulSession,
|
||||||
|
adapter: BufferedShowControlAdapter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stateless(port: P) -> Self {
|
||||||
|
Self {
|
||||||
|
port,
|
||||||
|
mode: ReferenceShowControlMode::StatelessPort,
|
||||||
|
adapter: BufferedShowControlAdapter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mode(&self) -> ReferenceShowControlMode {
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> HostSnapshot {
|
||||||
|
HostApiPort::snapshot(&self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_session(&self) -> &ShowControlSession {
|
||||||
|
self.adapter.session()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_primitive(
|
||||||
|
&mut self,
|
||||||
|
primitive: ShowControlPrimitive,
|
||||||
|
) -> Result<ShowControlPrimitiveOutcome, HostCommandError> {
|
||||||
|
match self.mode {
|
||||||
|
ReferenceShowControlMode::StatefulSession => {
|
||||||
|
self.adapter.apply_primitive(&self.port, primitive)
|
||||||
|
}
|
||||||
|
ReferenceShowControlMode::StatelessPort => self.port.execute_primitive(primitive),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::SimulationHostService;
|
||||||
|
use infinity_config::ProjectConfig;
|
||||||
|
|
||||||
|
fn sample_project() -> ProjectConfig {
|
||||||
|
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||||
|
.expect("project config must parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_pattern_and_transition_commit_replay_cleanly() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
|
||||||
|
style: SceneTransitionStyle::Chase,
|
||||||
|
duration_ms: Some(480),
|
||||||
|
})
|
||||||
|
.expect("transition style staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect("pattern staging should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.pending_session().pending_pattern_id.as_deref(),
|
||||||
|
Some("noise")
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = client
|
||||||
|
.apply_primitive(ShowControlPrimitive::TriggerTransition)
|
||||||
|
.expect("trigger should succeed");
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
ShowControlPrimitiveOutcome::Command(outcome) => {
|
||||||
|
assert!(outcome.summary.contains("transition triggered: noise"));
|
||||||
|
}
|
||||||
|
other => panic!("expected command outcome, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
assert_eq!(snapshot.active_scene.pattern_id, "noise");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.global.transition_style,
|
||||||
|
SceneTransitionStyle::Chase
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.global.transition_duration_ms, 480);
|
||||||
|
assert!(client.pending_session().pending_pattern_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_transition_requires_a_staged_pattern() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.apply_primitive(ShowControlPrimitive::TriggerTransition)
|
||||||
|
.expect_err("trigger without pattern should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "transition_pattern_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_primitives_only_mutate_host_when_transition_is_triggered() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let baseline = crate::HostApiPort::snapshot(&service);
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
let staged = client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect("pattern staging should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
staged,
|
||||||
|
ShowControlPrimitiveOutcome::Buffered {
|
||||||
|
summary: "pattern staged: noise".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let staged_snapshot = client.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
staged_snapshot.active_scene.pattern_id,
|
||||||
|
baseline.active_scene.pattern_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_update_parameter_change_and_commit_replay_cleanly() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::UpsertGroup {
|
||||||
|
group_id: "focus_pair".to_string(),
|
||||||
|
tags: vec!["runtime".to_string(), "focus".to_string()],
|
||||||
|
members: vec![
|
||||||
|
PanelTarget {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
panel_position: infinity_config::PanelPosition::Top,
|
||||||
|
},
|
||||||
|
PanelTarget {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
panel_position: infinity_config::PanelPosition::Middle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overwrite: true,
|
||||||
|
})
|
||||||
|
.expect("group upsert should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
|
||||||
|
group_id: Some("focus_pair".to_string()),
|
||||||
|
key: "grain".to_string(),
|
||||||
|
value: SceneParameterValue::Scalar(0.81),
|
||||||
|
})
|
||||||
|
.expect("group parameter staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
|
||||||
|
style: SceneTransitionStyle::Chase,
|
||||||
|
duration_ms: Some(480),
|
||||||
|
})
|
||||||
|
.expect("transition style staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect("pattern staging should succeed");
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::TriggerTransition)
|
||||||
|
.expect("trigger should succeed");
|
||||||
|
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.global.selected_group.as_deref(),
|
||||||
|
Some("focus_pair")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.global.transition_style,
|
||||||
|
SceneTransitionStyle::Chase
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.global.transition_duration_ms, 480);
|
||||||
|
assert_eq!(snapshot.active_scene.pattern_id, "noise");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.active_scene.target_group.as_deref(),
|
||||||
|
Some("focus_pair")
|
||||||
|
);
|
||||||
|
assert!(snapshot
|
||||||
|
.active_scene
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.any(|parameter| parameter.key == "grain"
|
||||||
|
&& parameter.value == SceneParameterValue::Scalar(0.81)));
|
||||||
|
assert!(snapshot
|
||||||
|
.catalog
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.any(|group| group.group_id == "focus_pair"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_recall_interrupts_running_transition_with_a_new_transition() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetTransitionStyle {
|
||||||
|
style: SceneTransitionStyle::Crossfade,
|
||||||
|
duration_ms: Some(1600),
|
||||||
|
})
|
||||||
|
.expect("transition style staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect("pattern staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::TriggerTransition)
|
||||||
|
.expect("trigger should succeed");
|
||||||
|
|
||||||
|
let active_before_recall = client.snapshot();
|
||||||
|
assert!(active_before_recall.engine.active_transition.is_some());
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::RecallPreset {
|
||||||
|
preset_id: "ocean_gradient".to_string(),
|
||||||
|
})
|
||||||
|
.expect("preset recall should succeed");
|
||||||
|
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.active_scene.preset_id.as_deref(),
|
||||||
|
Some("ocean_gradient")
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.active_scene.pattern_id, "gradient");
|
||||||
|
assert!(snapshot.engine.active_transition.is_some());
|
||||||
|
assert!(snapshot
|
||||||
|
.recent_events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.message.contains("preset recalled: ocean_gradient")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blackout_during_staged_session_keeps_pending_transition_buffer() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect("pattern staging should succeed");
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetGroupParameter {
|
||||||
|
group_id: Some("bottom_panels".to_string()),
|
||||||
|
key: "grain".to_string(),
|
||||||
|
value: SceneParameterValue::Scalar(0.74),
|
||||||
|
})
|
||||||
|
.expect("group parameter staging should succeed");
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::Blackout { enabled: true })
|
||||||
|
.expect("blackout should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.pending_session().pending_pattern_id.as_deref(),
|
||||||
|
Some("noise")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.pending_session().pending_group_id,
|
||||||
|
Some(Some("bottom_panels".to_string()))
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.apply_primitive(ShowControlPrimitive::TriggerTransition)
|
||||||
|
.expect("trigger should succeed");
|
||||||
|
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
assert_eq!(snapshot.global.blackout, true);
|
||||||
|
assert_eq!(snapshot.active_scene.pattern_id, "noise");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.active_scene.target_group.as_deref(),
|
||||||
|
Some("bottom_panels")
|
||||||
|
);
|
||||||
|
assert!(client.pending_session().pending_pattern_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_snapshot_is_read_only() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let baseline = crate::HostApiPort::snapshot(&service);
|
||||||
|
let mut client = ReferenceShowControlClient::stateful(service);
|
||||||
|
let outcome = client
|
||||||
|
.apply_primitive(ShowControlPrimitive::RequestSnapshot)
|
||||||
|
.expect("snapshot request should succeed");
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
ShowControlPrimitiveOutcome::Snapshot(snapshot) => {
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.active_scene.pattern_id,
|
||||||
|
baseline.active_scene.pattern_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected snapshot outcome, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = client.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
after.active_scene.pattern_id,
|
||||||
|
baseline.active_scene.pattern_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_primitives_are_rejected_on_a_stateless_port() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut client = ReferenceShowControlClient::stateless(service);
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.apply_primitive(ShowControlPrimitive::SetPattern {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
})
|
||||||
|
.expect_err("staged primitive should require a session");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "show_control_session_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_group_parameter_key_is_rejected() {
|
||||||
|
let service = SimulationHostService::new(sample_project());
|
||||||
|
let mut session = ShowControlSession::default();
|
||||||
|
|
||||||
|
let error = session
|
||||||
|
.apply(
|
||||||
|
&service,
|
||||||
|
ShowControlPrimitive::SetGroupParameter {
|
||||||
|
group_id: Some("top_panels".to_string()),
|
||||||
|
key: " ".to_string(),
|
||||||
|
value: SceneParameterValue::Scalar(0.4),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("empty parameter key should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "invalid_group_parameter_key");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/infinity_host/src/lib.rs
Normal file
13
crates/infinity_host/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod control;
|
||||||
|
pub mod external_control;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod scene;
|
||||||
|
pub mod show_store;
|
||||||
|
pub mod simulation;
|
||||||
|
|
||||||
|
pub use control::*;
|
||||||
|
pub use external_control::*;
|
||||||
|
pub use runtime::*;
|
||||||
|
pub use scene::*;
|
||||||
|
pub use show_store::*;
|
||||||
|
pub use simulation::*;
|
||||||
161
crates/infinity_host/src/main.rs
Normal file
161
crates/infinity_host/src/main.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity};
|
||||||
|
use infinity_host::{HostApiPort, RealtimeEngine, SimulationHostService};
|
||||||
|
use std::{path::PathBuf, process::ExitCode};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
author,
|
||||||
|
version,
|
||||||
|
about = "Infinity Vis host-side validation and planning CLI"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
Validate {
|
||||||
|
#[arg(long)]
|
||||||
|
config: PathBuf,
|
||||||
|
#[arg(long, value_enum, default_value_t = CliValidationMode::Structural)]
|
||||||
|
mode: CliValidationMode,
|
||||||
|
},
|
||||||
|
PlanBootScene {
|
||||||
|
#[arg(long)]
|
||||||
|
config: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
preset_id: String,
|
||||||
|
},
|
||||||
|
Snapshot {
|
||||||
|
#[arg(long)]
|
||||||
|
config: PathBuf,
|
||||||
|
},
|
||||||
|
OpenValidationPoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
enum CliValidationMode {
|
||||||
|
Structural,
|
||||||
|
Activation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CliValidationMode> for ValidationMode {
|
||||||
|
fn from(value: CliValidationMode) -> Self {
|
||||||
|
match value {
|
||||||
|
CliValidationMode::Structural => ValidationMode::Structural,
|
||||||
|
CliValidationMode::Activation => ValidationMode::Activation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Command::Validate { config, mode } => validate_command(config, mode),
|
||||||
|
Command::PlanBootScene { config, preset_id } => plan_boot_scene_command(config, &preset_id),
|
||||||
|
Command::Snapshot { config } => snapshot_command(config),
|
||||||
|
Command::OpenValidationPoints => {
|
||||||
|
print_open_validation_points();
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_command(config: PathBuf, mode: CliValidationMode) -> ExitCode {
|
||||||
|
let project = match load_project_from_path(&config) {
|
||||||
|
Ok(project) => project,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = RealtimeEngine::default();
|
||||||
|
let report = engine.validate_project(&project, mode.into());
|
||||||
|
println!(
|
||||||
|
"Validation finished: {} error(s), {} warning(s)",
|
||||||
|
report.error_count(),
|
||||||
|
report.warning_count()
|
||||||
|
);
|
||||||
|
for issue in &report.issues {
|
||||||
|
let level = match issue.severity {
|
||||||
|
ValidationSeverity::Warning => "WARN",
|
||||||
|
ValidationSeverity::Error => "ERROR",
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"[{level}] {} at {}: {}",
|
||||||
|
issue.code, issue.path, issue.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.is_ok() {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_boot_scene_command(config: PathBuf, preset_id: &str) -> ExitCode {
|
||||||
|
let project = match load_project_from_path(&config) {
|
||||||
|
Ok(project) => project,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = RealtimeEngine::default();
|
||||||
|
let plan = engine.plan_boot_scene(&project, preset_id);
|
||||||
|
if plan.is_empty() {
|
||||||
|
eprintln!("Preset '{preset_id}' was not found.");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::to_string_pretty(&plan) {
|
||||||
|
Ok(output) => {
|
||||||
|
println!("{output}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to serialize boot plan: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_command(config: PathBuf) -> ExitCode {
|
||||||
|
let project = match load_project_from_path(&config) {
|
||||||
|
Ok(project) => project,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to load config '{}': {error}", config.display());
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let service = SimulationHostService::new(project);
|
||||||
|
match serde_json::to_string_pretty(&service.snapshot()) {
|
||||||
|
Ok(output) => {
|
||||||
|
println!("{output}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to serialize snapshot: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_open_validation_points() {
|
||||||
|
for line in [
|
||||||
|
"Pending hardware validation gates:",
|
||||||
|
"1. Confirm whether 'UART 6 / 5 / 4' are GPIO labels, logical channels, or real UART peripherals.",
|
||||||
|
"2. Confirm the exact LED chipset and timing backend required per output.",
|
||||||
|
"3. Confirm color order, start pixel, and direction for all 18 outputs.",
|
||||||
|
"4. Confirm whether all outputs truly have 106 active LEDs without dummy or reserve pixels.",
|
||||||
|
"5. Confirm the final node-to-physical-panel mapping before enabling layout-specific scenes.",
|
||||||
|
] {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
175
crates/infinity_host/src/runtime.rs
Normal file
175
crates/infinity_host/src/runtime.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use infinity_config::{
|
||||||
|
PanelPosition, ProjectConfig, TransportMode, ValidationIssue, ValidationMode, ValidationReport,
|
||||||
|
ValidationSeverity,
|
||||||
|
};
|
||||||
|
use infinity_protocol::{
|
||||||
|
ControlEnvelope, ControlMessage, PanelAddress, PanelAssignment, PanelSlot, RealtimeEnvelope,
|
||||||
|
RealtimeMessage, RealtimeMode, SceneParametersFrame, TransitionMode, TransitionSpec,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TickSchedule {
|
||||||
|
pub logic_hz: u16,
|
||||||
|
pub frame_synthesis_hz: u16,
|
||||||
|
pub network_send_hz: u16,
|
||||||
|
pub preview_hz: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TickSchedule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
logic_hz: 120,
|
||||||
|
frame_synthesis_hz: 60,
|
||||||
|
network_send_hz: 60,
|
||||||
|
preview_hz: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct PlannedSend {
|
||||||
|
pub node_id: String,
|
||||||
|
pub control: Vec<ControlEnvelope>,
|
||||||
|
pub realtime: Vec<RealtimeEnvelope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RealtimeEngine {
|
||||||
|
pub schedule: TickSchedule,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RealtimeEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schedule: TickSchedule::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeEngine {
|
||||||
|
pub fn validate_project(
|
||||||
|
&self,
|
||||||
|
project: &ProjectConfig,
|
||||||
|
mode: ValidationMode,
|
||||||
|
) -> ValidationReport {
|
||||||
|
let mut report = project.validate(mode);
|
||||||
|
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
|
||||||
|
report.issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Warning,
|
||||||
|
code: "preview_rate_too_high",
|
||||||
|
path: "runtime.schedule.preview_hz".to_string(),
|
||||||
|
message: "preview rate should stay below frame synthesis rate".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plan_boot_scene(&self, project: &ProjectConfig, preset_id: &str) -> Vec<PlannedSend> {
|
||||||
|
let Some(preset) = project
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let transport = project
|
||||||
|
.transport_profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.profile_id == project.metadata.default_transport_profile);
|
||||||
|
|
||||||
|
let safety = project
|
||||||
|
.safety_profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.profile_id == project.metadata.default_safety_profile);
|
||||||
|
|
||||||
|
project
|
||||||
|
.topology
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, node)| {
|
||||||
|
let assignments = node
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.map(|output| PanelAssignment {
|
||||||
|
panel: PanelAddress {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
panel_slot: map_panel_slot(&output.panel_position),
|
||||||
|
},
|
||||||
|
physical_output_name: output.physical_output_name.clone(),
|
||||||
|
driver_reference: output.driver_channel.reference.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let targets = assignments
|
||||||
|
.iter()
|
||||||
|
.map(|assignment| assignment.panel.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let transition = TransitionSpec {
|
||||||
|
transition_ms: preset.transition_ms,
|
||||||
|
mode: TransitionMode::Crossfade,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mode = match transport.map(|profile| &profile.mode) {
|
||||||
|
Some(TransportMode::FrameStreaming) => RealtimeMode::FrameStreaming,
|
||||||
|
_ => RealtimeMode::DistributedScene,
|
||||||
|
};
|
||||||
|
|
||||||
|
PlannedSend {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
control: vec![
|
||||||
|
ControlEnvelope::new(
|
||||||
|
(index as u32) * 2 + 1,
|
||||||
|
0,
|
||||||
|
ControlMessage::ConfigSync {
|
||||||
|
topology_revision: format!(
|
||||||
|
"schema-{}",
|
||||||
|
project.metadata.schema_version
|
||||||
|
),
|
||||||
|
outputs: assignments,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ControlEnvelope::new(
|
||||||
|
(index as u32) * 2 + 2,
|
||||||
|
0,
|
||||||
|
ControlMessage::PresetRecall {
|
||||||
|
preset_id: preset.preset_id.clone(),
|
||||||
|
transition,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
realtime: vec![RealtimeEnvelope::new(
|
||||||
|
(index as u32) + 1,
|
||||||
|
0,
|
||||||
|
RealtimeMessage::SceneParameters(SceneParametersFrame {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
mode,
|
||||||
|
preset_id: Some(preset.preset_id.clone()),
|
||||||
|
effect: preset.scene.effect.clone(),
|
||||||
|
seed: preset.scene.seed,
|
||||||
|
palette: preset.scene.palette.clone(),
|
||||||
|
master_brightness: safety
|
||||||
|
.map(|profile| profile.default_start_brightness)
|
||||||
|
.unwrap_or(0.2),
|
||||||
|
speed: preset.scene.speed,
|
||||||
|
intensity: preset.scene.intensity,
|
||||||
|
target_group: preset.target_group.clone(),
|
||||||
|
target_outputs: targets,
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_panel_slot(position: &PanelPosition) -> PanelSlot {
|
||||||
|
match position {
|
||||||
|
PanelPosition::Top => PanelSlot::Top,
|
||||||
|
PanelPosition::Middle => PanelSlot::Middle,
|
||||||
|
PanelPosition::Bottom => PanelSlot::Bottom,
|
||||||
|
}
|
||||||
|
}
|
||||||
772
crates/infinity_host/src/scene.rs
Normal file
772
crates/infinity_host/src/scene.rs
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
use crate::control::{
|
||||||
|
ActiveSceneSnapshot, CatalogSnapshot, CatalogSource, GroupSummary, PatternDefinition,
|
||||||
|
PresetSummary, SceneParameterKind, SceneParameterSpec, SceneParameterState,
|
||||||
|
SceneParameterValue, SceneTransitionStyle, TransitionSnapshot,
|
||||||
|
};
|
||||||
|
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SceneRuntime {
|
||||||
|
pub preset_id: Option<String>,
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub seed: u64,
|
||||||
|
pub palette: Vec<String>,
|
||||||
|
pub parameters: BTreeMap<String, SceneParameterValue>,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub blackout: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TransitionRuntime {
|
||||||
|
pub style: SceneTransitionStyle,
|
||||||
|
pub duration_ms: u32,
|
||||||
|
pub started_at: Instant,
|
||||||
|
pub from_scene: SceneRuntime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RenderedPreview {
|
||||||
|
pub representative_color_hex: String,
|
||||||
|
pub sample_led_hex: Vec<String>,
|
||||||
|
pub energy_percent: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PatternRegistry {
|
||||||
|
definitions: BTreeMap<String, PatternDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct RgbColor {
|
||||||
|
r: u8,
|
||||||
|
g: u8,
|
||||||
|
b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatternRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let definitions = default_pattern_definitions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| (definition.pattern_id.clone(), definition))
|
||||||
|
.collect();
|
||||||
|
Self { definitions }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog(&self, project: &ProjectConfig) -> CatalogSnapshot {
|
||||||
|
CatalogSnapshot {
|
||||||
|
patterns: self.definitions.values().cloned().collect(),
|
||||||
|
presets: project
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.map(|preset| PresetSummary {
|
||||||
|
preset_id: preset.preset_id.clone(),
|
||||||
|
pattern_id: preset.scene.effect.clone(),
|
||||||
|
target_group: preset.target_group.clone(),
|
||||||
|
transition_duration_ms: preset.transition_ms,
|
||||||
|
transition_style: transition_style_from_duration(preset.transition_ms),
|
||||||
|
source: CatalogSource::BuiltIn,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
groups: project
|
||||||
|
.topology
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| GroupSummary {
|
||||||
|
group_id: group.group_id.clone(),
|
||||||
|
member_count: group.members.len(),
|
||||||
|
tags: group.tags.clone(),
|
||||||
|
source: CatalogSource::BuiltIn,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
creative_snapshots: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn initial_scene(&self, project: &ProjectConfig) -> SceneRuntime {
|
||||||
|
project
|
||||||
|
.presets
|
||||||
|
.first()
|
||||||
|
.map(|preset| self.scene_from_preset(preset))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
self.scene_for_pattern(
|
||||||
|
"solid_color",
|
||||||
|
Some("bootstrap-solid-color".to_string()),
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
vec!["#ffffff".to_string()],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pattern_definitions(&self) -> Vec<PatternDefinition> {
|
||||||
|
self.definitions.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scene_from_preset_id(
|
||||||
|
&self,
|
||||||
|
project: &ProjectConfig,
|
||||||
|
preset_id: &str,
|
||||||
|
) -> Option<SceneRuntime> {
|
||||||
|
project
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
.map(|preset| self.scene_from_preset(preset))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_style_for_preset(
|
||||||
|
&self,
|
||||||
|
project: &ProjectConfig,
|
||||||
|
preset_id: &str,
|
||||||
|
) -> SceneTransitionStyle {
|
||||||
|
project
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
.map(|preset| transition_style_from_duration(preset.transition_ms))
|
||||||
|
.unwrap_or(SceneTransitionStyle::Crossfade)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scene_for_pattern(
|
||||||
|
&self,
|
||||||
|
pattern_id: &str,
|
||||||
|
preset_id: Option<String>,
|
||||||
|
target_group: Option<String>,
|
||||||
|
seed: u64,
|
||||||
|
palette: Vec<String>,
|
||||||
|
blackout: bool,
|
||||||
|
) -> SceneRuntime {
|
||||||
|
let definition = self.definition_or_default(pattern_id);
|
||||||
|
let mut parameters = BTreeMap::new();
|
||||||
|
for spec in &definition.parameters {
|
||||||
|
parameters.insert(spec.key.clone(), spec.default_value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneRuntime {
|
||||||
|
preset_id,
|
||||||
|
pattern_id: definition.pattern_id.clone(),
|
||||||
|
seed,
|
||||||
|
palette: if palette.is_empty() {
|
||||||
|
vec!["#ffffff".to_string()]
|
||||||
|
} else {
|
||||||
|
palette
|
||||||
|
},
|
||||||
|
parameters,
|
||||||
|
target_group,
|
||||||
|
blackout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_scene_parameter(
|
||||||
|
&self,
|
||||||
|
scene: &mut SceneRuntime,
|
||||||
|
key: &str,
|
||||||
|
value: SceneParameterValue,
|
||||||
|
) {
|
||||||
|
scene.parameters.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_scene_snapshot(&self, scene: &SceneRuntime) -> ActiveSceneSnapshot {
|
||||||
|
let definition = self.definition_or_default(&scene.pattern_id);
|
||||||
|
let parameters = definition
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|spec| SceneParameterState {
|
||||||
|
key: spec.key.clone(),
|
||||||
|
label: spec.label.clone(),
|
||||||
|
kind: spec.kind,
|
||||||
|
value: scene
|
||||||
|
.parameters
|
||||||
|
.get(&spec.key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| spec.default_value.clone()),
|
||||||
|
min_scalar: spec.min_scalar,
|
||||||
|
max_scalar: spec.max_scalar,
|
||||||
|
step: spec.step,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ActiveSceneSnapshot {
|
||||||
|
preset_id: scene.preset_id.clone(),
|
||||||
|
pattern_id: scene.pattern_id.clone(),
|
||||||
|
seed: scene.seed,
|
||||||
|
palette: scene.palette.clone(),
|
||||||
|
parameters,
|
||||||
|
target_group: scene.target_group.clone(),
|
||||||
|
blackout: scene.blackout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_snapshot(
|
||||||
|
&self,
|
||||||
|
scene: &SceneRuntime,
|
||||||
|
transition: &TransitionRuntime,
|
||||||
|
) -> TransitionSnapshot {
|
||||||
|
let elapsed_ms = transition.started_at.elapsed().as_millis() as u64;
|
||||||
|
let progress = if transition.duration_ms == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(elapsed_ms as f32 / transition.duration_ms as f32).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
TransitionSnapshot {
|
||||||
|
style: transition.style,
|
||||||
|
from_pattern_id: transition.from_scene.pattern_id.clone(),
|
||||||
|
to_pattern_id: scene.pattern_id.clone(),
|
||||||
|
duration_ms: transition.duration_ms,
|
||||||
|
progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_finished(&self, transition: &TransitionRuntime) -> bool {
|
||||||
|
transition.started_at.elapsed().as_millis() as u32 >= transition.duration_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_preview(
|
||||||
|
&self,
|
||||||
|
scene: &SceneRuntime,
|
||||||
|
panel_index: usize,
|
||||||
|
panel_count: usize,
|
||||||
|
elapsed_ms: u64,
|
||||||
|
) -> RenderedPreview {
|
||||||
|
let colors = palette_from_scene(scene);
|
||||||
|
let sample_leds = (0..DEFAULT_SAMPLE_LED_COUNT)
|
||||||
|
.map(|sample_index| {
|
||||||
|
self.render_led_color(
|
||||||
|
scene,
|
||||||
|
&colors,
|
||||||
|
panel_index,
|
||||||
|
panel_count,
|
||||||
|
sample_index,
|
||||||
|
DEFAULT_SAMPLE_LED_COUNT,
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let representative = RgbColor::average(&sample_leds);
|
||||||
|
let energy_percent = sample_leds
|
||||||
|
.iter()
|
||||||
|
.map(|color| color.energy_percent() as u32)
|
||||||
|
.sum::<u32>()
|
||||||
|
/ sample_leds.len().max(1) as u32;
|
||||||
|
|
||||||
|
RenderedPreview {
|
||||||
|
representative_color_hex: representative.to_hex(),
|
||||||
|
sample_led_hex: sample_leds
|
||||||
|
.into_iter()
|
||||||
|
.map(|color| color.to_hex())
|
||||||
|
.collect(),
|
||||||
|
energy_percent: energy_percent.min(100) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scene_from_preset_config(&self, preset: &PresetConfig) -> SceneRuntime {
|
||||||
|
let mut scene = self.scene_for_pattern(
|
||||||
|
&preset.scene.effect,
|
||||||
|
Some(preset.preset_id.clone()),
|
||||||
|
preset.target_group.clone(),
|
||||||
|
preset.scene.seed,
|
||||||
|
preset.scene.palette.clone(),
|
||||||
|
preset.scene.blackout,
|
||||||
|
);
|
||||||
|
self.set_scene_parameter(
|
||||||
|
&mut scene,
|
||||||
|
"speed",
|
||||||
|
SceneParameterValue::Scalar(preset.scene.speed),
|
||||||
|
);
|
||||||
|
self.set_scene_parameter(
|
||||||
|
&mut scene,
|
||||||
|
"intensity",
|
||||||
|
SceneParameterValue::Scalar(preset.scene.intensity),
|
||||||
|
);
|
||||||
|
scene
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
|
||||||
|
self.scene_from_preset_config(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition_or_default(&self, pattern_id: &str) -> &PatternDefinition {
|
||||||
|
self.definitions
|
||||||
|
.get(pattern_id)
|
||||||
|
.or_else(|| self.definitions.get("solid_color"))
|
||||||
|
.expect("pattern registry must contain a solid_color definition")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_led_color(
|
||||||
|
&self,
|
||||||
|
scene: &SceneRuntime,
|
||||||
|
palette: &[RgbColor],
|
||||||
|
panel_index: usize,
|
||||||
|
panel_count: usize,
|
||||||
|
sample_index: usize,
|
||||||
|
sample_count: usize,
|
||||||
|
elapsed_ms: u64,
|
||||||
|
) -> RgbColor {
|
||||||
|
let speed = scene
|
||||||
|
.parameters
|
||||||
|
.get("speed")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
let intensity = scene
|
||||||
|
.parameters
|
||||||
|
.get("intensity")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
let spread = scene
|
||||||
|
.parameters
|
||||||
|
.get("spread")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(0.55);
|
||||||
|
let width = scene
|
||||||
|
.parameters
|
||||||
|
.get("width")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(0.25)
|
||||||
|
.clamp(0.05, 1.0);
|
||||||
|
let grain = scene
|
||||||
|
.parameters
|
||||||
|
.get("grain")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(0.65)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
let trail = scene
|
||||||
|
.parameters
|
||||||
|
.get("trail")
|
||||||
|
.and_then(SceneParameterValue::as_scalar)
|
||||||
|
.unwrap_or(0.45)
|
||||||
|
.clamp(0.05, 1.0);
|
||||||
|
|
||||||
|
let panel_phase = if panel_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
panel_index as f32 / panel_count as f32
|
||||||
|
};
|
||||||
|
let led_phase = if sample_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sample_index as f32 / sample_count as f32
|
||||||
|
};
|
||||||
|
let time_phase = elapsed_ms as f32 / 1000.0 * speed.max(0.01);
|
||||||
|
|
||||||
|
let raw = match scene.pattern_id.as_str() {
|
||||||
|
"solid_color" => palette.first().copied().unwrap_or(RgbColor::WHITE),
|
||||||
|
"gradient" => {
|
||||||
|
let from = palette.first().copied().unwrap_or(RgbColor::BLACK);
|
||||||
|
let to = palette.get(1).copied().unwrap_or(from);
|
||||||
|
let blend = (panel_phase + led_phase * spread + time_phase * 0.12).fract();
|
||||||
|
from.blend(to, blend)
|
||||||
|
}
|
||||||
|
"chase" => {
|
||||||
|
let position = (time_phase * 0.65 + panel_phase + led_phase).fract();
|
||||||
|
let highlight = distance_wrap(position, 0.0);
|
||||||
|
let strength = smooth_peak(highlight, width);
|
||||||
|
palette
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(RgbColor::WHITE)
|
||||||
|
.scale(strength)
|
||||||
|
}
|
||||||
|
"pulse" => {
|
||||||
|
let wave = ((time_phase * 2.8 + panel_phase * 0.6).sin() + 1.0) * 0.5;
|
||||||
|
palette
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(RgbColor::WHITE)
|
||||||
|
.scale(wave)
|
||||||
|
}
|
||||||
|
"noise" => {
|
||||||
|
let noise = hashed_noise(scene.seed, panel_index, sample_index, elapsed_ms);
|
||||||
|
let color_index = ((noise * palette.len().max(1) as f32).floor() as usize)
|
||||||
|
.min(palette.len().saturating_sub(1));
|
||||||
|
palette
|
||||||
|
.get(color_index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(RgbColor::WHITE)
|
||||||
|
.scale((0.3 + noise * grain).clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
"walking_pixel" => {
|
||||||
|
let head = (time_phase * 0.8 + panel_phase * 0.4).fract();
|
||||||
|
let sample = led_phase;
|
||||||
|
let distance = distance_wrap(sample, head);
|
||||||
|
let strength = smooth_peak(distance, trail);
|
||||||
|
let base = palette.first().copied().unwrap_or(RgbColor::WHITE);
|
||||||
|
base.scale(strength.max(0.08))
|
||||||
|
}
|
||||||
|
_ => palette.first().copied().unwrap_or(RgbColor::WHITE),
|
||||||
|
};
|
||||||
|
|
||||||
|
raw.scale(intensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_style_from_duration(duration_ms: u32) -> SceneTransitionStyle {
|
||||||
|
if duration_ms == 0 {
|
||||||
|
SceneTransitionStyle::Snap
|
||||||
|
} else if duration_ms <= 120 {
|
||||||
|
SceneTransitionStyle::Chase
|
||||||
|
} else {
|
||||||
|
SceneTransitionStyle::Crossfade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_group_gate(preview: &RenderedPreview, active_in_group: bool) -> RenderedPreview {
|
||||||
|
if active_in_group {
|
||||||
|
return preview.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let dimmed = preview
|
||||||
|
.sample_led_hex
|
||||||
|
.iter()
|
||||||
|
.map(|hex| parse_color(hex).scale(0.18).to_hex())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let representative = parse_color(&preview.representative_color_hex).scale(0.18);
|
||||||
|
RenderedPreview {
|
||||||
|
representative_color_hex: representative.to_hex(),
|
||||||
|
sample_led_hex: dimmed,
|
||||||
|
energy_percent: ((preview.energy_percent as f32) * 0.18) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blend_previews(
|
||||||
|
from: &RenderedPreview,
|
||||||
|
to: &RenderedPreview,
|
||||||
|
progress: f32,
|
||||||
|
) -> RenderedPreview {
|
||||||
|
let blend = progress.clamp(0.0, 1.0);
|
||||||
|
let from_color = parse_color(&from.representative_color_hex);
|
||||||
|
let to_color = parse_color(&to.representative_color_hex);
|
||||||
|
let sample_count = from.sample_led_hex.len().min(to.sample_led_hex.len());
|
||||||
|
let mut sample_led_hex = Vec::with_capacity(sample_count);
|
||||||
|
|
||||||
|
for index in 0..sample_count {
|
||||||
|
let left = parse_color(&from.sample_led_hex[index]);
|
||||||
|
let right = parse_color(&to.sample_led_hex[index]);
|
||||||
|
sample_led_hex.push(left.blend(right, blend).to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderedPreview {
|
||||||
|
representative_color_hex: from_color.blend(to_color, blend).to_hex(),
|
||||||
|
sample_led_hex,
|
||||||
|
energy_percent: ((from.energy_percent as f32)
|
||||||
|
+ (to.energy_percent as f32 - from.energy_percent as f32) * blend)
|
||||||
|
as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_test_preview(elapsed_since_trigger_ms: u64) -> RenderedPreview {
|
||||||
|
let phase = ((elapsed_since_trigger_ms / 120) % DEFAULT_SAMPLE_LED_COUNT as u64) as usize;
|
||||||
|
let mut sample_led_hex = Vec::with_capacity(DEFAULT_SAMPLE_LED_COUNT);
|
||||||
|
for index in 0..DEFAULT_SAMPLE_LED_COUNT {
|
||||||
|
let strength = if index == phase {
|
||||||
|
1.0
|
||||||
|
} else if distance_wrap(
|
||||||
|
index as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
|
||||||
|
phase as f32 / DEFAULT_SAMPLE_LED_COUNT as f32,
|
||||||
|
) < 0.20
|
||||||
|
{
|
||||||
|
0.35
|
||||||
|
} else {
|
||||||
|
0.06
|
||||||
|
};
|
||||||
|
sample_led_hex.push(RgbColor::WHITE.scale(strength).to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderedPreview {
|
||||||
|
representative_color_hex: RgbColor::WHITE.scale(0.42).to_hex(),
|
||||||
|
sample_led_hex,
|
||||||
|
energy_percent: 42,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blackout_preview() -> RenderedPreview {
|
||||||
|
RenderedPreview {
|
||||||
|
representative_color_hex: "#000000".to_string(),
|
||||||
|
sample_led_hex: vec!["#000000".to_string(); DEFAULT_SAMPLE_LED_COUNT],
|
||||||
|
energy_percent: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_group_members(project: &ProjectConfig) -> BTreeMap<String, BTreeSet<String>> {
|
||||||
|
project
|
||||||
|
.topology
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
let members = group
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|member| {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
member.node_id,
|
||||||
|
panel_position_key(&member.panel_position)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(group.group_id.clone(), members)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_membership_key(node_id: &str, panel_position: &str) -> String {
|
||||||
|
format!("{node_id}:{panel_position}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_position_key(position: &PanelPosition) -> &'static str {
|
||||||
|
match position {
|
||||||
|
PanelPosition::Top => "top",
|
||||||
|
PanelPosition::Middle => "middle",
|
||||||
|
PanelPosition::Bottom => "bottom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pattern_definitions() -> Vec<PatternDefinition> {
|
||||||
|
vec![
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "solid_color".to_string(),
|
||||||
|
display_name: "Solid Color".to_string(),
|
||||||
|
description: "Static palette color for calm base looks and blackout recovery scenes."
|
||||||
|
.to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.0),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "gradient".to_string(),
|
||||||
|
display_name: "Gradient Drift".to_string(),
|
||||||
|
description: "Spatial gradient with slow temporal drift for mood development."
|
||||||
|
.to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.0, 4.0, 0.05, 0.35),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.85),
|
||||||
|
scalar_spec("spread", "Spread", 0.1, 1.5, 0.05, 0.55),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "chase".to_string(),
|
||||||
|
display_name: "Chase".to_string(),
|
||||||
|
description: "Directional chase motion that is easy to time and group.".to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 1.0),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
|
||||||
|
scalar_spec("width", "Width", 0.05, 0.8, 0.01, 0.25),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "pulse".to_string(),
|
||||||
|
display_name: "Pulse".to_string(),
|
||||||
|
description: "Breathing pulse for soft transitions and level checks.".to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.1, 6.0, 0.05, 0.75),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.9),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "noise".to_string(),
|
||||||
|
display_name: "Noise".to_string(),
|
||||||
|
description: "Organic shimmer driven by deterministic pseudo noise.".to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.1, 4.0, 0.05, 0.7),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 0.8),
|
||||||
|
scalar_spec("grain", "Grain", 0.0, 1.0, 0.01, 0.65),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PatternDefinition {
|
||||||
|
pattern_id: "walking_pixel".to_string(),
|
||||||
|
display_name: "Walking Pixel".to_string(),
|
||||||
|
description: "Single-pixel scan for mapping tests and alignment checks.".to_string(),
|
||||||
|
parameters: vec![
|
||||||
|
scalar_spec("speed", "Speed", 0.1, 8.0, 0.05, 1.0),
|
||||||
|
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
|
||||||
|
scalar_spec("trail", "Trail", 0.05, 0.8, 0.01, 0.45),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_spec(
|
||||||
|
key: &str,
|
||||||
|
label: &str,
|
||||||
|
min_scalar: f32,
|
||||||
|
max_scalar: f32,
|
||||||
|
step: f32,
|
||||||
|
default_value: f32,
|
||||||
|
) -> SceneParameterSpec {
|
||||||
|
SceneParameterSpec {
|
||||||
|
key: key.to_string(),
|
||||||
|
label: label.to_string(),
|
||||||
|
kind: SceneParameterKind::Scalar,
|
||||||
|
min_scalar: Some(min_scalar),
|
||||||
|
max_scalar: Some(max_scalar),
|
||||||
|
step: Some(step),
|
||||||
|
default_value: SceneParameterValue::Scalar(default_value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn palette_from_scene(scene: &SceneRuntime) -> Vec<RgbColor> {
|
||||||
|
if scene.palette.is_empty() {
|
||||||
|
vec![RgbColor::WHITE]
|
||||||
|
} else {
|
||||||
|
scene
|
||||||
|
.palette
|
||||||
|
.iter()
|
||||||
|
.map(|entry| parse_color(entry))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_color(hex: &str) -> RgbColor {
|
||||||
|
let raw = hex.trim().trim_start_matches('#');
|
||||||
|
if raw.len() == 6 {
|
||||||
|
if let Ok(value) = u32::from_str_radix(raw, 16) {
|
||||||
|
return RgbColor {
|
||||||
|
r: ((value >> 16) & 0xff) as u8,
|
||||||
|
g: ((value >> 8) & 0xff) as u8,
|
||||||
|
b: (value & 0xff) as u8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RgbColor::WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hashed_noise(seed: u64, panel_index: usize, sample_index: usize, elapsed_ms: u64) -> f32 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
seed.hash(&mut hasher);
|
||||||
|
panel_index.hash(&mut hasher);
|
||||||
|
sample_index.hash(&mut hasher);
|
||||||
|
(elapsed_ms / 110).hash(&mut hasher);
|
||||||
|
let value = hasher.finish();
|
||||||
|
(value % 10_000) as f32 / 10_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distance_wrap(a: f32, b: f32) -> f32 {
|
||||||
|
let diff = (a - b).abs();
|
||||||
|
diff.min(1.0 - diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smooth_peak(distance: f32, width: f32) -> f32 {
|
||||||
|
let normalized = (1.0 - distance / width.max(0.0001)).clamp(0.0, 1.0);
|
||||||
|
normalized * normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RgbColor {
|
||||||
|
const BLACK: Self = Self { r: 0, g: 0, b: 0 };
|
||||||
|
const WHITE: Self = Self {
|
||||||
|
r: 255,
|
||||||
|
g: 255,
|
||||||
|
b: 255,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn blend(self, other: Self, t: f32) -> Self {
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Self {
|
||||||
|
r: (self.r as f32 + (other.r as f32 - self.r as f32) * t) as u8,
|
||||||
|
g: (self.g as f32 + (other.g as f32 - self.g as f32) * t) as u8,
|
||||||
|
b: (self.b as f32 + (other.b as f32 - self.b as f32) * t) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale(self, amount: f32) -> Self {
|
||||||
|
let amount = amount.clamp(0.0, 1.0);
|
||||||
|
Self {
|
||||||
|
r: (self.r as f32 * amount) as u8,
|
||||||
|
g: (self.g as f32 * amount) as u8,
|
||||||
|
b: (self.b as f32 * amount) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn average(colors: &[Self]) -> Self {
|
||||||
|
if colors.is_empty() {
|
||||||
|
return Self::BLACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut r = 0u32;
|
||||||
|
let mut g = 0u32;
|
||||||
|
let mut b = 0u32;
|
||||||
|
for color in colors {
|
||||||
|
r += color.r as u32;
|
||||||
|
g += color.g as u32;
|
||||||
|
b += color.b as u32;
|
||||||
|
}
|
||||||
|
let count = colors.len() as u32;
|
||||||
|
Self {
|
||||||
|
r: (r / count) as u8,
|
||||||
|
g: (g / count) as u8,
|
||||||
|
b: (b / count) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn energy_percent(self) -> u8 {
|
||||||
|
(((self.r as u32 + self.g as u32 + self.b as u32) as f32 / (255.0 * 3.0)) * 100.0) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_hex(self) -> String {
|
||||||
|
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use infinity_config::ProjectConfig;
|
||||||
|
|
||||||
|
fn sample_project() -> ProjectConfig {
|
||||||
|
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||||
|
.expect("project config must parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_builds_catalog_for_project() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let catalog = registry.catalog(&sample_project());
|
||||||
|
assert!(catalog
|
||||||
|
.patterns
|
||||||
|
.iter()
|
||||||
|
.any(|pattern| pattern.pattern_id == "solid_color"));
|
||||||
|
assert!(catalog
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset.preset_id == "ocean_gradient"));
|
||||||
|
assert!(catalog
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.any(|group| group.group_id == "bottom_panels"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_scene_uses_speed_and_intensity() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let project = sample_project();
|
||||||
|
let scene = registry
|
||||||
|
.scene_from_preset_id(&project, "mapping_walk_test")
|
||||||
|
.expect("preset must exist");
|
||||||
|
assert_eq!(scene.pattern_id, "walking_pixel");
|
||||||
|
assert_eq!(
|
||||||
|
scene.parameters.get("speed"),
|
||||||
|
Some(&SceneParameterValue::Scalar(1.0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_render_produces_hex_output() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let project = sample_project();
|
||||||
|
let scene = registry.initial_scene(&project);
|
||||||
|
let preview = registry.render_preview(&scene, 0, 18, 450);
|
||||||
|
assert_eq!(preview.sample_led_hex.len(), 6);
|
||||||
|
assert!(preview.representative_color_hex.starts_with('#'));
|
||||||
|
}
|
||||||
|
}
|
||||||
801
crates/infinity_host/src/show_store.rs
Normal file
801
crates/infinity_host/src/show_store.rs
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
use crate::{
|
||||||
|
control::{
|
||||||
|
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
|
||||||
|
PanelTarget, PresetSummary, SceneTransitionStyle,
|
||||||
|
},
|
||||||
|
scene::{PatternRegistry, SceneRuntime},
|
||||||
|
};
|
||||||
|
use infinity_config::{PanelPosition, ProjectConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const RUNTIME_STATE_SCHEMA_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct StoredPreset {
|
||||||
|
pub preset_id: String,
|
||||||
|
pub scene: SceneRuntime,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
pub source: CatalogSource,
|
||||||
|
pub updated_at_unix_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct StoredGroup {
|
||||||
|
pub group_id: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub members: Vec<PanelTarget>,
|
||||||
|
pub source: CatalogSource,
|
||||||
|
pub updated_at_unix_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct StoredCreativeSnapshot {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub scene: SceneRuntime,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
pub saved_at_unix_ms: u64,
|
||||||
|
pub source_preset_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PersistedGlobalState {
|
||||||
|
pub blackout: bool,
|
||||||
|
pub master_brightness: f32,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: SceneTransitionStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PersistedGlobalState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
blackout: false,
|
||||||
|
master_brightness: 0.20,
|
||||||
|
transition_duration_ms: 150,
|
||||||
|
transition_style: SceneTransitionStyle::Crossfade,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct PersistedRuntimeState {
|
||||||
|
pub active_scene: Option<SceneRuntime>,
|
||||||
|
pub global: PersistedGlobalState,
|
||||||
|
pub user_presets: Vec<StoredPreset>,
|
||||||
|
pub user_groups: Vec<StoredGroup>,
|
||||||
|
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
struct RuntimeStateEnvelope {
|
||||||
|
schema_version: u16,
|
||||||
|
saved_at_unix_ms: u64,
|
||||||
|
runtime: PersistedRuntimeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RuntimeStateStorage {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeStateLoadWarning {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RuntimeStateLoadResult {
|
||||||
|
pub runtime: PersistedRuntimeState,
|
||||||
|
pub loaded_from_disk: bool,
|
||||||
|
pub warnings: Vec<RuntimeStateLoadWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ShowStoreError {
|
||||||
|
#[error("runtime state I/O failed: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("runtime state parse failed: {0}")]
|
||||||
|
Parse(#[from] serde_json::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ShowStore {
|
||||||
|
presets: Vec<StoredPreset>,
|
||||||
|
groups: Vec<StoredGroup>,
|
||||||
|
creative_snapshots: Vec<StoredCreativeSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeStateStorage {
|
||||||
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { path: path.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> Result<PersistedRuntimeState, ShowStoreError> {
|
||||||
|
if !self.path.exists() {
|
||||||
|
return Ok(PersistedRuntimeState::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(&self.path)?;
|
||||||
|
parse_runtime_state(&raw, &self.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
|
||||||
|
if !self.path.exists() {
|
||||||
|
return RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = match fs::read_to_string(&self.path) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(error) => {
|
||||||
|
return RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: vec![RuntimeStateLoadWarning::new(
|
||||||
|
"runtime_state_read_failed",
|
||||||
|
format!(
|
||||||
|
"runtime state at {} could not be read and was reset to defaults: {error}",
|
||||||
|
self.path.display()
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: vec![RuntimeStateLoadWarning::new(
|
||||||
|
"runtime_state_empty",
|
||||||
|
format!(
|
||||||
|
"runtime state at {} was empty and was reset to defaults",
|
||||||
|
self.path.display()
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_runtime_state(&raw, &self.path) {
|
||||||
|
Ok(runtime) => RuntimeStateLoadResult {
|
||||||
|
runtime,
|
||||||
|
loaded_from_disk: true,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: vec![RuntimeStateLoadWarning::new(
|
||||||
|
"runtime_state_parse_failed",
|
||||||
|
format!(
|
||||||
|
"runtime state at {} could not be parsed and was reset to defaults: {error}",
|
||||||
|
self.path.display()
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: vec![RuntimeStateLoadWarning::new(
|
||||||
|
"runtime_state_schema_unsupported",
|
||||||
|
format!("{message}; runtime state was reset to defaults"),
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult {
|
||||||
|
runtime: PersistedRuntimeState::default(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
warnings: vec![RuntimeStateLoadWarning::new(
|
||||||
|
"runtime_state_read_failed",
|
||||||
|
format!(
|
||||||
|
"runtime state at {} could not be read and was reset to defaults: {error}",
|
||||||
|
self.path.display()
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope = RuntimeStateEnvelope {
|
||||||
|
schema_version: RUNTIME_STATE_SCHEMA_VERSION,
|
||||||
|
saved_at_unix_ms: now_unix_ms(),
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_string_pretty(&envelope)?;
|
||||||
|
fs::write(&self.path, payload)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeStateLoadWarning {
|
||||||
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_runtime_state(raw: &str, path: &Path) -> Result<PersistedRuntimeState, ShowStoreError> {
|
||||||
|
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(raw)?;
|
||||||
|
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
|
||||||
|
return Err(ShowStoreError::Validation(format!(
|
||||||
|
"unsupported runtime state schema version {} at {}",
|
||||||
|
envelope.schema_version,
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(envelope.runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowStore {
|
||||||
|
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
|
||||||
|
let presets = project
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.map(|preset| StoredPreset {
|
||||||
|
preset_id: preset.preset_id.clone(),
|
||||||
|
scene: registry.scene_from_preset_config(preset),
|
||||||
|
transition_duration_ms: preset.transition_ms,
|
||||||
|
transition_style: crate::scene::transition_style_from_duration(
|
||||||
|
preset.transition_ms,
|
||||||
|
),
|
||||||
|
source: CatalogSource::BuiltIn,
|
||||||
|
updated_at_unix_ms: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let groups = project
|
||||||
|
.topology
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| StoredGroup {
|
||||||
|
group_id: group.group_id.clone(),
|
||||||
|
tags: group.tags.clone(),
|
||||||
|
members: group
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|member| PanelTarget {
|
||||||
|
node_id: member.node_id.clone(),
|
||||||
|
panel_position: member.panel_position.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
source: CatalogSource::BuiltIn,
|
||||||
|
updated_at_unix_ms: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
presets,
|
||||||
|
groups,
|
||||||
|
creative_snapshots: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_persisted(&mut self, runtime: PersistedRuntimeState) {
|
||||||
|
for preset in runtime.user_presets {
|
||||||
|
replace_or_append_by(&mut self.presets, preset, |left, right| {
|
||||||
|
left.preset_id == right.preset_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in runtime.user_groups {
|
||||||
|
replace_or_append_by(&mut self.groups, group, |left, right| {
|
||||||
|
left.group_id == right.group_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.creative_snapshots = runtime.creative_snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog(&self, registry: &PatternRegistry) -> CatalogSnapshot {
|
||||||
|
CatalogSnapshot {
|
||||||
|
patterns: registry.pattern_definitions(),
|
||||||
|
presets: self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.map(|preset| PresetSummary {
|
||||||
|
preset_id: preset.preset_id.clone(),
|
||||||
|
pattern_id: preset.scene.pattern_id.clone(),
|
||||||
|
target_group: preset.scene.target_group.clone(),
|
||||||
|
transition_duration_ms: preset.transition_duration_ms,
|
||||||
|
transition_style: preset.transition_style,
|
||||||
|
source: preset.source,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
groups: self
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| GroupSummary {
|
||||||
|
group_id: group.group_id.clone(),
|
||||||
|
member_count: group.members.len(),
|
||||||
|
tags: group.tags.clone(),
|
||||||
|
source: group.source,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
creative_snapshots: self
|
||||||
|
.creative_snapshots
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| CreativeSnapshotSummary {
|
||||||
|
snapshot_id: snapshot.snapshot_id.clone(),
|
||||||
|
label: snapshot.label.clone(),
|
||||||
|
pattern_id: snapshot.scene.pattern_id.clone(),
|
||||||
|
target_group: snapshot.scene.target_group.clone(),
|
||||||
|
transition_duration_ms: snapshot.transition_duration_ms,
|
||||||
|
transition_style: snapshot.transition_style,
|
||||||
|
saved_at_unix_ms: snapshot.saved_at_unix_ms,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn initial_scene(&self, registry: &PatternRegistry) -> SceneRuntime {
|
||||||
|
self.presets
|
||||||
|
.first()
|
||||||
|
.map(|preset| preset.scene.clone())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
registry.scene_for_pattern(
|
||||||
|
"solid_color",
|
||||||
|
Some("bootstrap-solid-color".to_string()),
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
vec!["#ffffff".to_string()],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_patterns(&self, registry: &PatternRegistry) -> Vec<String> {
|
||||||
|
registry
|
||||||
|
.pattern_definitions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|pattern| pattern.pattern_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scene_from_preset_id(&self, preset_id: &str) -> Option<SceneRuntime> {
|
||||||
|
self.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
.map(|preset| preset.scene.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_for_preset(&self, preset_id: &str) -> Option<(u32, SceneTransitionStyle)> {
|
||||||
|
self.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option<StoredCreativeSnapshot> {
|
||||||
|
self.creative_snapshots
|
||||||
|
.iter()
|
||||||
|
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_group(&self, group_id: &str) -> bool {
|
||||||
|
self.groups.iter().any(|group| group.group_id == group_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_members_map(&self) -> BTreeMap<String, BTreeSet<String>> {
|
||||||
|
self.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
let members = group
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|member| {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
member.node_id,
|
||||||
|
panel_position_key(&member.panel_position)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(group.group_id.clone(), members)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_preset_from_scene(
|
||||||
|
&mut self,
|
||||||
|
preset_id: &str,
|
||||||
|
scene: &SceneRuntime,
|
||||||
|
transition_duration_ms: u32,
|
||||||
|
transition_style: SceneTransitionStyle,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<(), HostCommandError> {
|
||||||
|
if preset_id.trim().is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_preset_id",
|
||||||
|
"preset_id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset.preset_id == preset_id)
|
||||||
|
{
|
||||||
|
if !overwrite {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"preset_exists",
|
||||||
|
format!("preset '{preset_id}' already exists"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.source == CatalogSource::BuiltIn {
|
||||||
|
// Overwriting a built-in preset becomes a runtime overlay with the same id.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let preset = StoredPreset {
|
||||||
|
preset_id: preset_id.to_string(),
|
||||||
|
scene: scene.clone(),
|
||||||
|
transition_duration_ms,
|
||||||
|
transition_style,
|
||||||
|
source: CatalogSource::RuntimeUser,
|
||||||
|
updated_at_unix_ms: Some(now_unix_ms()),
|
||||||
|
};
|
||||||
|
replace_or_append_by(&mut self.presets, preset, |left, right| {
|
||||||
|
left.preset_id == right.preset_id
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_creative_snapshot(
|
||||||
|
&mut self,
|
||||||
|
snapshot_id: &str,
|
||||||
|
label: Option<String>,
|
||||||
|
scene: &SceneRuntime,
|
||||||
|
transition_duration_ms: u32,
|
||||||
|
transition_style: SceneTransitionStyle,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<(), HostCommandError> {
|
||||||
|
if snapshot_id.trim().is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_snapshot_id",
|
||||||
|
"snapshot_id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.creative_snapshots
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot.snapshot_id == snapshot_id)
|
||||||
|
&& !overwrite
|
||||||
|
{
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"snapshot_exists",
|
||||||
|
format!("creative snapshot '{snapshot_id}' already exists"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = StoredCreativeSnapshot {
|
||||||
|
snapshot_id: snapshot_id.to_string(),
|
||||||
|
label,
|
||||||
|
scene: scene.clone(),
|
||||||
|
transition_duration_ms,
|
||||||
|
transition_style,
|
||||||
|
saved_at_unix_ms: now_unix_ms(),
|
||||||
|
source_preset_id: scene.preset_id.clone(),
|
||||||
|
};
|
||||||
|
replace_or_append_by(&mut self.creative_snapshots, snapshot, |left, right| {
|
||||||
|
left.snapshot_id == right.snapshot_id
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_group(
|
||||||
|
&mut self,
|
||||||
|
group_id: &str,
|
||||||
|
tags: Vec<String>,
|
||||||
|
members: Vec<PanelTarget>,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<(), HostCommandError> {
|
||||||
|
if group_id.trim().is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_group_id",
|
||||||
|
"group_id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if members.is_empty() {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"invalid_group_members",
|
||||||
|
"group must contain at least one panel target",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.groups.iter().any(|group| group.group_id == group_id) && !overwrite {
|
||||||
|
return Err(HostCommandError::new(
|
||||||
|
"group_exists",
|
||||||
|
format!("group '{group_id}' already exists"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = StoredGroup {
|
||||||
|
group_id: group_id.to_string(),
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
source: CatalogSource::RuntimeUser,
|
||||||
|
updated_at_unix_ms: Some(now_unix_ms()),
|
||||||
|
};
|
||||||
|
replace_or_append_by(&mut self.groups, group, |left, right| {
|
||||||
|
left.group_id == right.group_id
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn persisted_runtime(
|
||||||
|
&self,
|
||||||
|
active_scene: &SceneRuntime,
|
||||||
|
global: PersistedGlobalState,
|
||||||
|
) -> PersistedRuntimeState {
|
||||||
|
PersistedRuntimeState {
|
||||||
|
active_scene: Some(active_scene.clone()),
|
||||||
|
global,
|
||||||
|
user_presets: self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.filter(|preset| preset.source == CatalogSource::RuntimeUser)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
user_groups: self
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter(|group| group.source == CatalogSource::RuntimeUser)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
creative_snapshots: self.creative_snapshots.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_position_key(position: &PanelPosition) -> &'static str {
|
||||||
|
match position {
|
||||||
|
PanelPosition::Top => "top",
|
||||||
|
PanelPosition::Middle => "middle",
|
||||||
|
PanelPosition::Bottom => "bottom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_or_append_by<T, F>(items: &mut Vec<T>, item: T, predicate: F)
|
||||||
|
where
|
||||||
|
F: Fn(&T, &T) -> bool,
|
||||||
|
{
|
||||||
|
if let Some(index) = items.iter().position(|existing| predicate(existing, &item)) {
|
||||||
|
items[index] = item;
|
||||||
|
} else {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn sample_project() -> ProjectConfig {
|
||||||
|
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||||
|
.expect("project config must parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_store_builds_runtime_catalog() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let catalog = store.catalog(®istry);
|
||||||
|
assert!(catalog
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset.preset_id == "ocean_gradient"));
|
||||||
|
assert!(catalog
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.any(|group| group.group_id == "top_panels"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_presets_and_snapshots_can_be_saved() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let scene = registry.scene_for_pattern(
|
||||||
|
"gradient",
|
||||||
|
None,
|
||||||
|
Some("top_panels".to_string()),
|
||||||
|
77,
|
||||||
|
vec!["#112233".to_string(), "#445566".to_string()],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.save_preset_from_scene(
|
||||||
|
"user_gradient",
|
||||||
|
&scene,
|
||||||
|
420,
|
||||||
|
SceneTransitionStyle::Crossfade,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("preset save should succeed");
|
||||||
|
store
|
||||||
|
.save_creative_snapshot(
|
||||||
|
"variant_a",
|
||||||
|
Some("Variant A".to_string()),
|
||||||
|
&scene,
|
||||||
|
240,
|
||||||
|
SceneTransitionStyle::Chase,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("snapshot save should succeed");
|
||||||
|
|
||||||
|
assert!(store.scene_from_preset_id("user_gradient").is_some());
|
||||||
|
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
||||||
|
let registry = PatternRegistry::new();
|
||||||
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
||||||
|
let scene = registry.scene_for_pattern(
|
||||||
|
"noise",
|
||||||
|
None,
|
||||||
|
Some("bottom_panels".to_string()),
|
||||||
|
99,
|
||||||
|
vec!["#AA8844".to_string()],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_preset_from_scene(
|
||||||
|
"roundtrip_noise",
|
||||||
|
&scene,
|
||||||
|
220,
|
||||||
|
SceneTransitionStyle::Chase,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("preset save should succeed");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"infinity_vis_show_store_{}.json",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis()
|
||||||
|
));
|
||||||
|
let storage = RuntimeStateStorage::new(&path);
|
||||||
|
let runtime = store.persisted_runtime(
|
||||||
|
&scene,
|
||||||
|
PersistedGlobalState {
|
||||||
|
blackout: false,
|
||||||
|
master_brightness: 0.42,
|
||||||
|
transition_duration_ms: 220,
|
||||||
|
transition_style: SceneTransitionStyle::Chase,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
storage.save(&runtime).expect("save should work");
|
||||||
|
let loaded = storage.load().expect("load should work");
|
||||||
|
|
||||||
|
assert_eq!(loaded.active_scene, Some(scene));
|
||||||
|
assert!(loaded
|
||||||
|
.user_presets
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset.preset_id == "roundtrip_noise"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_state_storage_recovers_from_empty_file() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"infinity_vis_show_store_empty_{}.json",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis()
|
||||||
|
));
|
||||||
|
let storage = RuntimeStateStorage::new(&path);
|
||||||
|
std::fs::write(&path, "").expect("empty file should write");
|
||||||
|
|
||||||
|
let loaded = storage.load_with_recovery();
|
||||||
|
|
||||||
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
||||||
|
assert!(!loaded.loaded_from_disk);
|
||||||
|
assert_eq!(loaded.warnings.len(), 1);
|
||||||
|
assert_eq!(loaded.warnings[0].code, "runtime_state_empty");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_state_storage_recovers_from_invalid_json() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"infinity_vis_show_store_invalid_{}.json",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis()
|
||||||
|
));
|
||||||
|
let storage = RuntimeStateStorage::new(&path);
|
||||||
|
std::fs::write(&path, "{ definitely not json").expect("invalid file should write");
|
||||||
|
|
||||||
|
let loaded = storage.load_with_recovery();
|
||||||
|
|
||||||
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
||||||
|
assert!(!loaded.loaded_from_disk);
|
||||||
|
assert_eq!(loaded.warnings.len(), 1);
|
||||||
|
assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_state_storage_recovers_from_unsupported_schema() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"infinity_vis_show_store_schema_{}.json",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis()
|
||||||
|
));
|
||||||
|
let storage = RuntimeStateStorage::new(&path);
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{
|
||||||
|
"schema_version": 99,
|
||||||
|
"saved_at_unix_ms": 1,
|
||||||
|
"runtime": {
|
||||||
|
"active_scene": null,
|
||||||
|
"global": {
|
||||||
|
"blackout": false,
|
||||||
|
"master_brightness": 0.2,
|
||||||
|
"transition_duration_ms": 150,
|
||||||
|
"transition_style": "crossfade"
|
||||||
|
},
|
||||||
|
"user_presets": [],
|
||||||
|
"user_groups": [],
|
||||||
|
"creative_snapshots": []
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("schema file should write");
|
||||||
|
|
||||||
|
let loaded = storage.load_with_recovery();
|
||||||
|
|
||||||
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
||||||
|
assert!(!loaded.loaded_from_disk);
|
||||||
|
assert_eq!(loaded.warnings.len(), 1);
|
||||||
|
assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
1023
crates/infinity_host/src/simulation.rs
Normal file
1023
crates/infinity_host/src/simulation.rs
Normal file
File diff suppressed because it is too large
Load Diff
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" }
|
||||||
|
|
||||||
989
crates/infinity_host_api/src/dto.rs
Normal file
989
crates/infinity_host_api/src/dto.rs
Normal file
@@ -0,0 +1,989 @@
|
|||||||
|
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
||||||
|
use infinity_host::{
|
||||||
|
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
|
||||||
|
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
|
||||||
|
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 ApiStateResponse {
|
||||||
|
pub api_version: &'static str,
|
||||||
|
pub generated_at_millis: u64,
|
||||||
|
pub state: ApiStateSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ApiPreviewResponse {
|
||||||
|
pub api_version: &'static str,
|
||||||
|
pub generated_at_millis: u64,
|
||||||
|
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>,
|
||||||
|
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 command_type: String,
|
||||||
|
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 code: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApiEventKind {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
pub transition_style: ApiTransitionStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
pub transition_style: ApiTransitionStyle,
|
||||||
|
pub source: ApiCatalogSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ApiGroupSummary {
|
||||||
|
pub group_id: String,
|
||||||
|
pub member_count: usize,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub source: ApiCatalogSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ApiCreativeSnapshotSummary {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub transition_duration_ms: u32,
|
||||||
|
pub transition_style: ApiTransitionStyle,
|
||||||
|
pub saved_at_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 ApiCatalogSource {
|
||||||
|
BuiltIn,
|
||||||
|
RuntimeUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
},
|
||||||
|
SetTransitionStyle {
|
||||||
|
style: ApiTransitionStyle,
|
||||||
|
},
|
||||||
|
TriggerPanelTest {
|
||||||
|
node_id: String,
|
||||||
|
panel_position: ApiPanelPosition,
|
||||||
|
pattern: ApiTestPattern,
|
||||||
|
},
|
||||||
|
SavePreset {
|
||||||
|
preset_id: String,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
SaveCreativeSnapshot {
|
||||||
|
snapshot_id: String,
|
||||||
|
label: Option<String>,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
RecallCreativeSnapshot {
|
||||||
|
snapshot_id: String,
|
||||||
|
},
|
||||||
|
UpsertGroup {
|
||||||
|
group_id: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
members: Vec<ApiPanelRef>,
|
||||||
|
overwrite: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ApiPanelRef {
|
||||||
|
pub node_id: String,
|
||||||
|
pub panel_position: ApiPanelPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 ApiStateResponse {
|
||||||
|
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
|
||||||
|
Self {
|
||||||
|
api_version: API_VERSION,
|
||||||
|
generated_at_millis: snapshot.generated_at_millis,
|
||||||
|
state: ApiStateSnapshot::from_snapshot(snapshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiPreviewResponse {
|
||||||
|
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
|
||||||
|
Self {
|
||||||
|
api_version: API_VERSION,
|
||||||
|
generated_at_millis: snapshot.generated_at_millis,
|
||||||
|
preview: ApiPreviewSnapshot::from_snapshot(snapshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
transition_style: map_transition_style(preset.transition_style),
|
||||||
|
source: map_catalog_source(preset.source),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
groups: snapshot
|
||||||
|
.catalog
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| ApiGroupSummary {
|
||||||
|
group_id: group.group_id.clone(),
|
||||||
|
member_count: group.member_count,
|
||||||
|
tags: group.tags.clone(),
|
||||||
|
source: map_catalog_source(group.source),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
creative_snapshots: snapshot
|
||||||
|
.catalog
|
||||||
|
.creative_snapshots
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| ApiCreativeSnapshotSummary {
|
||||||
|
snapshot_id: snapshot.snapshot_id.clone(),
|
||||||
|
label: snapshot.label.clone(),
|
||||||
|
pattern_id: snapshot.pattern_id.clone(),
|
||||||
|
target_group: snapshot.target_group.clone(),
|
||||||
|
transition_duration_ms: snapshot.transition_duration_ms,
|
||||||
|
transition_style: map_transition_style(snapshot.transition_style),
|
||||||
|
saved_at_unix_ms: snapshot.saved_at_unix_ms,
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
transition_style: map_transition_style(snapshot.global.transition_style),
|
||||||
|
},
|
||||||
|
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::SetTransitionStyle { style } => Ok(HostCommand::SetTransitionStyle(
|
||||||
|
map_command_transition_style(style),
|
||||||
|
)),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ApiCommand::SavePreset {
|
||||||
|
preset_id,
|
||||||
|
overwrite,
|
||||||
|
} => Ok(HostCommand::SavePreset {
|
||||||
|
preset_id,
|
||||||
|
overwrite,
|
||||||
|
}),
|
||||||
|
ApiCommand::SaveCreativeSnapshot {
|
||||||
|
snapshot_id,
|
||||||
|
label,
|
||||||
|
overwrite,
|
||||||
|
} => Ok(HostCommand::SaveCreativeSnapshot {
|
||||||
|
snapshot_id,
|
||||||
|
label,
|
||||||
|
overwrite,
|
||||||
|
}),
|
||||||
|
ApiCommand::RecallCreativeSnapshot { snapshot_id } => {
|
||||||
|
Ok(HostCommand::RecallCreativeSnapshot { snapshot_id })
|
||||||
|
}
|
||||||
|
ApiCommand::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members,
|
||||||
|
overwrite,
|
||||||
|
} => Ok(HostCommand::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
tags,
|
||||||
|
members: members
|
||||||
|
.into_iter()
|
||||||
|
.map(|member| infinity_host::PanelTarget {
|
||||||
|
node_id: member.node_id,
|
||||||
|
panel_position: map_command_panel_position(member.panel_position),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
overwrite,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_command_transition_style(style: ApiTransitionStyle) -> SceneTransitionStyle {
|
||||||
|
match style {
|
||||||
|
ApiTransitionStyle::Snap => SceneTransitionStyle::Snap,
|
||||||
|
ApiTransitionStyle::Crossfade => SceneTransitionStyle::Crossfade,
|
||||||
|
ApiTransitionStyle::Chase => SceneTransitionStyle::Chase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_catalog_source(source: CatalogSource) -> ApiCatalogSource {
|
||||||
|
match source {
|
||||||
|
CatalogSource::BuiltIn => ApiCatalogSource::BuiltIn,
|
||||||
|
CatalogSource::RuntimeUser => ApiCatalogSource::RuntimeUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_event_kind(kind: StatusEventKind) -> ApiEventKind {
|
||||||
|
match kind {
|
||||||
|
StatusEventKind::Info => ApiEventKind::Info,
|
||||||
|
StatusEventKind::Warning => ApiEventKind::Warning,
|
||||||
|
StatusEventKind::Error => ApiEventKind::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 kind_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SetBlackout { .. } => "set_blackout",
|
||||||
|
Self::SetMasterBrightness { .. } => "set_master_brightness",
|
||||||
|
Self::SelectPattern { .. } => "select_pattern",
|
||||||
|
Self::RecallPreset { .. } => "recall_preset",
|
||||||
|
Self::SelectGroup { .. } => "select_group",
|
||||||
|
Self::SetSceneParameter { .. } => "set_scene_parameter",
|
||||||
|
Self::SetTransitionDurationMs { .. } => "set_transition_duration_ms",
|
||||||
|
Self::SetTransitionStyle { .. } => "set_transition_style",
|
||||||
|
Self::TriggerPanelTest { .. } => "trigger_panel_test",
|
||||||
|
Self::SavePreset { .. } => "save_preset",
|
||||||
|
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
|
||||||
|
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
|
||||||
|
Self::UpsertGroup { .. } => "upsert_group",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::SetTransitionStyle { style } => {
|
||||||
|
format!("transition style set to {}", style.label())
|
||||||
|
}
|
||||||
|
Self::TriggerPanelTest {
|
||||||
|
node_id,
|
||||||
|
panel_position,
|
||||||
|
pattern,
|
||||||
|
} => format!(
|
||||||
|
"panel test {} on {}:{}",
|
||||||
|
pattern.label(),
|
||||||
|
node_id,
|
||||||
|
panel_position.label()
|
||||||
|
),
|
||||||
|
Self::SavePreset {
|
||||||
|
preset_id,
|
||||||
|
overwrite,
|
||||||
|
} => {
|
||||||
|
if *overwrite {
|
||||||
|
format!("preset overwritten: {preset_id}")
|
||||||
|
} else {
|
||||||
|
format!("preset saved: {preset_id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::SaveCreativeSnapshot {
|
||||||
|
snapshot_id,
|
||||||
|
overwrite,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *overwrite {
|
||||||
|
format!("creative snapshot overwritten: {snapshot_id}")
|
||||||
|
} else {
|
||||||
|
format!("creative snapshot saved: {snapshot_id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::RecallCreativeSnapshot { snapshot_id } => {
|
||||||
|
format!("creative snapshot recalled: {snapshot_id}")
|
||||||
|
}
|
||||||
|
Self::UpsertGroup {
|
||||||
|
group_id,
|
||||||
|
overwrite,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *overwrite {
|
||||||
|
format!("group updated: {group_id}")
|
||||||
|
} else {
|
||||||
|
format!("group saved: {group_id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiPanelPosition {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Top => "top",
|
||||||
|
Self::Middle => "middle",
|
||||||
|
Self::Bottom => "bottom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiTransitionStyle {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Snap => "snap",
|
||||||
|
Self::Crossfade => "crossfade",
|
||||||
|
Self::Chase => "chase",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<infinity_host::StatusEvent> for ApiEventNotice {
|
||||||
|
fn from(event: infinity_host::StatusEvent) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: map_event_kind(event.kind),
|
||||||
|
code: event.code,
|
||||||
|
message: event.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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::*;
|
||||||
39
crates/infinity_host_api/src/main.rs
Normal file
39
crates/infinity_host_api/src/main.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
#[arg(long, default_value = "data/runtime_state.json")]
|
||||||
|
runtime_state: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let project = load_project(&cli.config)?;
|
||||||
|
let service: Arc<dyn HostApiPort> =
|
||||||
|
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
|
||||||
|
let server = HostApiServer::bind(&cli.bind, service)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Infinity Vis host API listening on http://{}",
|
||||||
|
server.local_addr()
|
||||||
|
);
|
||||||
|
println!("Web UI available at http://{}/", server.local_addr());
|
||||||
|
println!("Runtime state persistence: {}", cli.runtime_state.display());
|
||||||
|
|
||||||
|
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)?)
|
||||||
|
}
|
||||||
467
crates/infinity_host_api/src/server.rs
Normal file
467
crates/infinity_host_api/src/server.rs
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
use crate::dto::{
|
||||||
|
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
|
||||||
|
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
|
||||||
|
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
|
||||||
|
};
|
||||||
|
use crate::websocket::{websocket_accept_value, write_text_frame};
|
||||||
|
use infinity_host::HostApiPort;
|
||||||
|
use 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<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ApiRequestError {
|
||||||
|
status: u16,
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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/state") => {
|
||||||
|
let snapshot = service.snapshot();
|
||||||
|
respond_json(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
&ApiStateResponse::from_snapshot(&snapshot),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
("GET", "/api/v1/preview") => {
|
||||||
|
let snapshot = service.snapshot();
|
||||||
|
respond_json(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
&ApiPreviewResponse::from_snapshot(&snapshot),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
("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, error.status, error.code, error.message),
|
||||||
|
},
|
||||||
|
("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>,
|
||||||
|
) -> Result<(), ApiRequestError> {
|
||||||
|
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body).map_err(|error| {
|
||||||
|
ApiRequestError {
|
||||||
|
status: 400,
|
||||||
|
code: "invalid_request_json".to_string(),
|
||||||
|
message: format!("command request body could not be parsed: {error}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let request_id = parsed.request_id.clone();
|
||||||
|
let command_type = parsed.command.kind_label().to_string();
|
||||||
|
let command = parsed
|
||||||
|
.into_host_command()
|
||||||
|
.map_err(|error| ApiRequestError {
|
||||||
|
status: 400,
|
||||||
|
code: "invalid_command".to_string(),
|
||||||
|
message: error,
|
||||||
|
})?;
|
||||||
|
let outcome = service
|
||||||
|
.send_command(command)
|
||||||
|
.map_err(|error| ApiRequestError {
|
||||||
|
status: 400,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
})?;
|
||||||
|
respond_json(
|
||||||
|
stream,
|
||||||
|
200,
|
||||||
|
&ApiCommandResponse {
|
||||||
|
api_version: API_VERSION,
|
||||||
|
accepted: true,
|
||||||
|
request_id,
|
||||||
|
generated_at_millis: outcome.generated_at_millis,
|
||||||
|
command_type,
|
||||||
|
summary: outcome.summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| ApiRequestError {
|
||||||
|
status: 500,
|
||||||
|
code: "response_write_failed".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = None::<u64>;
|
||||||
|
let mut last_event_signatures = Vec::<(Option<String>, String)>::new();
|
||||||
|
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| match last_event_millis {
|
||||||
|
None => true,
|
||||||
|
Some(last_millis) if event.at_millis > last_millis => true,
|
||||||
|
Some(last_millis) if event.at_millis == last_millis => !last_event_signatures
|
||||||
|
.iter()
|
||||||
|
.any(|signature| signature.0 == event.code && signature.1 == event.message),
|
||||||
|
Some(_) => false,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
new_events.sort_by_key(|event| event.at_millis);
|
||||||
|
for event in new_events {
|
||||||
|
let event_millis = event.at_millis;
|
||||||
|
let current_signature = (event.code.clone(), event.message.clone());
|
||||||
|
send_stream_message(
|
||||||
|
&mut stream,
|
||||||
|
sequence,
|
||||||
|
event_millis,
|
||||||
|
ApiStreamMessage::Event(event.into()),
|
||||||
|
)?;
|
||||||
|
sequence += 1;
|
||||||
|
match last_event_millis {
|
||||||
|
Some(last_millis) if last_millis == event_millis => {
|
||||||
|
last_event_signatures.push(current_signature);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
last_event_millis = Some(event_millis);
|
||||||
|
last_event_signatures = vec![current_signature];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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",
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
_ => "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")
|
||||||
|
}
|
||||||
148
crates/infinity_host_api/src/websocket.rs
Normal file
148
crates/infinity_host_api/src/websocket.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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=");
|
||||||
|
}
|
||||||
|
}
|
||||||
701
crates/infinity_host_api/tests/contract.rs
Normal file
701
crates/infinity_host_api/tests/contract.rs
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
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::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
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> = Arc::new(SimulationHostService::new(sample_project()));
|
||||||
|
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
|
||||||
|
let service: Arc<dyn HostApiPort> = Arc::new(
|
||||||
|
SimulationHostService::try_new_with_persistence(sample_project(), path)
|
||||||
|
.expect("persistent service must initialize"),
|
||||||
|
);
|
||||||
|
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_and_web_assets_target_the_versioned_api_contract() {
|
||||||
|
let server = start_server();
|
||||||
|
let html = send_http_request(server.local_addr(), "GET", "/", None);
|
||||||
|
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
|
||||||
|
let styles = send_http_request(server.local_addr(), "GET", "/styles.css", None);
|
||||||
|
|
||||||
|
assert_eq!(html.status_code, 200);
|
||||||
|
assert!(html
|
||||||
|
.headers
|
||||||
|
.get("content-type")
|
||||||
|
.expect("content-type header")
|
||||||
|
.starts_with("text/html"));
|
||||||
|
assert!(html.body.contains("Preset Capture"));
|
||||||
|
assert!(html.body.contains("Creative Snapshots"));
|
||||||
|
assert!(html.body.contains("Event Stream"));
|
||||||
|
assert!(html.body.contains("Pending Transition"));
|
||||||
|
assert!(html.body.contains("Trigger Transition"));
|
||||||
|
|
||||||
|
assert_eq!(app_js.status_code, 200);
|
||||||
|
assert!(app_js.body.contains("/api/v1/state"));
|
||||||
|
assert!(app_js.body.contains("/api/v1/preview"));
|
||||||
|
assert!(app_js.body.contains("save_preset"));
|
||||||
|
assert!(app_js.body.contains("save_creative_snapshot"));
|
||||||
|
assert!(app_js.body.contains("show_control_session_required"));
|
||||||
|
assert!(app_js.body.contains("trigger_transition"));
|
||||||
|
assert_eq!(styles.status_code, 200);
|
||||||
|
assert!(styles
|
||||||
|
.headers
|
||||||
|
.get("content-type")
|
||||||
|
.expect("content-type header")
|
||||||
|
.starts_with("text/css"));
|
||||||
|
assert!(styles.body.contains(".preview-grid"));
|
||||||
|
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
|
||||||
|
let server = start_server();
|
||||||
|
let html = send_http_request(server.local_addr(), "GET", "/", None);
|
||||||
|
let mut stream = open_websocket(server.local_addr());
|
||||||
|
|
||||||
|
assert_eq!(html.status_code, 200);
|
||||||
|
assert!(html.body.contains("Infinity Vis"));
|
||||||
|
assert!(html.body.contains("connection-pill"));
|
||||||
|
assert!(html.body.contains("preview-grid"));
|
||||||
|
|
||||||
|
let first_frame = read_websocket_text_frame(&mut stream);
|
||||||
|
let second_frame = read_websocket_text_frame(&mut stream);
|
||||||
|
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
|
||||||
|
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
|
||||||
|
|
||||||
|
assert_eq!(first_payload["message"]["type"], "snapshot");
|
||||||
|
assert_eq!(second_payload["message"]["type"], "preview");
|
||||||
|
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
|
||||||
|
let server = start_server();
|
||||||
|
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
|
||||||
|
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
|
||||||
|
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
|
||||||
|
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
|
||||||
|
|
||||||
|
assert_eq!(state.status_code, 200);
|
||||||
|
assert_eq!(state_body["api_version"], "v1");
|
||||||
|
assert!(state_body.get("state").is_some());
|
||||||
|
assert!(state_body.get("preview").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["nodes"].as_array().map(Vec::len),
|
||||||
|
Some(6)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(preview.status_code, 200);
|
||||||
|
assert_eq!(preview_body["api_version"], "v1");
|
||||||
|
assert!(preview_body.get("preview").is_some());
|
||||||
|
assert!(preview_body.get("state").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
preview_body["preview"]["panels"].as_array().map(Vec::len),
|
||||||
|
Some(18)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(snapshot.status_code, 200);
|
||||||
|
assert_eq!(snapshot_body["api_version"], "v1");
|
||||||
|
assert!(snapshot_body.get("state").is_some());
|
||||||
|
assert!(snapshot_body.get("preview").is_some());
|
||||||
|
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_flow_updates_group_parameters_transition_and_blackout() {
|
||||||
|
let server = start_server();
|
||||||
|
|
||||||
|
let responses = [
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_group","payload":{"group_id":"top_panels"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"speed","value":{"kind":"scalar","value":2.25}}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":320}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"gradient"}}}"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for response in responses {
|
||||||
|
assert_eq!(response.status_code, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["global"]["selected_group"],
|
||||||
|
"top_panels"
|
||||||
|
);
|
||||||
|
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
|
||||||
|
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["active_scene"]["pattern_id"],
|
||||||
|
"gradient"
|
||||||
|
);
|
||||||
|
assert!(state_body["state"]["active_scene"]["parameters"]
|
||||||
|
.as_array()
|
||||||
|
.expect("parameter array")
|
||||||
|
.iter()
|
||||||
|
.any(|parameter| parameter["key"] == "speed" && parameter["value"]["value"] == 2.25));
|
||||||
|
|
||||||
|
let blackout = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
|
||||||
|
);
|
||||||
|
let blackout_body: Value = serde_json::from_str(&blackout.body).expect("blackout json");
|
||||||
|
assert_eq!(blackout.status_code, 200);
|
||||||
|
assert_eq!(blackout_body["command_type"], "set_blackout");
|
||||||
|
|
||||||
|
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
|
||||||
|
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
|
||||||
|
assert!(preview_body["preview"]["panels"]
|
||||||
|
.as_array()
|
||||||
|
.expect("preview panels")
|
||||||
|
.iter()
|
||||||
|
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
|
||||||
|
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presets_and_creative_snapshots_persist_across_restart() {
|
||||||
|
let runtime_state_path = unique_runtime_state_path("persistence");
|
||||||
|
let server = start_server_with_runtime_state(&runtime_state_path);
|
||||||
|
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_group","payload":{"group_id":"bottom_panels"}}}"#,
|
||||||
|
);
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
|
||||||
|
);
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.93}}}}"#,
|
||||||
|
);
|
||||||
|
let save_preset = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"save_preset","payload":{"preset_id":"user_noise_floor","overwrite":false}}}"#,
|
||||||
|
);
|
||||||
|
let save_snapshot = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"variant_floor","label":"Variant Floor","overwrite":false}}}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(save_preset.status_code, 200);
|
||||||
|
assert_eq!(save_snapshot.status_code, 200);
|
||||||
|
server.shutdown();
|
||||||
|
|
||||||
|
let restarted = start_server_with_runtime_state(&runtime_state_path);
|
||||||
|
let catalog = send_http_request(restarted.local_addr(), "GET", "/api/v1/catalog", None);
|
||||||
|
let state = send_http_request(restarted.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
|
||||||
|
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
|
||||||
|
assert!(catalog_body["presets"]
|
||||||
|
.as_array()
|
||||||
|
.expect("preset array")
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset["preset_id"] == "user_noise_floor"
|
||||||
|
&& preset["source"] == "runtime_user"));
|
||||||
|
assert!(catalog_body["creative_snapshots"]
|
||||||
|
.as_array()
|
||||||
|
.expect("snapshot array")
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
|
||||||
|
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["active_scene"]["target_group"],
|
||||||
|
"bottom_panels"
|
||||||
|
);
|
||||||
|
assert!(state_body["state"]["active_scene"]["parameters"]
|
||||||
|
.as_array()
|
||||||
|
.expect("parameter array")
|
||||||
|
.iter()
|
||||||
|
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.93));
|
||||||
|
|
||||||
|
restarted.shutdown();
|
||||||
|
let _ = std::fs::remove_file(runtime_state_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_control_flows_cover_runtime_group_preset_snapshot_transition_blackout_and_eventfeed() {
|
||||||
|
let server = start_server();
|
||||||
|
let mut stream = open_websocket(server.local_addr());
|
||||||
|
|
||||||
|
let _ = read_websocket_text_frame(&mut stream);
|
||||||
|
let _ = read_websocket_text_frame(&mut stream);
|
||||||
|
|
||||||
|
let flow_responses = [
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"upsert_group","payload":{"group_id":"focus_pair","tags":["runtime","focus"],"members":[{"node_id":"node-a","panel_position":"top"},{"node_id":"node-a","panel_position":"middle"}],"overwrite":false}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_group","payload":{"group_id":"focus_pair"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":480}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.67}}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":false}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.81}}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":true}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"recall_preset","payload":{"preset_id":"focus_noise"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"focus_variant","label":"Focus Variant","overwrite":false}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"pulse"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"focus_variant"}}}"#,
|
||||||
|
),
|
||||||
|
send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for response in flow_responses {
|
||||||
|
assert_eq!(response.status_code, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
|
||||||
|
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
|
||||||
|
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
|
||||||
|
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["global"]["selected_group"],
|
||||||
|
"focus_pair"
|
||||||
|
);
|
||||||
|
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
|
||||||
|
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 480);
|
||||||
|
assert_eq!(state_body["state"]["global"]["blackout"], true);
|
||||||
|
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["active_scene"]["target_group"],
|
||||||
|
"focus_pair"
|
||||||
|
);
|
||||||
|
assert!(state_body["state"]["active_scene"]["parameters"]
|
||||||
|
.as_array()
|
||||||
|
.expect("parameter array")
|
||||||
|
.iter()
|
||||||
|
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.81));
|
||||||
|
|
||||||
|
assert!(catalog_body["groups"]
|
||||||
|
.as_array()
|
||||||
|
.expect("group array")
|
||||||
|
.iter()
|
||||||
|
.any(|group| group["group_id"] == "focus_pair" && group["source"] == "runtime_user"));
|
||||||
|
assert!(catalog_body["presets"]
|
||||||
|
.as_array()
|
||||||
|
.expect("preset array")
|
||||||
|
.iter()
|
||||||
|
.any(|preset| preset["preset_id"] == "focus_noise"
|
||||||
|
&& preset["source"] == "runtime_user"
|
||||||
|
&& preset["transition_style"] == "chase"));
|
||||||
|
assert!(catalog_body["creative_snapshots"]
|
||||||
|
.as_array()
|
||||||
|
.expect("snapshot array")
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot["snapshot_id"] == "focus_variant"
|
||||||
|
&& snapshot["label"] == "Focus Variant"));
|
||||||
|
assert!(preview_body["preview"]["panels"]
|
||||||
|
.as_array()
|
||||||
|
.expect("preview panels")
|
||||||
|
.iter()
|
||||||
|
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
|
||||||
|
|
||||||
|
let mut event_messages = Vec::new();
|
||||||
|
for _ in 0..24 {
|
||||||
|
let frame = read_websocket_text_frame(&mut stream);
|
||||||
|
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
|
||||||
|
if payload["message"]["type"] == "event" {
|
||||||
|
if let Some(message) = payload["message"]["payload"]["message"].as_str() {
|
||||||
|
event_messages.push(message.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(event_messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.contains("group saved: focus_pair")));
|
||||||
|
assert!(event_messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.contains("preset overwritten: focus_noise")));
|
||||||
|
assert!(event_messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.contains("creative snapshot recalled: focus_variant")));
|
||||||
|
assert!(event_messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.contains("global blackout enabled")));
|
||||||
|
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_runtime_state_file_falls_back_without_blocking_server_start() {
|
||||||
|
let runtime_state_path = unique_runtime_state_path("invalid_runtime");
|
||||||
|
std::fs::write(&runtime_state_path, "{ broken").expect("invalid runtime state should write");
|
||||||
|
|
||||||
|
let server = start_server_with_runtime_state(&runtime_state_path);
|
||||||
|
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
let mut stream = open_websocket(server.local_addr());
|
||||||
|
|
||||||
|
assert_eq!(state.status_code, 200);
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["active_scene"]["pattern_id"],
|
||||||
|
"solid_color"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = read_websocket_text_frame(&mut stream);
|
||||||
|
let _ = read_websocket_text_frame(&mut stream);
|
||||||
|
|
||||||
|
let mut saw_recovery_warning = false;
|
||||||
|
for _ in 0..8 {
|
||||||
|
let frame = read_websocket_text_frame(&mut stream);
|
||||||
|
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
|
||||||
|
if payload["message"]["type"] == "event"
|
||||||
|
&& payload["message"]["payload"]["code"] == "runtime_state_parse_failed"
|
||||||
|
{
|
||||||
|
saw_recovery_warning = true;
|
||||||
|
assert_eq!(payload["message"]["payload"]["kind"], "warning");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
saw_recovery_warning,
|
||||||
|
"expected recovery warning event after invalid runtime state"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
server.shutdown();
|
||||||
|
let _ = std::fs::remove_file(runtime_state_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
|
||||||
|
let server = start_server();
|
||||||
|
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 invalid = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
|
||||||
|
);
|
||||||
|
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid json");
|
||||||
|
assert_eq!(invalid.status_code, 400);
|
||||||
|
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
|
||||||
|
|
||||||
|
let mut saw_warning = false;
|
||||||
|
for _ in 0..12 {
|
||||||
|
let frame = read_websocket_text_frame(&mut stream);
|
||||||
|
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
|
||||||
|
if payload["message"]["type"] == "event"
|
||||||
|
&& payload["message"]["payload"]["code"] == "unknown_creative_snapshot"
|
||||||
|
{
|
||||||
|
saw_warning = true;
|
||||||
|
assert_eq!(payload["message"]["payload"]["kind"], "warning");
|
||||||
|
assert_eq!(
|
||||||
|
payload["message"]["payload"]["code"],
|
||||||
|
"unknown_creative_snapshot"
|
||||||
|
);
|
||||||
|
assert!(payload["message"]["payload"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.expect("event message")
|
||||||
|
.contains("does_not_exist"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_warning, "expected warning event after failed command");
|
||||||
|
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "longer load-oriented sequence for platform hardening"]
|
||||||
|
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
|
||||||
|
let server = start_server();
|
||||||
|
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
|
||||||
|
let groups = [
|
||||||
|
None,
|
||||||
|
Some("top_panels"),
|
||||||
|
Some("middle_panels"),
|
||||||
|
Some("bottom_panels"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for index in 0..80 {
|
||||||
|
let pattern = patterns[index % patterns.len()];
|
||||||
|
let group = groups[index % groups.len()];
|
||||||
|
let brightness = ((index % 10) as f32) / 10.0;
|
||||||
|
let speed = 0.5 + (index % 6) as f32 * 0.25;
|
||||||
|
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
&format!(
|
||||||
|
r#"{{"command":{{"type":"select_pattern","payload":{{"pattern_id":"{pattern}"}}}}}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
&format!(
|
||||||
|
r#"{{"command":{{"type":"set_master_brightness","payload":{{"value":{brightness}}}}}}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
&format!(
|
||||||
|
r#"{{"command":{{"type":"set_scene_parameter","payload":{{"key":"speed","value":{{"kind":"scalar","value":{speed}}}}}}}}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let group_json = match group {
|
||||||
|
Some(group_id) => format!(r#""{group_id}""#),
|
||||||
|
None => "null".to_string(),
|
||||||
|
};
|
||||||
|
let _ = send_command_json(
|
||||||
|
server.local_addr(),
|
||||||
|
&format!(
|
||||||
|
r#"{{"command":{{"type":"select_group","payload":{{"group_id":{group_json}}}}}}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||||
|
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
|
||||||
|
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
|
||||||
|
|
||||||
|
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||||
|
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
|
||||||
|
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state_body["state"]["panels"].as_array().map(Vec::len),
|
||||||
|
Some(18)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preview_body["preview"]["panels"].as_array().map(Vec::len),
|
||||||
|
Some(18)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog_body["patterns"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len)
|
||||||
|
.unwrap_or_default()
|
||||||
|
>= 5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
|
||||||
|
send_http_request(addr, "POST", "/api/v1/command", Some(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_http_request(
|
||||||
|
addr: SocketAddr,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_runtime_state_path(label: &str) -> PathBuf {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time")
|
||||||
|
.as_millis();
|
||||||
|
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
|
||||||
|
}
|
||||||
13
crates/infinity_host_ui/Cargo.toml
Normal file
13
crates/infinity_host_ui/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "infinity_host_ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
eframe.workspace = true
|
||||||
|
infinity_config = { path = "../infinity_config" }
|
||||||
|
infinity_host = { path = "../infinity_host" }
|
||||||
|
|
||||||
319
crates/infinity_host_ui/src/app.rs
Normal file
319
crates/infinity_host_ui/src/app.rs
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
use eframe::egui::{self, Color32, RichText};
|
||||||
|
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
||||||
|
use infinity_host::{
|
||||||
|
HostCommand, HostSnapshot, HostUiPort, NodeConnectionState, PanelTarget, TestPatternKind,
|
||||||
|
};
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
pub struct HostUiApp {
|
||||||
|
service: Arc<dyn HostUiPort>,
|
||||||
|
snapshot: HostSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostUiApp {
|
||||||
|
pub fn new(service: Arc<dyn HostUiPort>) -> Self {
|
||||||
|
let snapshot = service.snapshot();
|
||||||
|
Self { service, snapshot }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for HostUiApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.snapshot = self.service.snapshot();
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(100));
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("global_controls").show(ctx, |ui| {
|
||||||
|
draw_global_controls(ui, &self.snapshot, &self.service);
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::SidePanel::right("status_panel")
|
||||||
|
.default_width(320.0)
|
||||||
|
.resizable(true)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
draw_status_panel(ui, &self.snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
draw_node_overview(ui, &self.snapshot);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
draw_panel_mapping(ui, &self.snapshot, &self.service);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.heading("Infinity Vis Host UI");
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("backend: {}", snapshot.backend_label))
|
||||||
|
.color(Color32::from_rgb(110, 169, 255)),
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
ui.label(format!("nodes: {}", snapshot.nodes.len()));
|
||||||
|
ui.label(format!("outputs: {}", snapshot.panels.len()));
|
||||||
|
ui.label(format!("schema time: {} ms", snapshot.generated_at_millis));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
let blackout = snapshot.global.blackout;
|
||||||
|
let blackout_button = egui::Button::new(if blackout {
|
||||||
|
RichText::new("Blackout ACTIVE")
|
||||||
|
.strong()
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
} else {
|
||||||
|
RichText::new("Blackout").strong()
|
||||||
|
})
|
||||||
|
.fill(if blackout {
|
||||||
|
Color32::from_rgb(190, 32, 32)
|
||||||
|
} else {
|
||||||
|
Color32::from_rgb(70, 24, 24)
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui.add(blackout_button).clicked() {
|
||||||
|
let _ = service.send_command(HostCommand::SetBlackout(!blackout));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut brightness = snapshot.global.master_brightness;
|
||||||
|
if ui
|
||||||
|
.add(egui::Slider::new(&mut brightness, 0.0..=1.0).text("Master Brightness"))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_pattern = snapshot.global.selected_pattern.clone();
|
||||||
|
egui::ComboBox::from_id_salt("pattern_selector")
|
||||||
|
.width(180.0)
|
||||||
|
.selected_text(selected_pattern.clone())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for pattern in &snapshot.available_patterns {
|
||||||
|
if ui
|
||||||
|
.selectable_label(selected_pattern == *pattern, pattern)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
let _ = service.send_command(HostCommand::SelectPattern(pattern.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_node_overview(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
|
||||||
|
ui.heading("Node Overview");
|
||||||
|
ui.label("Simulation snapshots are shown here without making the UI thread the timing master.");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
ui.columns(3, |columns| {
|
||||||
|
for (index, node) in snapshot.nodes.iter().enumerate() {
|
||||||
|
let column = &mut columns[index % 3];
|
||||||
|
column.group(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new(&node.display_name).strong());
|
||||||
|
ui.label(connection_badge(node.connection));
|
||||||
|
});
|
||||||
|
ui.label(format!("Node ID: {}", node.node_id));
|
||||||
|
ui.label(format!(
|
||||||
|
"IP: {}",
|
||||||
|
node.reserved_ip.as_deref().unwrap_or("unassigned")
|
||||||
|
));
|
||||||
|
ui.label(format!("Panels: {}", node.panel_count));
|
||||||
|
ui.label(format!("Last contact: {} ms", node.last_contact_ms));
|
||||||
|
if let Some(error) = &node.error_status {
|
||||||
|
ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140)));
|
||||||
|
} else {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_panel_mapping(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc<dyn HostUiPort>) {
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Panel Mapping");
|
||||||
|
ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology.");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new("panel_mapping_grid")
|
||||||
|
.num_columns(11)
|
||||||
|
.striped(true)
|
||||||
|
.min_col_width(72.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
header_cell(ui, "Node");
|
||||||
|
header_cell(ui, "Panel");
|
||||||
|
header_cell(ui, "Output");
|
||||||
|
header_cell(ui, "GPIO / Channel");
|
||||||
|
header_cell(ui, "LEDs");
|
||||||
|
header_cell(ui, "Direction");
|
||||||
|
header_cell(ui, "Color");
|
||||||
|
header_cell(ui, "Enabled");
|
||||||
|
header_cell(ui, "Validation");
|
||||||
|
header_cell(ui, "Status");
|
||||||
|
header_cell(ui, "Test");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
for panel in &snapshot.panels {
|
||||||
|
ui.label(&panel.target.node_id);
|
||||||
|
ui.label(panel_position_label(&panel.target.panel_position));
|
||||||
|
ui.label(&panel.physical_output_name);
|
||||||
|
ui.label(&panel.driver_reference);
|
||||||
|
ui.label(panel.led_count.to_string());
|
||||||
|
ui.label(direction_label(&panel.direction));
|
||||||
|
ui.label(color_order_label(&panel.color_order));
|
||||||
|
ui.label(bool_flag(panel.enabled));
|
||||||
|
ui.label(validation_label(&panel.validation_state));
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(connection_badge(panel.connection));
|
||||||
|
if let Some(last_test_ms) = panel.last_test_trigger_ms {
|
||||||
|
ui.label(format!(
|
||||||
|
"last test: {} ms",
|
||||||
|
snapshot.generated_at_millis.saturating_sub(last_test_ms)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(error) = &panel.error_status {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(error).color(Color32::from_rgb(255, 140, 140)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ui.button("Walk 106").clicked() {
|
||||||
|
let _ = service.send_command(HostCommand::TriggerPanelTest {
|
||||||
|
target: PanelTarget {
|
||||||
|
node_id: panel.target.node_id.clone(),
|
||||||
|
panel_position: panel.target.panel_position.clone(),
|
||||||
|
},
|
||||||
|
pattern: TestPatternKind::WalkingPixel106,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) {
|
||||||
|
ui.heading("Status");
|
||||||
|
ui.label("Node connectivity, last contact, and recent control activity.");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label(RichText::new("Engine").strong());
|
||||||
|
ui.label(format!("Pattern: {}", snapshot.active_scene.pattern_id));
|
||||||
|
ui.label(format!(
|
||||||
|
"Preset: {}",
|
||||||
|
snapshot
|
||||||
|
.active_scene
|
||||||
|
.preset_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("custom")
|
||||||
|
));
|
||||||
|
ui.label(format!(
|
||||||
|
"Group: {}",
|
||||||
|
snapshot
|
||||||
|
.active_scene
|
||||||
|
.target_group
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("all_panels")
|
||||||
|
));
|
||||||
|
ui.label(format!(
|
||||||
|
"Frames: {} at {} Hz",
|
||||||
|
snapshot.engine.frame_index, snapshot.engine.frame_hz
|
||||||
|
));
|
||||||
|
if let Some(transition) = &snapshot.engine.active_transition {
|
||||||
|
ui.label(format!(
|
||||||
|
"Transition: {:?} {:.0}%",
|
||||||
|
transition.style,
|
||||||
|
transition.progress * 100.0
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ui.label("Transition: idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
for node in &snapshot.nodes {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new(&node.display_name).strong());
|
||||||
|
ui.label(connection_badge(node.connection));
|
||||||
|
});
|
||||||
|
ui.label(format!("Last contact: {} ms", node.last_contact_ms));
|
||||||
|
ui.label(format!(
|
||||||
|
"Error status: {}",
|
||||||
|
node.error_status.as_deref().unwrap_or("none")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Recent Events");
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(220.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for event in &snapshot.recent_events {
|
||||||
|
ui.label(format!("[{} ms] {}", event.at_millis, event.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_cell(ui: &mut egui::Ui, label: &str) {
|
||||||
|
ui.label(RichText::new(label).strong());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_badge(state: NodeConnectionState) -> RichText {
|
||||||
|
let (label, color) = match state {
|
||||||
|
NodeConnectionState::Online => ("online", Color32::from_rgb(78, 194, 120)),
|
||||||
|
NodeConnectionState::Degraded => ("degraded", Color32::from_rgb(242, 182, 59)),
|
||||||
|
NodeConnectionState::Offline => ("offline", Color32::from_rgb(220, 76, 76)),
|
||||||
|
};
|
||||||
|
RichText::new(label).strong().color(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_position_label(position: &PanelPosition) -> &'static str {
|
||||||
|
match position {
|
||||||
|
PanelPosition::Top => "top",
|
||||||
|
PanelPosition::Middle => "middle",
|
||||||
|
PanelPosition::Bottom => "bottom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction_label(direction: &LedDirection) -> &'static str {
|
||||||
|
match direction {
|
||||||
|
LedDirection::Forward => "forward",
|
||||||
|
LedDirection::Reverse => "reverse",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_order_label(color_order: &ColorOrder) -> &'static str {
|
||||||
|
match color_order {
|
||||||
|
ColorOrder::Rgb => "RGB",
|
||||||
|
ColorOrder::Rbg => "RBG",
|
||||||
|
ColorOrder::Grb => "GRB",
|
||||||
|
ColorOrder::Gbr => "GBR",
|
||||||
|
ColorOrder::Brg => "BRG",
|
||||||
|
ColorOrder::Bgr => "BGR",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validation_label(state: &ValidationState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
ValidationState::PendingHardwareValidation => "pending_validation",
|
||||||
|
ValidationState::Validated => "validated",
|
||||||
|
ValidationState::Retired => "retired",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool_flag(value: bool) -> &'static str {
|
||||||
|
if value {
|
||||||
|
"true"
|
||||||
|
} else {
|
||||||
|
"false"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
crates/infinity_host_ui/src/main.rs
Normal file
46
crates/infinity_host_ui/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
mod app;
|
||||||
|
|
||||||
|
use app::HostUiApp;
|
||||||
|
use clap::Parser;
|
||||||
|
use eframe::egui;
|
||||||
|
use infinity_config::{load_project_from_path, ProjectConfig};
|
||||||
|
use infinity_host::SimulationHostService;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(author, version, about = "Infinity Vis native host UI")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let project = load_project(cli.config.as_deref());
|
||||||
|
let service = SimulationHostService::spawn_shared(project);
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_title("Infinity Vis Host UI")
|
||||||
|
.with_inner_size([1500.0, 940.0])
|
||||||
|
.with_min_inner_size([1200.0, 760.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Infinity Vis Host UI",
|
||||||
|
options,
|
||||||
|
Box::new(move |_creation_context| Ok(Box::new(HostUiApp::new(service.clone())))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_project(path: Option<&std::path::Path>) -> ProjectConfig {
|
||||||
|
if let Some(path) = path {
|
||||||
|
if let Ok(project) = load_project_from_path(path) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||||
|
.expect("embedded example config must parse")
|
||||||
|
}
|
||||||
10
crates/infinity_protocol/Cargo.toml
Normal file
10
crates/infinity_protocol/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "infinity_protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
205
crates/infinity_protocol/src/lib.rs
Normal file
205
crates/infinity_protocol/src/lib.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const CONTROL_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
pub const REALTIME_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ControlEnvelope {
|
||||||
|
pub protocol_version: u16,
|
||||||
|
pub sequence: u32,
|
||||||
|
pub sent_at_millis: u64,
|
||||||
|
pub message: ControlMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlEnvelope {
|
||||||
|
pub fn new(sequence: u32, sent_at_millis: u64, message: ControlMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
protocol_version: CONTROL_PROTOCOL_VERSION,
|
||||||
|
sequence,
|
||||||
|
sent_at_millis,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct RealtimeEnvelope {
|
||||||
|
pub protocol_version: u16,
|
||||||
|
pub sequence: u32,
|
||||||
|
pub sent_at_millis: u64,
|
||||||
|
pub message: RealtimeMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeEnvelope {
|
||||||
|
pub fn new(sequence: u32, sent_at_millis: u64, message: RealtimeMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
protocol_version: REALTIME_PROTOCOL_VERSION,
|
||||||
|
sequence,
|
||||||
|
sent_at_millis,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ControlMessage {
|
||||||
|
DiscoveryHello {
|
||||||
|
host_id: String,
|
||||||
|
},
|
||||||
|
DiscoveryAck {
|
||||||
|
node_id: String,
|
||||||
|
firmware_version: String,
|
||||||
|
capabilities: NodeCapabilities,
|
||||||
|
},
|
||||||
|
ConfigSync {
|
||||||
|
topology_revision: String,
|
||||||
|
outputs: Vec<PanelAssignment>,
|
||||||
|
},
|
||||||
|
HeartbeatRequest {
|
||||||
|
node_id: String,
|
||||||
|
},
|
||||||
|
Heartbeat(NodeHeartbeat),
|
||||||
|
PresetRecall {
|
||||||
|
preset_id: String,
|
||||||
|
transition: TransitionSpec,
|
||||||
|
},
|
||||||
|
Blackout {
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RealtimeMessage {
|
||||||
|
SceneParameters(SceneParametersFrame),
|
||||||
|
PixelFrame(PixelFrame),
|
||||||
|
ResyncRequest {
|
||||||
|
node_id: String,
|
||||||
|
last_sequence_seen: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NodeCapabilities {
|
||||||
|
pub outputs_per_node: u8,
|
||||||
|
pub leds_per_output: u16,
|
||||||
|
pub supported_driver_backends: Vec<String>,
|
||||||
|
pub supports_scene_mode: bool,
|
||||||
|
pub supports_frame_streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct NodeHeartbeat {
|
||||||
|
pub node_id: String,
|
||||||
|
pub fps: f32,
|
||||||
|
pub heartbeat_age_ms: u32,
|
||||||
|
pub dropped_frames: u32,
|
||||||
|
pub reconnect_count: u32,
|
||||||
|
pub free_heap_bytes: Option<u32>,
|
||||||
|
pub temperature_celsius: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PanelAssignment {
|
||||||
|
pub panel: PanelAddress,
|
||||||
|
pub physical_output_name: String,
|
||||||
|
pub driver_reference: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TransitionSpec {
|
||||||
|
pub transition_ms: u32,
|
||||||
|
pub mode: TransitionMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TransitionMode {
|
||||||
|
Snap,
|
||||||
|
Crossfade,
|
||||||
|
Chase,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SceneParametersFrame {
|
||||||
|
pub node_id: String,
|
||||||
|
pub mode: RealtimeMode,
|
||||||
|
pub preset_id: Option<String>,
|
||||||
|
pub effect: String,
|
||||||
|
pub seed: u64,
|
||||||
|
pub palette: Vec<String>,
|
||||||
|
pub master_brightness: f32,
|
||||||
|
pub speed: f32,
|
||||||
|
pub intensity: f32,
|
||||||
|
pub target_group: Option<String>,
|
||||||
|
pub target_outputs: Vec<PanelAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PixelFrame {
|
||||||
|
pub node_id: String,
|
||||||
|
pub frame_index: u64,
|
||||||
|
pub target_outputs: Vec<OutputFrame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct OutputFrame {
|
||||||
|
pub panel: PanelAddress,
|
||||||
|
pub pixels: Vec<Rgb8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Rgb8 {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct PanelAddress {
|
||||||
|
pub node_id: String,
|
||||||
|
pub panel_slot: PanelSlot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PanelSlot {
|
||||||
|
Top,
|
||||||
|
Middle,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RealtimeMode {
|
||||||
|
DistributedScene,
|
||||||
|
FrameStreaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructors_stamp_current_protocol_versions() {
|
||||||
|
let control = ControlEnvelope::new(
|
||||||
|
4,
|
||||||
|
123,
|
||||||
|
ControlMessage::HeartbeatRequest {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let realtime = RealtimeEnvelope::new(
|
||||||
|
5,
|
||||||
|
456,
|
||||||
|
RealtimeMessage::ResyncRequest {
|
||||||
|
node_id: "node-01".to_string(),
|
||||||
|
last_sequence_seen: 4,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(control.protocol_version, CONTROL_PROTOCOL_VERSION);
|
||||||
|
assert_eq!(realtime.protocol_version, REALTIME_PROTOCOL_VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
docs/acceptance_template.md
Normal file
37
docs/acceptance_template.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Acceptance Template
|
||||||
|
|
||||||
|
Date:
|
||||||
|
|
||||||
|
Technician:
|
||||||
|
|
||||||
|
Firmware build:
|
||||||
|
|
||||||
|
Host build:
|
||||||
|
|
||||||
|
## Core Checks
|
||||||
|
|
||||||
|
- [ ] System starts from cold boot
|
||||||
|
- [ ] All 6 nodes discovered
|
||||||
|
- [ ] All 18 outputs configured
|
||||||
|
- [ ] All outputs confirm 106 LEDs
|
||||||
|
- [ ] Global brightness change is visible immediately
|
||||||
|
- [ ] Blackout works and recovers cleanly
|
||||||
|
- [ ] Preset recall works under load
|
||||||
|
- [ ] Node reconnect works during active show
|
||||||
|
- [ ] 8-hour soak run completed without critical fault
|
||||||
|
|
||||||
|
## Mapping Checks
|
||||||
|
|
||||||
|
- [ ] Walking pixel test completed on every output
|
||||||
|
- [ ] Start pixel confirmed for every output
|
||||||
|
- [ ] Direction confirmed for every output
|
||||||
|
- [ ] Color order confirmed for every output
|
||||||
|
- [ ] Top, middle, bottom assignment confirmed on every node
|
||||||
|
- [ ] No mismatch between configured mapping and observed physical behavior
|
||||||
|
|
||||||
|
## Observations
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
- Exceptions:
|
||||||
|
- Follow-up actions:
|
||||||
|
|
||||||
115
docs/architecture.md
Normal file
115
docs/architecture.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a live-capable LED control platform that keeps realtime output deterministic while letting operators change scenes, brightness, tests, and presets without UI jitter leaking into the hot path.
|
||||||
|
|
||||||
|
## Current Priority
|
||||||
|
|
||||||
|
The current delivery order is intentionally software-first:
|
||||||
|
|
||||||
|
1. host-core and shared API
|
||||||
|
2. scene, preset, group, parameter, transition, and simulation model
|
||||||
|
3. web UI as the primary creative surface
|
||||||
|
4. engineering GUI as the technical surface
|
||||||
|
5. external show-control adapters such as grandMA
|
||||||
|
6. hardware validation and real node activation later
|
||||||
|
|
||||||
|
## Layer Split
|
||||||
|
|
||||||
|
1. Control layer
|
||||||
|
- Shared host API first
|
||||||
|
- Creative web UI later
|
||||||
|
- Engineering GUI already implemented in `crates/infinity_host_ui`
|
||||||
|
- Monitoring, mapping, diagnostics, and admin
|
||||||
|
- Never the timing master for LED output
|
||||||
|
2. Realtime engine
|
||||||
|
- Owns the monotonic clock
|
||||||
|
- Computes scene state, transitions, and dirty regions
|
||||||
|
- Produces transport-ready commands or pixel frames
|
||||||
|
3. Transport and node layer
|
||||||
|
- Discovery, heartbeat, config sync, sequencing, and recovery
|
||||||
|
- Control protocol and realtime protocol stay separate
|
||||||
|
- Latest realtime state wins, stale frames may be dropped
|
||||||
|
4. ESP32 firmware
|
||||||
|
- Receives commands
|
||||||
|
- Maintains local buffers
|
||||||
|
- Drives three independent outputs per node
|
||||||
|
- Handles watchdog and reconnect logic locally
|
||||||
|
|
||||||
|
## Runtime Model
|
||||||
|
|
||||||
|
- Logic tick target: 120 Hz
|
||||||
|
- Frame synthesis target: 60 Hz
|
||||||
|
- Network send target: 40-60 Hz, profile dependent
|
||||||
|
- Preview target: 10-15 Hz
|
||||||
|
|
||||||
|
Preview and telemetry are explicitly degradable. Realtime output is not.
|
||||||
|
|
||||||
|
## Shared Surface Model
|
||||||
|
|
||||||
|
Every surface must talk to the same host API:
|
||||||
|
|
||||||
|
- engineering GUI
|
||||||
|
- future creative web UI
|
||||||
|
- CLI inspection
|
||||||
|
- future grandMA adapter
|
||||||
|
|
||||||
|
The host core now also carries a runtime show store and persistence layer for:
|
||||||
|
|
||||||
|
- saved presets
|
||||||
|
- runtime user groups
|
||||||
|
- active scene state
|
||||||
|
- creative snapshots and variants
|
||||||
|
|
||||||
|
The current software-first implementation uses a simulation-backed host API so looks, presets, parameters, and grouping can be developed before real node activation.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
### Distributed Scene Mode
|
||||||
|
|
||||||
|
- Default operating mode
|
||||||
|
- Host sends scene parameters, time basis, seed, palette, and transitions
|
||||||
|
- Nodes render locally for low bandwidth and better resilience
|
||||||
|
|
||||||
|
### Frame Streaming Mode
|
||||||
|
|
||||||
|
- Used for mapping tests, debugging, and effects that cannot run node-local
|
||||||
|
- Host sends explicit output frames
|
||||||
|
- Kept logically separate so it does not contaminate the primary scene pipeline
|
||||||
|
|
||||||
|
## Mapping Model
|
||||||
|
|
||||||
|
The project configuration separates mapping into three layers:
|
||||||
|
|
||||||
|
1. Hardware mapping
|
||||||
|
- Node ID
|
||||||
|
- Top, middle, bottom output
|
||||||
|
- Physical output label
|
||||||
|
- Driver channel reference
|
||||||
|
- LED count, direction, color order, enable flag
|
||||||
|
2. Layout mapping
|
||||||
|
- Optional row and column placement
|
||||||
|
- Optional preview transforms only
|
||||||
|
3. Group mapping
|
||||||
|
- Explicit groups for artistic control and fast operator access
|
||||||
|
|
||||||
|
The current example config intentionally keeps layout mapping empty because the old XML is only a spatial reference and the final node-to-room placement must still be confirmed on real hardware.
|
||||||
|
|
||||||
|
## Validation Gates
|
||||||
|
|
||||||
|
The codebase deliberately blocks activation when these remain unresolved:
|
||||||
|
|
||||||
|
- `UART 6`, `UART 5`, `UART 4` still marked as `pending_validation`
|
||||||
|
- output validation state is not `validated`
|
||||||
|
- LED count deviates from 106
|
||||||
|
- node outputs are missing top, middle, or bottom
|
||||||
|
- driver references are ambiguous or duplicated per node
|
||||||
|
|
||||||
|
## Planned Next Steps
|
||||||
|
|
||||||
|
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. Add future external show-control bridges such as grandMA on the same API boundary and generic adapter interface
|
||||||
|
5. Keep hardware activation behind explicit later validation gates
|
||||||
46
docs/build_and_deploy.md
Normal file
46
docs/build_and_deploy.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Build and Deploy
|
||||||
|
|
||||||
|
## Host Side
|
||||||
|
|
||||||
|
Required tools:
|
||||||
|
|
||||||
|
- Rust stable toolchain
|
||||||
|
- `cargo`
|
||||||
|
|
||||||
|
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 --runtime-state data/runtime_state.json
|
||||||
|
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 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/`. Runtime creative data such as saved presets, groups, active scene state, and creative snapshots are persisted to `data/runtime_state.json` by default.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p infinity_host -- validate --config config/project.example.toml --mode activation
|
||||||
|
```
|
||||||
|
|
||||||
|
Activation mode is expected to fail until the hardware mapping has been confirmed and the config is updated from `pending_validation` to concrete driver references.
|
||||||
|
|
||||||
|
## Firmware Side
|
||||||
|
|
||||||
|
Required tools:
|
||||||
|
|
||||||
|
- ESP-IDF
|
||||||
|
- Xtensa or RISC-V toolchain matching the actual ESP32 variant
|
||||||
|
|
||||||
|
Suggested layout:
|
||||||
|
|
||||||
|
- `firmware/esp32_node/`
|
||||||
|
- build with `idf.py build`
|
||||||
|
- flash with `idf.py -p <serial-port> flash monitor`
|
||||||
|
|
||||||
|
The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`.
|
||||||
87
docs/config_schema.md
Normal file
87
docs/config_schema.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Config Schema
|
||||||
|
|
||||||
|
## Primary File
|
||||||
|
|
||||||
|
The example project file is [config/project.example.toml](../config/project.example.toml).
|
||||||
|
|
||||||
|
## Root Objects
|
||||||
|
|
||||||
|
- `metadata`
|
||||||
|
- `topology`
|
||||||
|
- `transport_profiles`
|
||||||
|
- `safety_profiles`
|
||||||
|
- `presets`
|
||||||
|
|
||||||
|
## `metadata`
|
||||||
|
|
||||||
|
- `project_name`
|
||||||
|
- `schema_version`
|
||||||
|
- `default_transport_profile`
|
||||||
|
- `default_safety_profile`
|
||||||
|
|
||||||
|
## `topology`
|
||||||
|
|
||||||
|
- `expected_node_count`
|
||||||
|
- `outputs_per_node`
|
||||||
|
- `leds_per_output`
|
||||||
|
- `nodes`
|
||||||
|
- `layout_panels`
|
||||||
|
- `groups`
|
||||||
|
|
||||||
|
## `topology.nodes[]`
|
||||||
|
|
||||||
|
- `node_id`
|
||||||
|
- `display_name`
|
||||||
|
- `network.reserved_ip`
|
||||||
|
- `network.telemetry_label`
|
||||||
|
- `outputs`
|
||||||
|
|
||||||
|
## `topology.nodes[].outputs[]`
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `panel_position`
|
||||||
|
- `physical_output_name`
|
||||||
|
- `driver_channel.kind`
|
||||||
|
- `driver_channel.reference`
|
||||||
|
- `led_count`
|
||||||
|
- `direction`
|
||||||
|
- `color_order`
|
||||||
|
- `enabled`
|
||||||
|
- `validation_state`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `logical_panel_name`
|
||||||
|
|
||||||
|
## Activation Rules
|
||||||
|
|
||||||
|
Structural validation accepts `pending_validation` so the system can model unresolved wiring.
|
||||||
|
|
||||||
|
Activation validation rejects any output that is still:
|
||||||
|
|
||||||
|
- `driver_channel.kind = "pending_validation"`
|
||||||
|
- `validation_state != "validated"`
|
||||||
|
|
||||||
|
This is intentional and prevents accidental deployment against guessed hardware assumptions.
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
`topology.groups[]` keeps grouping explicit and simple:
|
||||||
|
|
||||||
|
- `group_id`
|
||||||
|
- `tags`
|
||||||
|
- `members[] = { node_id, panel_position }`
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
`topology.layout_panels[]` is optional and only needed for preview or spatial effects:
|
||||||
|
|
||||||
|
- `node_id`
|
||||||
|
- `panel_position`
|
||||||
|
- `row`
|
||||||
|
- `column`
|
||||||
|
- `rotation_degrees`
|
||||||
|
- `mirror_x`
|
||||||
|
- `mirror_y`
|
||||||
|
|
||||||
397
docs/host_api.md
Normal file
397
docs/host_api.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# Host API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The host API is the stable external product boundary for:
|
||||||
|
|
||||||
|
- the creative web UI
|
||||||
|
- the native engineering GUI
|
||||||
|
- future remote operator clients
|
||||||
|
- later external show-control adapters such as a grandMA bridge
|
||||||
|
|
||||||
|
The realtime rule remains strict:
|
||||||
|
|
||||||
|
- the API is a control and observation layer
|
||||||
|
- the host core remains the timing authority
|
||||||
|
- no frontend or external adapter is allowed to become the LED clock
|
||||||
|
|
||||||
|
## Runtime Components
|
||||||
|
|
||||||
|
Core and API implementation:
|
||||||
|
|
||||||
|
- `crates/infinity_host/src/control.rs`
|
||||||
|
- `crates/infinity_host/src/scene.rs`
|
||||||
|
- `crates/infinity_host/src/show_store.rs`
|
||||||
|
- `crates/infinity_host/src/simulation.rs`
|
||||||
|
- `crates/infinity_host/src/external_control.rs`
|
||||||
|
- `crates/infinity_host_api/src/dto.rs`
|
||||||
|
- `crates/infinity_host_api/src/server.rs`
|
||||||
|
|
||||||
|
Server startup:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Creative web UI V1 is served by the same process:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:9001/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versioning Policy
|
||||||
|
|
||||||
|
The current public contract is `v1`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- all public HTTP and WebSocket routes are namespaced under `/api/v1`
|
||||||
|
- every response body carries `api_version: "v1"`
|
||||||
|
- additive fields are allowed inside `v1`
|
||||||
|
- semantic breaking changes require a new version namespace
|
||||||
|
- external consumers must treat undocumented internal-only fields as unstable and ignore them
|
||||||
|
|
||||||
|
## Stable External Models
|
||||||
|
|
||||||
|
Stable external response families:
|
||||||
|
|
||||||
|
- command response
|
||||||
|
- state snapshot
|
||||||
|
- preview snapshot
|
||||||
|
- combined snapshot
|
||||||
|
- catalog
|
||||||
|
- event stream
|
||||||
|
- typed error object
|
||||||
|
|
||||||
|
Stable external command families:
|
||||||
|
|
||||||
|
- global control
|
||||||
|
- pattern and preset selection
|
||||||
|
- group targeting
|
||||||
|
- scene parameter updates
|
||||||
|
- transition configuration
|
||||||
|
- preset persistence
|
||||||
|
- creative snapshot persistence and recall
|
||||||
|
- panel test trigger
|
||||||
|
|
||||||
|
## Internal Versus External Fields
|
||||||
|
|
||||||
|
External and stable in `v1`:
|
||||||
|
|
||||||
|
- every field defined in `crates/infinity_host_api/src/dto.rs`
|
||||||
|
- route names and payload shapes documented below
|
||||||
|
- error object shape `{ api_version, error: { code, message } }`
|
||||||
|
- event stream envelope shape `{ api_version, sequence, generated_at_millis, message }`
|
||||||
|
|
||||||
|
Internal and not part of the API contract:
|
||||||
|
|
||||||
|
- the exact shape of `HostSnapshot` in `crates/infinity_host/src/control.rs`
|
||||||
|
- simulation-only storage layout in `data/runtime_state.json`
|
||||||
|
- internal event history buffering size
|
||||||
|
- internal scene library structures in `show_store.rs`
|
||||||
|
- engineering-GUI-specific rendering or polling behavior
|
||||||
|
|
||||||
|
## HTTP Endpoints
|
||||||
|
|
||||||
|
### GET `/api/v1/state`
|
||||||
|
|
||||||
|
Returns only the stable state snapshot.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"generated_at_millis": 512,
|
||||||
|
"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": "gradient",
|
||||||
|
"selected_group": "top_panels",
|
||||||
|
"transition_duration_ms": 320,
|
||||||
|
"transition_style": "chase"
|
||||||
|
},
|
||||||
|
"engine": {
|
||||||
|
"logic_hz": 120,
|
||||||
|
"frame_hz": 60,
|
||||||
|
"preview_hz": 15,
|
||||||
|
"uptime_ms": 512,
|
||||||
|
"frame_index": 30,
|
||||||
|
"dropped_frames": 0,
|
||||||
|
"active_transition": {
|
||||||
|
"style": "chase",
|
||||||
|
"from_pattern_id": "solid_color",
|
||||||
|
"to_pattern_id": "gradient",
|
||||||
|
"duration_ms": 320,
|
||||||
|
"progress": 0.28
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/v1/preview`
|
||||||
|
|
||||||
|
Returns only the stable preview snapshot.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"generated_at_millis": 512,
|
||||||
|
"preview": {
|
||||||
|
"generated_at_millis": 512,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"node_id": "node-01",
|
||||||
|
"panel_position": "top",
|
||||||
|
"representative_color_hex": "#FF8A5B",
|
||||||
|
"sample_led_hex": [
|
||||||
|
"#FF8A5B",
|
||||||
|
"#F36E43",
|
||||||
|
"#D85A2F"
|
||||||
|
],
|
||||||
|
"energy_percent": 47,
|
||||||
|
"source": "transition"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/v1/snapshot`
|
||||||
|
|
||||||
|
Returns the convenience composition of `state` plus `preview`.
|
||||||
|
|
||||||
|
This route exists for lightweight clients and debugging. Consumers that want strict separation should prefer `GET /api/v1/state` and `GET /api/v1/preview`.
|
||||||
|
|
||||||
|
### GET `/api/v1/catalog`
|
||||||
|
|
||||||
|
Returns the stable creative library:
|
||||||
|
|
||||||
|
- patterns
|
||||||
|
- presets
|
||||||
|
- groups
|
||||||
|
- creative snapshots
|
||||||
|
|
||||||
|
Example preset summary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset_id": "ocean_gradient",
|
||||||
|
"pattern_id": "gradient",
|
||||||
|
"target_group": null,
|
||||||
|
"transition_duration_ms": 320,
|
||||||
|
"transition_style": "crossfade",
|
||||||
|
"source": "built_in"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example creative snapshot summary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"snapshot_id": "variant_floor",
|
||||||
|
"label": "Variant Floor",
|
||||||
|
"pattern_id": "noise",
|
||||||
|
"target_group": "bottom_panels",
|
||||||
|
"transition_duration_ms": 220,
|
||||||
|
"transition_style": "chase",
|
||||||
|
"saved_at_unix_ms": 1760000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/v1/presets`
|
||||||
|
|
||||||
|
Returns only preset summaries.
|
||||||
|
|
||||||
|
### GET `/api/v1/groups`
|
||||||
|
|
||||||
|
Returns only group summaries.
|
||||||
|
|
||||||
|
### POST `/api/v1/command`
|
||||||
|
|
||||||
|
Accepts one versioned command envelope.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "web-1713352662000",
|
||||||
|
"command": {
|
||||||
|
"type": "save_creative_snapshot",
|
||||||
|
"payload": {
|
||||||
|
"snapshot_id": "variant_floor",
|
||||||
|
"label": "Variant Floor",
|
||||||
|
"overwrite": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"accepted": true,
|
||||||
|
"request_id": "web-1713352662000",
|
||||||
|
"generated_at_millis": 522,
|
||||||
|
"command_type": "save_creative_snapshot",
|
||||||
|
"summary": "creative snapshot saved: variant_floor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stable Error Object
|
||||||
|
|
||||||
|
All API failures return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"error": {
|
||||||
|
"code": "unknown_creative_snapshot",
|
||||||
|
"message": "creative snapshot 'does_not_exist' does not exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stable `v1` error families currently include:
|
||||||
|
|
||||||
|
- `invalid_request_json`
|
||||||
|
- `invalid_command`
|
||||||
|
- `unknown_group`
|
||||||
|
- `unknown_preset`
|
||||||
|
- `unknown_creative_snapshot`
|
||||||
|
- `preset_exists`
|
||||||
|
- `snapshot_exists`
|
||||||
|
- `group_exists`
|
||||||
|
- `persist_failed`
|
||||||
|
- `missing_websocket_key`
|
||||||
|
- `not_found`
|
||||||
|
|
||||||
|
## WebSocket Event Stream
|
||||||
|
|
||||||
|
### WS `/api/v1/stream`
|
||||||
|
|
||||||
|
The stream emits a typed envelope with a monotonic sequence counter:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"sequence": 19,
|
||||||
|
"generated_at_millis": 880,
|
||||||
|
"message": {
|
||||||
|
"type": "event",
|
||||||
|
"payload": {
|
||||||
|
"kind": "warning",
|
||||||
|
"code": "unknown_creative_snapshot",
|
||||||
|
"message": "creative snapshot 'does_not_exist' does not exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stable message types:
|
||||||
|
|
||||||
|
- `snapshot`
|
||||||
|
- `preview`
|
||||||
|
- `event`
|
||||||
|
|
||||||
|
Stable event kinds:
|
||||||
|
|
||||||
|
- `info`
|
||||||
|
- `warning`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
## Guaranteed Commands In `v1`
|
||||||
|
|
||||||
|
Guaranteed control commands:
|
||||||
|
|
||||||
|
- `set_blackout`
|
||||||
|
- `set_master_brightness`
|
||||||
|
- `select_pattern`
|
||||||
|
- `recall_preset`
|
||||||
|
- `select_group`
|
||||||
|
- `set_scene_parameter`
|
||||||
|
- `set_transition_duration_ms`
|
||||||
|
- `set_transition_style`
|
||||||
|
- `trigger_panel_test`
|
||||||
|
|
||||||
|
Guaranteed persistence and creative-library commands:
|
||||||
|
|
||||||
|
- `save_preset`
|
||||||
|
- `save_creative_snapshot`
|
||||||
|
- `recall_creative_snapshot`
|
||||||
|
- `upsert_group`
|
||||||
|
|
||||||
|
## Persistence Behavior
|
||||||
|
|
||||||
|
The simulation-backed host service now persists runtime-facing creative data to `data/runtime_state.json` by default.
|
||||||
|
|
||||||
|
Persisted data includes:
|
||||||
|
|
||||||
|
- active scene
|
||||||
|
- global blackout and brightness state
|
||||||
|
- transition duration and style
|
||||||
|
- runtime user presets
|
||||||
|
- runtime user groups
|
||||||
|
- creative snapshots and variants
|
||||||
|
|
||||||
|
This persistence file is an internal runtime artifact, not the public API contract.
|
||||||
|
|
||||||
|
## External Show Control Adapter Boundary
|
||||||
|
|
||||||
|
The generic internal adapter surface lives in:
|
||||||
|
|
||||||
|
- `crates/infinity_host/src/external_control.rs`
|
||||||
|
|
||||||
|
Key rule:
|
||||||
|
|
||||||
|
- future adapters may only translate external intent into the defined host command surface
|
||||||
|
- they must not reach into simulation internals, UI state, or hardware driver details directly
|
||||||
|
|
||||||
|
## Contract And Integration Coverage
|
||||||
|
|
||||||
|
Current software-side hardening lives in:
|
||||||
|
|
||||||
|
- `crates/infinity_host_api/tests/contract.rs`
|
||||||
|
- `crates/infinity_host/src/show_store.rs` tests
|
||||||
|
- `crates/infinity_host/src/simulation.rs` tests
|
||||||
|
|
||||||
|
Covered flows include:
|
||||||
|
|
||||||
|
- state, preview, snapshot, catalog, presets, and groups endpoints
|
||||||
|
- command success and typed command failure
|
||||||
|
- WebSocket snapshot, preview, and event messages
|
||||||
|
- group targeting
|
||||||
|
- parameter updates
|
||||||
|
- transition configuration
|
||||||
|
- blackout
|
||||||
|
- preset save
|
||||||
|
- creative snapshot save and recall
|
||||||
|
- persistence across restart
|
||||||
|
- a longer ignored load-oriented sequence for platform hardening
|
||||||
|
|
||||||
|
## Web UI Scope
|
||||||
|
|
||||||
|
The current web UI intentionally focuses on creative use:
|
||||||
|
|
||||||
|
- pattern and preset selection
|
||||||
|
- group targeting
|
||||||
|
- transition configuration
|
||||||
|
- scene parameters
|
||||||
|
- preset save and overwrite
|
||||||
|
- creative snapshot save and recall
|
||||||
|
- preview
|
||||||
|
- raw snapshot display
|
||||||
|
- filterable event feed
|
||||||
|
|
||||||
|
Mapping, topology diagnostics, panel-test administration, and low-level node status remain primarily in the native engineering GUI.
|
||||||
26
docs/legacy_xml_reference.md
Normal file
26
docs/legacy_xml_reference.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Legacy XML Reference
|
||||||
|
|
||||||
|
Source reviewed:
|
||||||
|
|
||||||
|
- `c:\Users\janni\Nextcloud\Documents\Infinity Vis\sample_data\infinity_mirror_mapping_clean.xml`
|
||||||
|
|
||||||
|
Useful facts extracted from the legacy file:
|
||||||
|
|
||||||
|
- 3 rows by 6 columns logical preview layout
|
||||||
|
- 18 total tiles
|
||||||
|
- 106 LEDs per tile
|
||||||
|
- legacy transport metadata mentions WLED, Art-Net, and per-tile IP addresses
|
||||||
|
- each tile is described as four perimeter segments summing to 106 LEDs
|
||||||
|
|
||||||
|
What this file is **not** used for:
|
||||||
|
|
||||||
|
- it is not the new hardware-mapping schema
|
||||||
|
- it is not proof of the final ESP32 node-to-panel assignment
|
||||||
|
- it does not validate `UART 6`, `UART 5`, or `UART 4`
|
||||||
|
- it should not be imported as a generic slice engine
|
||||||
|
|
||||||
|
Recommended use:
|
||||||
|
|
||||||
|
- use it as a preview and spatial-reference aid only
|
||||||
|
- manually transfer final room layout after the new hardware mapping is physically validated
|
||||||
|
|
||||||
50
docs/local_software_only_runbook.md
Normal file
50
docs/local_software_only_runbook.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Local Software-Only Runbook
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Rust `stable` Toolchain mit `cargo`, `rustc`, `rustfmt` und `clippy`
|
||||||
|
- dieses Repo ist lokal aktuell **kein echter Git-Clone**, sondern nur ein Arbeitsbaum ohne `.git`
|
||||||
|
- keine Hardware ist fuer den software-only Betrieb noetig
|
||||||
|
|
||||||
|
Beispiel fuer eine user-lokale Rust-Installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
|
||||||
|
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
rustup component add rustfmt clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokale URLs
|
||||||
|
|
||||||
|
- Creative Web-UI: `http://127.0.0.1:9001/`
|
||||||
|
- State API: `http://127.0.0.1:9001/api/v1/state`
|
||||||
|
- Preview API: `http://127.0.0.1:9001/api/v1/preview`
|
||||||
|
- Snapshot API: `http://127.0.0.1:9001/api/v1/snapshot`
|
||||||
|
- WebSocket-Stream: `ws://127.0.0.1:9001/api/v1/stream`
|
||||||
|
|
||||||
|
## Minimale Smoke-Checks
|
||||||
|
|
||||||
|
1. Web-UI laedt unter `http://127.0.0.1:9001/`.
|
||||||
|
2. `GET /api/v1/state` antwortet mit `api_version: "v1"`.
|
||||||
|
3. `ws://127.0.0.1:9001/api/v1/stream` verbindet und liefert zuerst `snapshot`, dann `preview`.
|
||||||
|
4. In der Web-UI oder ueber `POST /api/v1/command` funktionieren diese Basisfluesse:
|
||||||
|
- preset recall
|
||||||
|
- preset save / overwrite
|
||||||
|
- creative snapshot save / recall
|
||||||
|
- blackout
|
||||||
|
|
||||||
|
## Runtime-State und Recovery
|
||||||
|
|
||||||
|
- Runtime-Persistenz liegt standardmaessig unter `data/runtime_state.json`.
|
||||||
|
- Beim Schreiben werden aktiver Scene-State, Runtime-Presets, Runtime-Gruppen, Creative Snapshots und globale Steuerwerte persistiert.
|
||||||
|
- Fehlende Dateien sind okay.
|
||||||
|
- Leere, defekte oder schema-inkompatible Persistenzdateien blockieren den Serverstart nicht mehr.
|
||||||
|
- In diesen Recovery-Faellen startet der Host mit Default-State und erzeugt Warning-/Info-Events im Eventfeed statt abzubrechen.
|
||||||
67
docs/protocol.md
Normal file
67
docs/protocol.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Protocol
|
||||||
|
|
||||||
|
## Design Rules
|
||||||
|
|
||||||
|
- Separate control traffic from realtime traffic
|
||||||
|
- Version every envelope
|
||||||
|
- Keep realtime messages disposable
|
||||||
|
- Prefer idempotent control commands
|
||||||
|
- Let nodes recover independently after packet loss or reconnect
|
||||||
|
|
||||||
|
## Control Protocol
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Discovery
|
||||||
|
- Heartbeat
|
||||||
|
- Config sync
|
||||||
|
- Preset recall
|
||||||
|
- Panic and blackout
|
||||||
|
- Telemetry
|
||||||
|
|
||||||
|
Current envelope model:
|
||||||
|
|
||||||
|
- `protocol_version`
|
||||||
|
- `sequence`
|
||||||
|
- `sent_at_millis`
|
||||||
|
- `message`
|
||||||
|
|
||||||
|
Current control messages:
|
||||||
|
|
||||||
|
- `discovery_hello`
|
||||||
|
- `discovery_ack`
|
||||||
|
- `config_sync`
|
||||||
|
- `heartbeat_request`
|
||||||
|
- `heartbeat`
|
||||||
|
- `preset_recall`
|
||||||
|
- `blackout`
|
||||||
|
|
||||||
|
`config_sync` carries the authoritative per-node hardware assignment so the node can reject invalid activation before the first frame.
|
||||||
|
|
||||||
|
## Realtime Protocol
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Scene parameters for distributed rendering
|
||||||
|
- Explicit pixel frames for frame streaming mode
|
||||||
|
- Resync requests
|
||||||
|
|
||||||
|
Current realtime messages:
|
||||||
|
|
||||||
|
- `scene_parameters`
|
||||||
|
- `pixel_frame`
|
||||||
|
- `resync_request`
|
||||||
|
|
||||||
|
## Delivery Semantics
|
||||||
|
|
||||||
|
- Control messages must be small and replay-safe where possible
|
||||||
|
- Realtime messages use latest-state-wins semantics
|
||||||
|
- Nodes may drop stale realtime frames rather than replay them
|
||||||
|
- A reconnecting node must request or receive a clean config sync before resuming output
|
||||||
|
|
||||||
|
## DDP Compatibility
|
||||||
|
|
||||||
|
DDP is treated as an optional compatibility shell for frame streaming mode only.
|
||||||
|
|
||||||
|
The internal model is intentionally broader than DDP because the preferred operating path is distributed scene mode with time-based parameters and node-local rendering.
|
||||||
|
|
||||||
113
docs/show_control_primitives.md
Normal file
113
docs/show_control_primitives.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Show-Control Primitives
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Diese Primitive-Menge ist die kleine, dauerhafte interne Steuersemantik fuer software-only Show-Control. Sie bleibt bewusst generisch:
|
||||||
|
|
||||||
|
- keine UI-spezifischen Sonderfaelle
|
||||||
|
- keine grandMA-spezifische Kopplung
|
||||||
|
- keine zweite Architektur neben Host-Core und API
|
||||||
|
|
||||||
|
Der Implementierungspfad liegt in `crates/infinity_host/src/external_control.rs`.
|
||||||
|
|
||||||
|
## Primitive
|
||||||
|
|
||||||
|
### `blackout`
|
||||||
|
|
||||||
|
- Typ: direkt, mutierend
|
||||||
|
- Semantik: setzt globalen Blackout an oder aus
|
||||||
|
- Idempotenz: ja, bezogen auf den Zielzustand
|
||||||
|
- Fehlercodes: unterliegende Host-Fehler nur bei Persistenzproblemen, typischerweise `persist_failed`
|
||||||
|
- Event-Auswirkung: Info-Event auf Erfolg
|
||||||
|
|
||||||
|
### `recall_preset`
|
||||||
|
|
||||||
|
- Typ: direkt, mutierend
|
||||||
|
- Semantik: recalled ein Preset inklusive seiner Zielgruppe und Transition-Metadaten
|
||||||
|
- Idempotenz: praktisch ja, wenn dasselbe Preset erneut recalled wird
|
||||||
|
- Fehlercodes: `unknown_preset`, `persist_failed`
|
||||||
|
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Preset
|
||||||
|
|
||||||
|
### `recall_creative_snapshot`
|
||||||
|
|
||||||
|
- Typ: direkt, mutierend
|
||||||
|
- Semantik: recalled einen gespeicherten Creative Snapshot inklusive Scene- und Transition-State
|
||||||
|
- Idempotenz: praktisch ja, wenn derselbe Snapshot erneut recalled wird
|
||||||
|
- Fehlercodes: `unknown_creative_snapshot`, `persist_failed`
|
||||||
|
- Event-Auswirkung: Info-Event auf Erfolg, Warning-Event bei unbekanntem Snapshot
|
||||||
|
|
||||||
|
### `set_master_brightness`
|
||||||
|
|
||||||
|
- Typ: direkt, mutierend
|
||||||
|
- Semantik: setzt globale Helligkeit, intern auf `0.0..1.0` geklemmt
|
||||||
|
- Idempotenz: ja, bezogen auf den geklemmten Zielwert
|
||||||
|
- Fehlercodes: typischerweise nur `persist_failed`
|
||||||
|
- Event-Auswirkung: Info-Event auf Erfolg
|
||||||
|
|
||||||
|
### `set_pattern`
|
||||||
|
|
||||||
|
- Typ: staged
|
||||||
|
- Semantik: staged das Pattern fuer die naechste explizite Transition
|
||||||
|
- Idempotenz: ja, letzter Wert gewinnt
|
||||||
|
- Fehlercodes: `invalid_pattern_id`
|
||||||
|
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
|
||||||
|
|
||||||
|
### `set_group_parameter`
|
||||||
|
|
||||||
|
- Typ: staged
|
||||||
|
- Semantik: staged einen Scene-Parameter fuer die naechste Transition und kann optional gleichzeitig die Zielgruppe fuer diese Transition setzen
|
||||||
|
- Idempotenz: ja, letzter Wert pro Parameter-Key gewinnt
|
||||||
|
- Fehlercodes: `invalid_group_parameter_key`
|
||||||
|
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
|
||||||
|
|
||||||
|
### `upsert_group`
|
||||||
|
|
||||||
|
- Typ: direkt, mutierend
|
||||||
|
- Semantik: legt eine Runtime-Gruppe an oder ueberschreibt sie bewusst mit `overwrite: true`
|
||||||
|
- Idempotenz: ja mit `overwrite: true`, nein mit `overwrite: false`
|
||||||
|
- Fehlercodes: `invalid_group_id`, `invalid_group_members`, `group_exists`, `persist_failed`
|
||||||
|
- Event-Auswirkung: Info-Event auf Erfolg
|
||||||
|
|
||||||
|
### `set_transition_style`
|
||||||
|
|
||||||
|
- Typ: staged
|
||||||
|
- Semantik: staged Transition-Style und optional Duration fuer die naechste explizite Transition
|
||||||
|
- Idempotenz: ja, letzter Wert gewinnt
|
||||||
|
- Fehlercodes: keine zusaetzlichen Primitive-Fehler
|
||||||
|
- Event-Auswirkung: kein Host-Event bis `trigger_transition`
|
||||||
|
|
||||||
|
### `trigger_transition`
|
||||||
|
|
||||||
|
- Typ: ausfuehrend, mutierend
|
||||||
|
- Semantik: materialisiert den aktuell gestagten Transition-Intent in den Host
|
||||||
|
- Ausfuehrungsreihenfolge:
|
||||||
|
1. `select_group` nur wenn ueber staged Parameter ein Gruppenkontext gesetzt wurde
|
||||||
|
2. `set_transition_duration_ms` nur wenn staged
|
||||||
|
3. `set_transition_style` nur wenn staged
|
||||||
|
4. `select_pattern`
|
||||||
|
5. `set_scene_parameter` fuer alle staged Parameter
|
||||||
|
- Idempotenz: nein, erfolgreicher Trigger konsumiert den gestagten Intent
|
||||||
|
- Fehlercodes: `transition_pattern_required` plus unterliegende Host-Fehler wie `unknown_group` oder `persist_failed`
|
||||||
|
- Event-Auswirkung: die unterliegenden Host-Kommandos erzeugen die sichtbaren Info-/Warning-Events
|
||||||
|
|
||||||
|
### `request_snapshot`
|
||||||
|
|
||||||
|
- Typ: read-only
|
||||||
|
- Semantik: liefert den aktuellen Host-Snapshot ohne Host-Mutation
|
||||||
|
- Idempotenz: ja
|
||||||
|
- Fehlercodes: keine Primitive-eigenen
|
||||||
|
- Event-Auswirkung: keine
|
||||||
|
|
||||||
|
## Hinweis zur Adapter-Nutzung
|
||||||
|
|
||||||
|
Die staged Primitive sind fuer externe Show-Control-Adapter gedacht, die absichtlich mehrere kleine Eingaben sammeln und erst mit `trigger_transition` in einen Host-seitigen Uebergang umsetzen. Direkte UI- oder API-Kommandos koennen weiterhin eager bleiben; die stabile interne Adapter-Semantik wird davon nicht aufgeblasen.
|
||||||
|
|
||||||
|
Ein stateless Port darf staged Primitive nicht stillschweigend akzeptieren. Wenn `set_pattern`, `set_group_parameter`, `set_transition_style` oder `trigger_transition` ohne Session-/Adapter-Kontext direkt an einem Port landen, ist der erwartete Fehlercode `show_control_session_required`.
|
||||||
|
|
||||||
|
## Referenz-Client
|
||||||
|
|
||||||
|
Der sehr duenne generische Referenzpfad liegt in `crates/infinity_host/src/external_control.rs`:
|
||||||
|
|
||||||
|
- `ReferenceShowControlClient::stateful(...)` fuer direkte plus staged Flows
|
||||||
|
- `ReferenceShowControlClient::stateless(...)` zum bewussten Nachweis, dass staged Primitive am nackten Port mit `show_control_session_required` abgewiesen werden
|
||||||
|
- `BufferedShowControlAdapter` und `ShowControlSession` als kleine Buffer-/Commit-Implementierung ohne neue Grundarchitektur
|
||||||
37
docs/testing.md
Normal file
37
docs/testing.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
Host-side unit tests currently cover:
|
||||||
|
|
||||||
|
- fixed LED count validation
|
||||||
|
- duplicate driver reference rejection
|
||||||
|
- activation guard against unresolved hardware mapping
|
||||||
|
- protocol envelope version stamping
|
||||||
|
|
||||||
|
## Planned Integration Tests
|
||||||
|
|
||||||
|
1. Host to node config sync
|
||||||
|
2. Host to node preset recall during load
|
||||||
|
3. Reconnect and resync after heartbeat timeout
|
||||||
|
4. Frame streaming fallback without scene-mode support
|
||||||
|
|
||||||
|
## Soak Tests
|
||||||
|
|
||||||
|
Target procedure:
|
||||||
|
|
||||||
|
1. Run a continuous scene loop for 8 hours
|
||||||
|
2. Rotate presets on a schedule
|
||||||
|
3. Simulate packet loss and node reconnects
|
||||||
|
4. Log dropped frames, reconnect count, and jitter
|
||||||
|
|
||||||
|
## Hardware Validation Tests
|
||||||
|
|
||||||
|
Required before live deployment:
|
||||||
|
|
||||||
|
1. Walking pixel test across all 106 LEDs on each of the 18 outputs
|
||||||
|
2. Start pixel verification per output
|
||||||
|
3. Direction verification per output
|
||||||
|
4. Color order verification per output
|
||||||
|
5. Final confirmation of which physical channel maps to top, middle, and bottom on every node
|
||||||
|
|
||||||
14
docs/validation_open_points.md
Normal file
14
docs/validation_open_points.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Validation Open Points
|
||||||
|
|
||||||
|
These items must be resolved before the system can move from structural validation to activation validation:
|
||||||
|
|
||||||
|
1. Confirm the real meaning of `UART 6`, `UART 5`, and `UART 4`.
|
||||||
|
2. Confirm the exact LED chipset and required output timing backend.
|
||||||
|
3. Confirm whether every output truly has exactly 106 active LEDs, with no dummy or reserve pixels.
|
||||||
|
4. Confirm color order for each output.
|
||||||
|
5. Confirm direction and start pixel for each output.
|
||||||
|
6. Confirm which physical node controls which installed top, middle, and bottom panel.
|
||||||
|
7. Confirm whether transport runs over dedicated Wi-Fi or wired Ethernet.
|
||||||
|
8. Confirm whether DDP compatibility is optional or mandatory in the first deployment.
|
||||||
|
9. Confirm whether the first production UI is Tauri, egui, or another adapter on top of the host core.
|
||||||
|
|
||||||
4
firmware/esp32_node/CMakeLists.txt
Normal file
4
firmware/esp32_node/CMakeLists.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(infinity_vis_esp32_node)
|
||||||
|
|
||||||
25
firmware/esp32_node/README.md
Normal file
25
firmware/esp32_node/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ESP32 Node Firmware
|
||||||
|
|
||||||
|
This directory contains the ESP-IDF firmware skeleton for one ESP32 node that drives exactly three outputs:
|
||||||
|
|
||||||
|
- top
|
||||||
|
- middle
|
||||||
|
- bottom
|
||||||
|
|
||||||
|
The firmware is intentionally built around a driver abstraction. It does not assume that `UART 6`, `UART 5`, or `UART 4` are real UART peripherals.
|
||||||
|
|
||||||
|
## Planned Modules
|
||||||
|
|
||||||
|
- Network RX task
|
||||||
|
- Command decode task
|
||||||
|
- Render and apply task
|
||||||
|
- Output task
|
||||||
|
- Telemetry task
|
||||||
|
- Watchdog and recovery path
|
||||||
|
|
||||||
|
## Current Safety Posture
|
||||||
|
|
||||||
|
The skeleton blocks activation while output channels remain marked as `PANEL_DRIVER_KIND_UNVALIDATED`.
|
||||||
|
|
||||||
|
That is expected and desirable until the physical backend is confirmed.
|
||||||
|
|
||||||
8
firmware/esp32_node/main/CMakeLists.txt
Normal file
8
firmware/esp32_node/main/CMakeLists.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS
|
||||||
|
"app_main.c"
|
||||||
|
"panel_driver.c"
|
||||||
|
INCLUDE_DIRS
|
||||||
|
"."
|
||||||
|
)
|
||||||
|
|
||||||
141
firmware/esp32_node/main/app_main.c
Normal file
141
firmware/esp32_node/main/app_main.c
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_system.h"
|
||||||
|
|
||||||
|
#include "panel_driver.h"
|
||||||
|
|
||||||
|
static const char *TAG = "infinity_node";
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *node_id;
|
||||||
|
panel_output_config_t outputs[INFINITY_NODE_OUTPUT_COUNT];
|
||||||
|
} node_runtime_config_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t heartbeat_count;
|
||||||
|
uint32_t reconnect_count;
|
||||||
|
} node_metrics_t;
|
||||||
|
|
||||||
|
static node_runtime_config_t make_default_runtime_config(void);
|
||||||
|
static esp_err_t validate_runtime_config(const node_runtime_config_t *config);
|
||||||
|
static void network_rx_task(void *arg);
|
||||||
|
static void render_task(void *arg);
|
||||||
|
static void output_task(void *arg);
|
||||||
|
static void telemetry_task(void *arg);
|
||||||
|
|
||||||
|
void app_main(void) {
|
||||||
|
static node_metrics_t metrics = {0};
|
||||||
|
node_runtime_config_t runtime_config = make_default_runtime_config();
|
||||||
|
|
||||||
|
esp_err_t validation = validate_runtime_config(&runtime_config);
|
||||||
|
if (validation != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "startup halted until hardware mapping is validated: %s", esp_err_to_name(validation));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xTaskCreate(network_rx_task, "network_rx", 4096, &metrics, 8, NULL);
|
||||||
|
xTaskCreate(render_task, "render", 4096, &metrics, 7, NULL);
|
||||||
|
xTaskCreate(output_task, "output", 4096, &metrics, 9, NULL);
|
||||||
|
xTaskCreate(telemetry_task, "telemetry", 4096, &metrics, 5, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static node_runtime_config_t make_default_runtime_config(void) {
|
||||||
|
node_runtime_config_t config = {
|
||||||
|
.node_id = "unassigned-node",
|
||||||
|
.outputs =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
.panel_slot = PANEL_SLOT_TOP,
|
||||||
|
.physical_output_name = "UART 6",
|
||||||
|
.driver_reference = "UART 6",
|
||||||
|
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
|
||||||
|
.led_count = INFINITY_LEDS_PER_OUTPUT,
|
||||||
|
.reverse = false,
|
||||||
|
.enabled = true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.panel_slot = PANEL_SLOT_MIDDLE,
|
||||||
|
.physical_output_name = "UART 5",
|
||||||
|
.driver_reference = "UART 5",
|
||||||
|
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
|
||||||
|
.led_count = INFINITY_LEDS_PER_OUTPUT,
|
||||||
|
.reverse = false,
|
||||||
|
.enabled = true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.panel_slot = PANEL_SLOT_BOTTOM,
|
||||||
|
.physical_output_name = "UART 4",
|
||||||
|
.driver_reference = "UART 4",
|
||||||
|
.driver_kind = PANEL_DRIVER_KIND_UNVALIDATED,
|
||||||
|
.led_count = INFINITY_LEDS_PER_OUTPUT,
|
||||||
|
.reverse = false,
|
||||||
|
.enabled = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t validate_runtime_config(const node_runtime_config_t *config) {
|
||||||
|
if (config == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t index = 0; index < INFINITY_NODE_OUTPUT_COUNT; ++index) {
|
||||||
|
esp_err_t status = panel_driver_validate_output(&config->outputs[index]);
|
||||||
|
if (status != ESP_OK) {
|
||||||
|
ESP_LOGE(
|
||||||
|
TAG,
|
||||||
|
"output %u (%s) failed validation",
|
||||||
|
(unsigned int)index,
|
||||||
|
config->outputs[index].physical_output_name
|
||||||
|
);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void network_rx_task(void *arg) {
|
||||||
|
node_metrics_t *metrics = (node_metrics_t *)arg;
|
||||||
|
for (;;) {
|
||||||
|
metrics->heartbeat_count++;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_task(void *arg) {
|
||||||
|
(void)arg;
|
||||||
|
for (;;) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void output_task(void *arg) {
|
||||||
|
(void)arg;
|
||||||
|
for (;;) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void telemetry_task(void *arg) {
|
||||||
|
node_metrics_t *metrics = (node_metrics_t *)arg;
|
||||||
|
for (;;) {
|
||||||
|
ESP_LOGI(
|
||||||
|
TAG,
|
||||||
|
"telemetry heartbeat_count=%u reconnect_count=%u free_heap=%u",
|
||||||
|
metrics->heartbeat_count,
|
||||||
|
metrics->reconnect_count,
|
||||||
|
(unsigned int)esp_get_free_heap_size()
|
||||||
|
);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
firmware/esp32_node/main/panel_driver.c
Normal file
28
firmware/esp32_node/main/panel_driver.c
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#include "panel_driver.h"
|
||||||
|
|
||||||
|
#include "esp_check.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
static const char *TAG = "panel_driver";
|
||||||
|
|
||||||
|
esp_err_t panel_driver_validate_output(const panel_output_config_t *config) {
|
||||||
|
ESP_RETURN_ON_FALSE(config != NULL, ESP_ERR_INVALID_ARG, TAG, "config must not be null");
|
||||||
|
ESP_RETURN_ON_FALSE(config->led_count == INFINITY_LEDS_PER_OUTPUT, ESP_ERR_INVALID_ARG, TAG, "output must be configured for exactly 106 LEDs");
|
||||||
|
ESP_RETURN_ON_FALSE(config->enabled, ESP_ERR_INVALID_STATE, TAG, "disabled output cannot enter the active driver set");
|
||||||
|
ESP_RETURN_ON_FALSE(config->driver_kind != PANEL_DRIVER_KIND_UNVALIDATED, ESP_ERR_INVALID_STATE, TAG, "driver backend is still unvalidated");
|
||||||
|
ESP_RETURN_ON_FALSE(config->driver_reference != NULL, ESP_ERR_INVALID_ARG, TAG, "driver reference must not be null");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t panel_driver_self_test_all(panel_driver_t *driver, const panel_output_config_t *outputs, size_t output_count) {
|
||||||
|
ESP_RETURN_ON_FALSE(driver != NULL, ESP_ERR_INVALID_ARG, TAG, "driver must not be null");
|
||||||
|
ESP_RETURN_ON_FALSE(driver->vtable != NULL, ESP_ERR_INVALID_STATE, TAG, "driver vtable missing");
|
||||||
|
ESP_RETURN_ON_FALSE(driver->vtable->self_test != NULL, ESP_ERR_INVALID_STATE, TAG, "driver self test callback missing");
|
||||||
|
|
||||||
|
for (size_t index = 0; index < output_count; ++index) {
|
||||||
|
ESP_RETURN_ON_ERROR(panel_driver_validate_output(&outputs[index]), TAG, "output validation failed before self test");
|
||||||
|
}
|
||||||
|
|
||||||
|
return driver->vtable->self_test(driver);
|
||||||
|
}
|
||||||
|
|
||||||
61
firmware/esp32_node/main/panel_driver.h
Normal file
61
firmware/esp32_node/main/panel_driver.h
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#define INFINITY_NODE_OUTPUT_COUNT 3
|
||||||
|
#define INFINITY_LEDS_PER_OUTPUT 106
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
PANEL_SLOT_TOP = 0,
|
||||||
|
PANEL_SLOT_MIDDLE = 1,
|
||||||
|
PANEL_SLOT_BOTTOM = 2,
|
||||||
|
} panel_slot_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
PANEL_DRIVER_KIND_UNVALIDATED = 0,
|
||||||
|
PANEL_DRIVER_KIND_GPIO,
|
||||||
|
PANEL_DRIVER_KIND_RMT,
|
||||||
|
PANEL_DRIVER_KIND_I2S,
|
||||||
|
PANEL_DRIVER_KIND_UART,
|
||||||
|
PANEL_DRIVER_KIND_SPI,
|
||||||
|
PANEL_DRIVER_KIND_EXTERNAL,
|
||||||
|
} panel_driver_kind_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t r;
|
||||||
|
uint8_t g;
|
||||||
|
uint8_t b;
|
||||||
|
} rgb8_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
panel_slot_t panel_slot;
|
||||||
|
const char *physical_output_name;
|
||||||
|
const char *driver_reference;
|
||||||
|
panel_driver_kind_t driver_kind;
|
||||||
|
uint16_t led_count;
|
||||||
|
bool reverse;
|
||||||
|
bool enabled;
|
||||||
|
} panel_output_config_t;
|
||||||
|
|
||||||
|
typedef struct panel_driver panel_driver_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
esp_err_t (*init)(panel_driver_t *driver);
|
||||||
|
esp_err_t (*configure)(panel_driver_t *driver, const panel_output_config_t *config);
|
||||||
|
esp_err_t (*submit_frame)(panel_driver_t *driver, panel_slot_t panel_slot, const rgb8_t *pixels, size_t pixel_count);
|
||||||
|
esp_err_t (*apply)(panel_driver_t *driver);
|
||||||
|
esp_err_t (*self_test)(panel_driver_t *driver);
|
||||||
|
} panel_driver_vtable_t;
|
||||||
|
|
||||||
|
struct panel_driver {
|
||||||
|
const panel_driver_vtable_t *vtable;
|
||||||
|
void *context;
|
||||||
|
};
|
||||||
|
|
||||||
|
esp_err_t panel_driver_validate_output(const panel_output_config_t *config);
|
||||||
|
esp_err_t panel_driver_self_test_all(panel_driver_t *driver, const panel_output_config_t *outputs, size_t output_count);
|
||||||
|
|
||||||
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
|
||||||
1212
web/v1/app.js
Normal file
1212
web/v1/app.js
Normal file
File diff suppressed because it is too large
Load Diff
224
web/v1/index.html
Normal file
224
web/v1/index.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!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 Duration</span>
|
||||||
|
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
|
||||||
|
<strong id="transition-value">0 ms</strong>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Transition Style</span>
|
||||||
|
<select id="transition-style-select">
|
||||||
|
<option value="snap">Snap</option>
|
||||||
|
<option value="crossfade">Crossfade</option>
|
||||||
|
<option value="chase">Chase</option>
|
||||||
|
</select>
|
||||||
|
</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>Pending Transition</h3>
|
||||||
|
<p>Stage primitives locally and commit them with one explicit trigger.</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-panel">
|
||||||
|
<div class="session-status-row">
|
||||||
|
<div class="status-card">
|
||||||
|
<span class="status-label">Control mode</span>
|
||||||
|
<span id="control-mode-pill" class="pill pill-online">stateful</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<span class="status-label">Commit state</span>
|
||||||
|
<span id="pending-commit-pill" class="pill pill-offline">idle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||||
|
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button id="trigger-transition-button" class="ghost-button" type="button">
|
||||||
|
Trigger Transition
|
||||||
|
</button>
|
||||||
|
<button id="clear-staged-button" class="ghost-button" type="button">
|
||||||
|
Clear Staged
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>Preset Capture</h3>
|
||||||
|
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
|
||||||
|
</div>
|
||||||
|
<div class="capture-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Preset ID</span>
|
||||||
|
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
|
||||||
|
</label>
|
||||||
|
<label class="field inline-checkbox">
|
||||||
|
<span>Overwrite Existing</span>
|
||||||
|
<input id="preset-overwrite-input" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
<button id="save-preset-button" class="ghost-button" type="button">
|
||||||
|
Save Current Scene As Preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subsection">
|
||||||
|
<div class="subsection-heading">
|
||||||
|
<h3>Groups</h3>
|
||||||
|
<p>Focus looks on a subset while keeping the core scene model shared.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="group-filter-input"
|
||||||
|
class="filter-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter groups by id or tag"
|
||||||
|
/>
|
||||||
|
<div id="group-list" class="pill-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subsection">
|
||||||
|
<div class="subsection-heading">
|
||||||
|
<h3>Creative Snapshots</h3>
|
||||||
|
<p>Capture exploratory variants without replacing curated presets.</p>
|
||||||
|
</div>
|
||||||
|
<div class="capture-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Snapshot ID</span>
|
||||||
|
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Label</span>
|
||||||
|
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
|
||||||
|
</label>
|
||||||
|
<label class="field inline-checkbox">
|
||||||
|
<span>Overwrite Existing</span>
|
||||||
|
<input id="snapshot-overwrite-input" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
<button id="save-snapshot-button" class="ghost-button" type="button">
|
||||||
|
Save Creative Snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="snapshot-list" class="snapshot-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subsection">
|
||||||
|
<div class="subsection-heading">
|
||||||
|
<h3>Scene Parameters</h3>
|
||||||
|
<p>Rendered from the active scene schema, not hardcoded per frontend.</p>
|
||||||
|
</div>
|
||||||
|
<div id="scene-params" class="parameter-grid"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel preview-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Preview</h2>
|
||||||
|
<p>Live panel previews from the host snapshot and stream feed.</p>
|
||||||
|
</div>
|
||||||
|
<div id="preview-grid" class="preview-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel summary-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Snapshot</h2>
|
||||||
|
<p>Operator-friendly scene state with a raw API view underneath.</p>
|
||||||
|
</div>
|
||||||
|
<div id="summary-cards" class="summary-cards"></div>
|
||||||
|
<pre id="snapshot-json" class="snapshot-json"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel event-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Event Stream</h2>
|
||||||
|
<p>Recent notices from the websocket feed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="event-filter-bar">
|
||||||
|
<select id="event-kind-filter">
|
||||||
|
<option value="all">All kinds</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
id="event-search-filter"
|
||||||
|
class="filter-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by code or message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="event-list" class="event-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
672
web/v1/styles.css
Normal file
672
web/v1/styles.css
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
: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,
|
||||||
|
.capture-grid,
|
||||||
|
.parameter-grid,
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-session-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 249, 241, 0.9);
|
||||||
|
border: 1px solid rgba(56, 63, 61, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primitive-error-banner {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(188, 47, 47, 0.1);
|
||||||
|
border: 1px solid rgba(188, 47, 47, 0.16);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primitive-error-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fffdfa;
|
||||||
|
border: 1px solid rgba(56, 63, 61, 0.14);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-checkbox {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-checkbox input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
accent-color: var(--accent-cool);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-button.staged,
|
||||||
|
.group-button.staged,
|
||||||
|
.ghost-button.staged {
|
||||||
|
background: linear-gradient(135deg, rgba(25, 140, 143, 0.16), rgba(234, 106, 54, 0.1));
|
||||||
|
border-color: rgba(25, 140, 143, 0.35);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(25, 140, 143, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-bar {
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 36, 36, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: var(--energy-width, 0%);
|
||||||
|
background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84));
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-filter-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.event-info strong {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.event-warning strong {
|
||||||
|
color: #a7631c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.event-error strong {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-code {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 36, 36, 0.08);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid rgba(56, 63, 61, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-card-header strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 36, 36, 0.08);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
.event-filter-bar {
|
||||||
|
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