From a56cecb23d80a3728565493c38f7e1454f182a27 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 17 Apr 2026 21:17:23 +0200 Subject: [PATCH] Software-only show-control readiness baseline --- .gitignore | 1 + Cargo.lock | 4521 ++++++++++++++++++ Cargo.toml | 23 + README.md | 53 + config/project.example.toml | 396 ++ crates/infinity_config/Cargo.toml | 12 + crates/infinity_config/src/lib.rs | 26 + crates/infinity_config/src/model.rs | 261 + crates/infinity_config/src/validation.rs | 673 +++ crates/infinity_host/Cargo.toml | 14 + crates/infinity_host/src/control.rs | 391 ++ crates/infinity_host/src/external_control.rs | 699 +++ crates/infinity_host/src/lib.rs | 13 + crates/infinity_host/src/main.rs | 161 + crates/infinity_host/src/runtime.rs | 175 + crates/infinity_host/src/scene.rs | 772 +++ crates/infinity_host/src/show_store.rs | 801 ++++ crates/infinity_host/src/simulation.rs | 1023 ++++ crates/infinity_host_api/Cargo.toml | 14 + crates/infinity_host_api/src/dto.rs | 989 ++++ crates/infinity_host_api/src/lib.rs | 6 + crates/infinity_host_api/src/main.rs | 39 + crates/infinity_host_api/src/server.rs | 467 ++ crates/infinity_host_api/src/websocket.rs | 148 + crates/infinity_host_api/tests/contract.rs | 701 +++ crates/infinity_host_ui/Cargo.toml | 13 + crates/infinity_host_ui/src/app.rs | 319 ++ crates/infinity_host_ui/src/main.rs | 46 + crates/infinity_protocol/Cargo.toml | 10 + crates/infinity_protocol/src/lib.rs | 205 + docs/acceptance_template.md | 37 + docs/architecture.md | 115 + docs/build_and_deploy.md | 46 + docs/config_schema.md | 87 + docs/host_api.md | 397 ++ docs/legacy_xml_reference.md | 26 + docs/local_software_only_runbook.md | 50 + docs/protocol.md | 67 + docs/show_control_primitives.md | 113 + docs/testing.md | 37 + docs/validation_open_points.md | 14 + firmware/esp32_node/CMakeLists.txt | 4 + firmware/esp32_node/README.md | 25 + firmware/esp32_node/main/CMakeLists.txt | 8 + firmware/esp32_node/main/app_main.c | 141 + firmware/esp32_node/main/panel_driver.c | 28 + firmware/esp32_node/main/panel_driver.h | 61 + rust-toolchain.toml | 4 + web/v1/app.js | 1212 +++++ web/v1/index.html | 224 + web/v1/styles.css | 672 +++ 51 files changed, 16340 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 config/project.example.toml create mode 100644 crates/infinity_config/Cargo.toml create mode 100644 crates/infinity_config/src/lib.rs create mode 100644 crates/infinity_config/src/model.rs create mode 100644 crates/infinity_config/src/validation.rs create mode 100644 crates/infinity_host/Cargo.toml create mode 100644 crates/infinity_host/src/control.rs create mode 100644 crates/infinity_host/src/external_control.rs create mode 100644 crates/infinity_host/src/lib.rs create mode 100644 crates/infinity_host/src/main.rs create mode 100644 crates/infinity_host/src/runtime.rs create mode 100644 crates/infinity_host/src/scene.rs create mode 100644 crates/infinity_host/src/show_store.rs create mode 100644 crates/infinity_host/src/simulation.rs create mode 100644 crates/infinity_host_api/Cargo.toml create mode 100644 crates/infinity_host_api/src/dto.rs create mode 100644 crates/infinity_host_api/src/lib.rs create mode 100644 crates/infinity_host_api/src/main.rs create mode 100644 crates/infinity_host_api/src/server.rs create mode 100644 crates/infinity_host_api/src/websocket.rs create mode 100644 crates/infinity_host_api/tests/contract.rs create mode 100644 crates/infinity_host_ui/Cargo.toml create mode 100644 crates/infinity_host_ui/src/app.rs create mode 100644 crates/infinity_host_ui/src/main.rs create mode 100644 crates/infinity_protocol/Cargo.toml create mode 100644 crates/infinity_protocol/src/lib.rs create mode 100644 docs/acceptance_template.md create mode 100644 docs/architecture.md create mode 100644 docs/build_and_deploy.md create mode 100644 docs/config_schema.md create mode 100644 docs/host_api.md create mode 100644 docs/legacy_xml_reference.md create mode 100644 docs/local_software_only_runbook.md create mode 100644 docs/protocol.md create mode 100644 docs/show_control_primitives.md create mode 100644 docs/testing.md create mode 100644 docs/validation_open_points.md create mode 100644 firmware/esp32_node/CMakeLists.txt create mode 100644 firmware/esp32_node/README.md create mode 100644 firmware/esp32_node/main/CMakeLists.txt create mode 100644 firmware/esp32_node/main/app_main.c create mode 100644 firmware/esp32_node/main/panel_driver.c create mode 100644 firmware/esp32_node/main/panel_driver.h create mode 100644 rust-toolchain.toml create mode 100644 web/v1/app.js create mode 100644 web/v1/index.html create mode 100644 web/v1/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..24d9bd7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4521 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" + +[[package]] +name = "accesskit_atspi_common" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5393c75d4666f580f4cac0a968bc97c36076bb536a129f28210dac54ee127ed" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a12dc159d52233c43d9fe5415969433cbdd52c3d6e0df51bda7d447427b9986" +dependencies = [ + "accesskit", + "immutable-chunkmap", +] + +[[package]] +name = "accesskit_macos" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc6c1ecd82053d127961ad80a8beaa6004fb851a3a5b96506d7a6bd462403f6" +dependencies = [ + "accesskit", + "accesskit_consumer", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", +] + +[[package]] +name = "accesskit_unix" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be7f5cf6165be10a54b2655fa2e0e12b2509f38ed6fc43e11c31fdb7ee6230bb" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974e96c347384d9133427167fb8a58c340cb0496988dacceebdc1ed27071023b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "paste", + "static_assertions", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "accesskit_winit" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea3522719f1c44564d03e9469a8e2f3a98b3a8a880bd66d0789c6b9c4a669dd" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496" +dependencies = [ + "atspi-common", + "serde", + "zbus", + "zvariant", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow 0.14.2", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "accesskit", + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow 0.14.2", + "log", + "memoffset", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases 0.2.1", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases 0.2.1", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.1", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "immutable-chunkmap" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "infinity_config" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 1.0.69", + "toml", +] + +[[package]] +name = "infinity_host" +version = "0.1.0" +dependencies = [ + "clap", + "infinity_config", + "infinity_protocol", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "infinity_host_api" +version = "0.1.0" +dependencies = [ + "clap", + "infinity_config", + "infinity_host", + "serde", + "serde_json", +] + +[[package]] +name = "infinity_host_ui" +version = "0.1.0" +dependencies = [ + "clap", + "eframe", + "infinity_config", + "infinity_host", +] + +[[package]] +name = "infinity_protocol" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow 0.13.1", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233" +dependencies = [ + "quick-xml 0.30.0", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6c9991c --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9bcda2 --- /dev/null +++ b/README.md @@ -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) diff --git a/config/project.example.toml b/config/project.example.toml new file mode 100644 index 0000000..9c32f3e --- /dev/null +++ b/config/project.example.toml @@ -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 diff --git a/crates/infinity_config/Cargo.toml b/crates/infinity_config/Cargo.toml new file mode 100644 index 0000000..0961d79 --- /dev/null +++ b/crates/infinity_config/Cargo.toml @@ -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 + diff --git a/crates/infinity_config/src/lib.rs b/crates/infinity_config/src/lib.rs new file mode 100644 index 0000000..d4f95e4 --- /dev/null +++ b/crates/infinity_config/src/lib.rs @@ -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 { + toml::from_str(raw) + } +} + +pub fn load_project_from_path(path: impl AsRef) -> Result { + let raw = fs::read_to_string(path)?; + ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from) +} diff --git a/crates/infinity_config/src/model.rs b/crates/infinity_config/src/model.rs new file mode 100644 index 0000000..48e49b8 --- /dev/null +++ b/crates/infinity_config/src/model.rs @@ -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, + #[serde(default)] + pub safety_profiles: Vec, + #[serde(default)] + pub presets: Vec, +} + +#[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, + #[serde(default)] + pub layout_panels: Vec, + #[serde(default)] + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeConfig { + pub node_id: String, + pub display_name: String, + pub network: NodeNetworkConfig, + pub outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeNetworkConfig { + pub reserved_ip: Option, + pub telemetry_label: Option, +} + +#[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, +} + +#[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, + pub members: Vec, +} + +#[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, + #[serde(default = "default_hold_last_frame_ms")] + pub hold_last_frame_ms: u32, + #[serde(default)] + pub fallback_preset_id: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SceneConfig { + pub effect: String, + pub seed: u64, + #[serde(default)] + pub palette: Vec, + #[serde(default)] + pub speed: f32, + #[serde(default)] + pub intensity: f32, + #[serde(default)] + pub blackout: bool, +} diff --git a/crates/infinity_config/src/validation.rs b/crates/infinity_config/src/validation.rs new file mode 100644 index 0000000..62909f1 --- /dev/null +++ b/crates/infinity_config/src/validation.rs @@ -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, +} + +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, + message: impl Into, + ) { + 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, + 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")); + } +} diff --git a/crates/infinity_host/Cargo.toml b/crates/infinity_host/Cargo.toml new file mode 100644 index 0000000..4c8ac85 --- /dev/null +++ b/crates/infinity_host/Cargo.toml @@ -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" } diff --git a/crates/infinity_host/src/control.rs b/crates/infinity_host/src/control.rs new file mode 100644 index 0000000..94cced2 --- /dev/null +++ b/crates/infinity_host/src/control.rs @@ -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, + pub nodes: Vec, + pub panels: Vec, + pub recent_events: Vec, +} + +#[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, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CatalogSnapshot { + pub patterns: Vec, + pub presets: Vec, + pub groups: Vec, + pub creative_snapshots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PatternDefinition { + pub pattern_id: String, + pub display_name: String, + pub description: String, + pub parameters: Vec, +} + +#[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, + 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, + pub source: CatalogSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CreativeSnapshotSummary { + pub snapshot_id: String, + pub label: Option, + pub pattern_id: String, + pub target_group: Option, + 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, + pub pattern_id: String, + pub seed: u64, + pub palette: Vec, + pub parameters: Vec, + pub target_group: Option, + pub blackout: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SceneParameterState { + pub key: String, + pub label: String, + pub kind: SceneParameterKind, + pub value: SceneParameterValue, + pub min_scalar: Option, + pub max_scalar: Option, + pub step: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SceneParameterSpec { + pub key: String, + pub label: String, + pub kind: SceneParameterKind, + pub min_scalar: Option, + pub max_scalar: Option, + pub step: Option, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PreviewPanelSnapshot { + pub target: PanelTarget, + pub representative_color_hex: String, + pub sample_led_hex: Vec, + 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, + pub connection: NodeConnectionState, + pub last_contact_ms: u64, + pub error_status: Option, + 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, + pub error_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StatusEvent { + pub at_millis: u64, + pub kind: StatusEventKind, + pub code: Option, + 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, + }, + 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, + overwrite: bool, + }, + RecallCreativeSnapshot { + snapshot_id: String, + }, + UpsertGroup { + group_id: String, + tags: Vec, + members: Vec, + 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; +} + +pub trait HostUiPort: HostApiPort {} + +impl 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 { + match self { + Self::Scalar(value) => Some(*value), + _ => None, + } + } + + pub fn as_toggle(&self) -> Option { + match self { + Self::Toggle(value) => Some(*value), + _ => None, + } + } + + pub fn text(value: impl Into) -> Self { + Self::Text(value.into()) + } +} + +impl HostCommandError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} diff --git a/crates/infinity_host/src/external_control.rs b/crates/infinity_host/src/external_control.rs new file mode 100644 index 0000000..60bac7a --- /dev/null +++ b/crates/infinity_host/src/external_control.rs @@ -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, + key: String, + value: SceneParameterValue, + }, + UpsertGroup { + group_id: String, + tags: Vec, + members: Vec, + overwrite: bool, + }, + SetTransitionStyle { + style: SceneTransitionStyle, + duration_ms: Option, + }, + 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, + pending_group_id: Option>, + pending_parameters: BTreeMap, + pending_transition_style: Option, + pending_transition_duration_ms: Option, +} + +pub trait ExternalShowControlPort: Send + Sync { + fn snapshot(&self) -> HostSnapshot; + fn execute_primitive( + &self, + primitive: ShowControlPrimitive, + ) -> Result; +} + +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; +} + +impl ShowControlSession { + pub fn apply( + &mut self, + port: &dyn HostApiPort, + primitive: ShowControlPrimitive, + ) -> Result { + 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 { + 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 ExternalShowControlPort for T { + fn snapshot(&self) -> HostSnapshot { + HostApiPort::snapshot(self) + } + + fn execute_primitive( + &self, + primitive: ShowControlPrimitive, + ) -> Result { + 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 { + self.session.apply(port, primitive) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceShowControlMode { + StatefulSession, + StatelessPort, +} + +#[derive(Debug, Clone)] +pub struct ReferenceShowControlClient

{ + port: P, + mode: ReferenceShowControlMode, + adapter: BufferedShowControlAdapter, +} + +impl ReferenceShowControlClient

{ + 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 { + 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"); + } +} diff --git a/crates/infinity_host/src/lib.rs b/crates/infinity_host/src/lib.rs new file mode 100644 index 0000000..5c5b8b1 --- /dev/null +++ b/crates/infinity_host/src/lib.rs @@ -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::*; diff --git a/crates/infinity_host/src/main.rs b/crates/infinity_host/src/main.rs new file mode 100644 index 0000000..7b4c076 --- /dev/null +++ b/crates/infinity_host/src/main.rs @@ -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 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}"); + } +} diff --git a/crates/infinity_host/src/runtime.rs b/crates/infinity_host/src/runtime.rs new file mode 100644 index 0000000..07a5d51 --- /dev/null +++ b/crates/infinity_host/src/runtime.rs @@ -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, + pub realtime: Vec, +} + +#[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 { + 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::>(); + + let targets = assignments + .iter() + .map(|assignment| assignment.panel.clone()) + .collect::>(); + + 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, + } +} diff --git a/crates/infinity_host/src/scene.rs b/crates/infinity_host/src/scene.rs new file mode 100644 index 0000000..cb72a0b --- /dev/null +++ b/crates/infinity_host/src/scene.rs @@ -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, + pub pattern_id: String, + pub seed: u64, + pub palette: Vec, + pub parameters: BTreeMap, + pub target_group: Option, + 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, + pub energy_percent: u8, +} + +#[derive(Debug, Clone)] +pub struct PatternRegistry { + definitions: BTreeMap, +} + +#[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 { + self.definitions.values().cloned().collect() + } + + pub fn scene_from_preset_id( + &self, + project: &ProjectConfig, + preset_id: &str, + ) -> Option { + 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, + target_group: Option, + seed: u64, + palette: Vec, + 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::>(); + + let representative = RgbColor::average(&sample_leds); + let energy_percent = sample_leds + .iter() + .map(|color| color.energy_percent() as u32) + .sum::() + / 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::>(); + 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> { + 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 { + 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 { + 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('#')); + } +} diff --git a/crates/infinity_host/src/show_store.rs b/crates/infinity_host/src/show_store.rs new file mode 100644 index 0000000..b41189e --- /dev/null +++ b/crates/infinity_host/src/show_store.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredGroup { + pub group_id: String, + pub tags: Vec, + pub members: Vec, + pub source: CatalogSource, + pub updated_at_unix_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredCreativeSnapshot { + pub snapshot_id: String, + pub label: Option, + pub scene: SceneRuntime, + pub transition_duration_ms: u32, + pub transition_style: SceneTransitionStyle, + pub saved_at_unix_ms: u64, + pub source_preset_id: Option, +} + +#[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, + pub global: PersistedGlobalState, + pub user_presets: Vec, + pub user_groups: Vec, + pub creative_snapshots: Vec, +} + +#[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, +} + +#[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, + groups: Vec, + creative_snapshots: Vec, +} + +impl RuntimeStateStorage { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result { + 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, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +fn parse_runtime_state(raw: &str, path: &Path) -> Result { + let envelope = serde_json::from_str::(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::>(); + + 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::>(); + + 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 { + registry + .pattern_definitions() + .into_iter() + .map(|pattern| pattern.pattern_id) + .collect() + } + + pub fn scene_from_preset_id(&self, preset_id: &str) -> Option { + 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 { + 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> { + 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, + 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, + members: Vec, + 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(items: &mut Vec, 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); + } +} diff --git a/crates/infinity_host/src/simulation.rs b/crates/infinity_host/src/simulation.rs new file mode 100644 index 0000000..b26dabc --- /dev/null +++ b/crates/infinity_host/src/simulation.rs @@ -0,0 +1,1023 @@ +use crate::{ + control::{ + CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort, + HostCommand, HostCommandError, HostSnapshot, NodeConnectionState, NodeSnapshot, + PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource, SceneTransitionStyle, + StatusEvent, StatusEventKind, SystemSnapshot, HOST_API_VERSION, + }, + runtime::TickSchedule, + scene::{ + apply_group_gate, blackout_preview, blend_previews, panel_membership_key, + panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime, + }, + show_store::{PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError}, +}; +use infinity_config::{PanelPosition, ProjectConfig}; +use std::{ + collections::BTreeMap, + path::PathBuf, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +const MAX_RECENT_EVENTS: usize = 16; +const PANEL_TEST_HOLD_MS: u64 = 1_400; + +#[derive(Debug)] +pub struct SimulationHostService { + inner: Arc>, +} + +#[derive(Debug)] +struct SimulationState { + registry: PatternRegistry, + group_members: BTreeMap>, + show_store: ShowStore, + runtime_storage: Option, + started_at: Instant, + next_seed: u64, + tick_count: u64, + frame_index: u64, + dropped_frames: u64, + schedule: TickSchedule, + current_scene: SceneRuntime, + active_transition: Option, + snapshot: HostSnapshot, +} + +impl SimulationHostService { + pub fn new(project: ProjectConfig) -> Self { + Self::try_new(project).expect("memory-only simulation host service must initialize") + } + + pub fn spawn_shared(project: ProjectConfig) -> Arc { + let service = Arc::new(Self::new(project)); + Self::spawn_simulation_loop(Arc::clone(&service)); + service + } + + pub fn try_new(project: ProjectConfig) -> Result { + Ok(Self { + inner: Arc::new(Mutex::new(SimulationState::try_new(project, None)?)), + }) + } + + pub fn try_new_with_persistence( + project: ProjectConfig, + runtime_state_path: impl Into, + ) -> Result { + Ok(Self { + inner: Arc::new(Mutex::new(SimulationState::try_new( + project, + Some(RuntimeStateStorage::new(runtime_state_path)), + )?)), + }) + } + + pub fn try_spawn_shared_with_persistence( + project: ProjectConfig, + runtime_state_path: impl Into, + ) -> Result, ShowStoreError> { + let service = Arc::new(Self::try_new_with_persistence(project, runtime_state_path)?); + Self::spawn_simulation_loop(Arc::clone(&service)); + Ok(service) + } + + fn spawn_simulation_loop(service: Arc) { + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(80)); + if let Ok(mut state) = service.inner.lock() { + state.simulate_tick(); + } + }); + } +} + +impl HostApiPort for SimulationHostService { + fn snapshot(&self) -> HostSnapshot { + self.inner + .lock() + .map(|state| state.snapshot.clone()) + .unwrap_or_else(|_| unavailable_snapshot()) + } + + fn send_command(&self, command: HostCommand) -> Result { + if let Ok(mut state) = self.inner.lock() { + state.apply_command(command) + } else { + Err(HostCommandError::new( + "service_unavailable", + "simulation state lock was unavailable", + )) + } + } +} + +impl SimulationState { + fn try_new( + project: ProjectConfig, + runtime_storage: Option, + ) -> Result { + let registry = PatternRegistry::new(); + let mut show_store = ShowStore::from_project(&project, ®istry); + let runtime_load = if let Some(storage) = &runtime_storage { + storage.load_with_recovery() + } else { + crate::show_store::RuntimeStateLoadResult { + runtime: Default::default(), + loaded_from_disk: false, + warnings: Vec::new(), + } + }; + let runtime_loaded_from_disk = runtime_load.loaded_from_disk; + let runtime_warnings = runtime_load.warnings; + let persisted_runtime = runtime_load.runtime; + let restored_scene = persisted_runtime.active_scene.clone(); + let restored_global = persisted_runtime.global.clone(); + show_store.apply_persisted(persisted_runtime); + let group_members = show_store.group_members_map(); + let schedule = TickSchedule::default(); + let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(®istry)); + let catalog = show_store.catalog(®istry); + let available_patterns = show_store.available_patterns(®istry); + let nodes = project + .topology + .nodes + .iter() + .map(|node| NodeSnapshot { + node_id: node.node_id.clone(), + display_name: node.display_name.clone(), + reserved_ip: node.network.reserved_ip.clone(), + connection: NodeConnectionState::Online, + last_contact_ms: 10, + error_status: None, + panel_count: node.outputs.len(), + }) + .collect::>(); + let panels = project + .topology + .nodes + .iter() + .flat_map(|node| { + node.outputs.iter().map(move |output| PanelSnapshot { + target: PanelTarget { + node_id: node.node_id.clone(), + panel_position: output.panel_position.clone(), + }, + physical_output_name: output.physical_output_name.clone(), + driver_reference: output.driver_channel.reference.clone(), + led_count: output.led_count, + direction: output.direction.clone(), + color_order: output.color_order.clone(), + enabled: output.enabled, + validation_state: output.validation_state.clone(), + connection: NodeConnectionState::Online, + last_test_trigger_ms: None, + error_status: None, + }) + }) + .collect::>(); + + let selected_pattern = current_scene.pattern_id.clone(); + let mut state = Self { + registry, + group_members, + show_store, + runtime_storage, + started_at: Instant::now(), + next_seed: 100, + tick_count: 0, + frame_index: 0, + dropped_frames: 0, + schedule: schedule.clone(), + current_scene, + active_transition: None, + snapshot: HostSnapshot { + api_version: HOST_API_VERSION, + backend_label: "simulation-core".to_string(), + generated_at_millis: 0, + system: SystemSnapshot { + project_name: project.metadata.project_name.clone(), + schema_version: project.metadata.schema_version, + topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(), + }, + global: GlobalControlSnapshot { + blackout: restored_global.blackout, + master_brightness: restored_global.master_brightness, + selected_pattern, + selected_group: None, + transition_duration_ms: restored_global.transition_duration_ms, + transition_style: restored_global.transition_style, + }, + engine: EngineSnapshot { + logic_hz: schedule.logic_hz, + frame_hz: schedule.frame_synthesis_hz, + preview_hz: schedule.preview_hz, + uptime_ms: 0, + frame_index: 0, + dropped_frames: 0, + active_transition: None, + }, + catalog, + active_scene: crate::control::ActiveSceneSnapshot { + preset_id: None, + pattern_id: "solid_color".to_string(), + seed: 0, + palette: vec!["#ffffff".to_string()], + parameters: Vec::new(), + target_group: None, + blackout: false, + }, + preview: crate::control::PreviewSnapshot { panels: Vec::new() }, + available_patterns, + nodes, + panels, + recent_events: Vec::new(), + }, + }; + state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone(); + state.snapshot.global.selected_group = state.current_scene.target_group.clone(); + state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene); + state.push_event( + StatusEventKind::Info, + None, + "simulation host service started".to_string(), + ); + if state.runtime_storage.is_some() { + let (code, message) = if runtime_loaded_from_disk { + ( + "runtime_state_restored", + "runtime state restored from persistence".to_string(), + ) + } else { + ( + "runtime_state_persistence_enabled", + "runtime state persistence enabled".to_string(), + ) + }; + state.push_event(StatusEventKind::Info, Some(code.to_string()), message); + } + for warning in runtime_warnings { + state.push_event( + StatusEventKind::Warning, + Some(warning.code), + warning.message, + ); + } + state.simulate_tick(); + Ok(state) + } + + fn simulate_tick(&mut self) { + self.tick_count += 1; + let elapsed_ms = self.elapsed_millis(); + self.frame_index = elapsed_ms * self.schedule.frame_synthesis_hz as u64 / 1_000; + self.snapshot.generated_at_millis = elapsed_ms; + self.snapshot.engine.uptime_ms = elapsed_ms; + self.snapshot.engine.frame_index = self.frame_index; + self.snapshot.engine.dropped_frames = self.dropped_frames; + self.snapshot.engine.logic_hz = self.schedule.logic_hz; + self.snapshot.engine.frame_hz = self.schedule.frame_synthesis_hz; + self.snapshot.engine.preview_hz = self.schedule.preview_hz; + + self.update_node_states(); + self.update_panel_states(); + self.resolve_transition_if_complete(); + self.snapshot.engine.active_transition = + self.active_transition.as_ref().map(|transition| { + self.registry + .transition_snapshot(&self.current_scene, transition) + }); + self.snapshot.active_scene = self.registry.active_scene_snapshot(&self.current_scene); + self.snapshot.global.selected_pattern = self.current_scene.pattern_id.clone(); + self.snapshot.global.selected_group = self.current_scene.target_group.clone(); + self.snapshot.preview.panels = self.render_preview_panels(elapsed_ms); + } + + fn apply_command(&mut self, command: HostCommand) -> Result { + let mut should_persist = false; + let summary = match command { + HostCommand::SetBlackout(enabled) => { + self.snapshot.global.blackout = enabled; + should_persist = true; + let summary = if enabled { + "global blackout enabled".to_string() + } else { + "global blackout released".to_string() + }; + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SetMasterBrightness(value) => { + self.snapshot.global.master_brightness = value.clamp(0.0, 1.0); + should_persist = true; + let summary = format!( + "master brightness set to {:.0}%", + self.snapshot.global.master_brightness * 100.0 + ); + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SelectPattern(pattern_id) => { + let mut new_scene = self.registry.scene_for_pattern( + &pattern_id, + None, + self.current_scene.target_group.clone(), + self.next_seed, + self.current_scene.palette.clone(), + false, + ); + self.next_seed += 1; + + if let Some(speed) = self.current_scene.parameters.get("speed").cloned() { + self.registry + .set_scene_parameter(&mut new_scene, "speed", speed); + } + if let Some(intensity) = self.current_scene.parameters.get("intensity").cloned() { + self.registry + .set_scene_parameter(&mut new_scene, "intensity", intensity); + } + + let duration_ms = self.snapshot.global.transition_duration_ms; + let style = self.snapshot.global.transition_style; + should_persist = true; + self.start_scene_transition( + new_scene, + duration_ms, + style, + format!("pattern selected: {pattern_id}"), + ); + format!("pattern selected: {pattern_id}") + } + HostCommand::RecallPreset { preset_id } => { + let Some(scene) = self.show_store.scene_from_preset_id(&preset_id) else { + let error = HostCommandError::new( + "unknown_preset", + format!("preset '{preset_id}' does not exist"), + ); + self.push_event( + StatusEventKind::Warning, + Some(error.code.clone()), + error.message.clone(), + ); + return Err(error); + }; + let (duration_ms, style) = self + .show_store + .transition_for_preset(&preset_id) + .unwrap_or(( + self.snapshot.global.transition_duration_ms, + self.snapshot.global.transition_style, + )); + self.snapshot.global.transition_duration_ms = duration_ms; + self.snapshot.global.transition_style = style; + should_persist = true; + self.start_scene_transition( + scene, + duration_ms, + style, + format!("preset recalled: {preset_id}"), + ); + format!("preset recalled: {preset_id}") + } + HostCommand::SelectGroup { group_id } => { + if let Some(group_id_ref) = &group_id { + if !self.show_store.has_group(group_id_ref) { + let error = HostCommandError::new( + "unknown_group", + format!("group '{group_id_ref}' does not exist"), + ); + self.push_event( + StatusEventKind::Warning, + Some(error.code.clone()), + error.message.clone(), + ); + return Err(error); + } + } + self.current_scene.target_group = group_id.clone(); + self.snapshot.global.selected_group = group_id.clone(); + self.current_scene.preset_id = None; + should_persist = true; + let summary = format!( + "target group set to {}", + group_id.as_deref().unwrap_or("all_panels") + ); + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SetSceneParameter { key, value } => { + self.registry + .set_scene_parameter(&mut self.current_scene, &key, value.clone()); + self.current_scene.preset_id = None; + should_persist = true; + let summary = format!("scene parameter updated: {key} = {value:?}"); + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SetTransitionDurationMs(duration_ms) => { + self.snapshot.global.transition_duration_ms = duration_ms; + should_persist = true; + let summary = format!("default transition duration set to {} ms", duration_ms); + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SetTransitionStyle(style) => { + self.snapshot.global.transition_style = style; + should_persist = true; + let summary = format!("default transition style set to {}", style.label()); + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::TriggerPanelTest { target, pattern } => { + let now = self.elapsed_millis(); + let mut message = format!( + "test '{}' requested for unknown panel {}:{}", + pattern.label(), + target.node_id, + panel_position_label(&target.panel_position) + ); + + if let Some(panel) = self + .snapshot + .panels + .iter_mut() + .find(|panel| panel.target == target) + { + panel.last_test_trigger_ms = Some(now); + message = format!( + "test '{}' triggered for {}:{}", + pattern.label(), + target.node_id, + panel_position_label(&target.panel_position) + ); + } + + self.push_event(StatusEventKind::Info, None, message.clone()); + message + } + HostCommand::SavePreset { + preset_id, + overwrite, + } => { + self.show_store.save_preset_from_scene( + &preset_id, + &self.current_scene, + self.snapshot.global.transition_duration_ms, + self.snapshot.global.transition_style, + overwrite, + )?; + self.rebuild_catalog(); + self.group_members = self.show_store.group_members_map(); + should_persist = true; + let summary = if overwrite { + format!("preset overwritten: {preset_id}") + } else { + format!("preset saved: {preset_id}") + }; + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::SaveCreativeSnapshot { + snapshot_id, + label, + overwrite, + } => { + self.show_store.save_creative_snapshot( + &snapshot_id, + label.clone(), + &self.current_scene, + self.snapshot.global.transition_duration_ms, + self.snapshot.global.transition_style, + overwrite, + )?; + self.rebuild_catalog(); + should_persist = true; + let summary = if overwrite { + format!("creative snapshot overwritten: {snapshot_id}") + } else { + format!("creative snapshot saved: {snapshot_id}") + }; + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + HostCommand::RecallCreativeSnapshot { snapshot_id } => { + let Some(snapshot) = self.show_store.recall_creative_snapshot(&snapshot_id) else { + let error = HostCommandError::new( + "unknown_creative_snapshot", + format!("creative snapshot '{snapshot_id}' does not exist"), + ); + self.push_event( + StatusEventKind::Warning, + Some(error.code.clone()), + error.message.clone(), + ); + return Err(error); + }; + self.snapshot.global.transition_duration_ms = snapshot.transition_duration_ms; + self.snapshot.global.transition_style = snapshot.transition_style; + should_persist = true; + self.start_scene_transition( + snapshot.scene, + snapshot.transition_duration_ms, + snapshot.transition_style, + format!("creative snapshot recalled: {snapshot_id}"), + ); + format!("creative snapshot recalled: {snapshot_id}") + } + HostCommand::UpsertGroup { + group_id, + tags, + members, + overwrite, + } => { + self.show_store + .upsert_group(&group_id, tags, members, overwrite)?; + self.group_members = self.show_store.group_members_map(); + self.rebuild_catalog(); + should_persist = true; + let summary = if overwrite { + format!("group updated: {group_id}") + } else { + format!("group saved: {group_id}") + }; + self.push_event(StatusEventKind::Info, None, summary.clone()); + summary + } + }; + + self.simulate_tick(); + if should_persist { + self.persist_runtime_state()?; + } + Ok(CommandOutcome { + generated_at_millis: self.snapshot.generated_at_millis, + summary, + }) + } + + fn start_scene_transition( + &mut self, + new_scene: SceneRuntime, + duration_ms: u32, + style: SceneTransitionStyle, + event_message: String, + ) { + let previous_scene = self.current_scene.clone(); + self.current_scene = new_scene; + self.active_transition = if duration_ms == 0 { + None + } else { + Some(TransitionRuntime { + style, + duration_ms, + started_at: Instant::now(), + from_scene: previous_scene, + }) + }; + self.push_event(StatusEventKind::Info, None, event_message); + } + + fn resolve_transition_if_complete(&mut self) { + let finished = self + .active_transition + .as_ref() + .map(|transition| self.registry.transition_finished(transition)) + .unwrap_or(false); + + if finished { + self.active_transition = None; + self.push_event( + StatusEventKind::Info, + None, + format!("transition completed to {}", self.current_scene.pattern_id), + ); + } + } + + fn update_node_states(&mut self) { + let previous_states: BTreeMap<_, _> = self + .snapshot + .nodes + .iter() + .map(|node| (node.node_id.clone(), node.connection)) + .collect(); + let mut transition_messages = Vec::new(); + + for (index, node) in self.snapshot.nodes.iter_mut().enumerate() { + let connection = simulated_connection_state(index, self.tick_count); + node.connection = connection; + node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection); + node.error_status = simulated_error_status(connection); + if previous_states + .get(&node.node_id) + .copied() + .unwrap_or(NodeConnectionState::Offline) + != connection + { + transition_messages.push(format!( + "{} is now {}", + node.display_name, + connection.label() + )); + } + } + + for message in transition_messages { + self.push_event( + StatusEventKind::Warning, + Some("node_connection_state".to_string()), + message, + ); + } + } + + fn update_panel_states(&mut self) { + let node_states: BTreeMap<_, _> = self + .snapshot + .nodes + .iter() + .map(|node| { + ( + node.node_id.clone(), + (node.connection, node.error_status.clone()), + ) + }) + .collect(); + + for panel in &mut self.snapshot.panels { + if let Some((connection, node_error)) = node_states.get(&panel.target.node_id) { + panel.connection = *connection; + panel.error_status = match (node_error, panel.enabled) { + (_, false) => Some("output disabled".to_string()), + (Some(error), _) => Some(error.clone()), + (None, true) => None, + }; + } + } + } + + fn render_preview_panels(&self, elapsed_ms: u64) -> Vec { + self.snapshot + .panels + .iter() + .enumerate() + .map(|(panel_index, panel)| { + let preview = self.render_preview_for_panel(panel, panel_index, elapsed_ms); + PreviewPanelSnapshot { + target: panel.target.clone(), + representative_color_hex: preview.0.representative_color_hex, + sample_led_hex: preview.0.sample_led_hex, + energy_percent: preview.0.energy_percent, + preview_source: preview.1, + } + }) + .collect() + } + + fn render_preview_for_panel( + &self, + panel: &PanelSnapshot, + panel_index: usize, + elapsed_ms: u64, + ) -> (RenderedPreview, PreviewSource) { + if self.snapshot.global.blackout || self.current_scene.blackout || !panel.enabled { + return (blackout_preview(), PreviewSource::Blackout); + } + + if let Some(last_trigger_ms) = panel.last_test_trigger_ms { + let age = elapsed_ms.saturating_sub(last_trigger_ms); + if age <= PANEL_TEST_HOLD_MS { + let preview = scale_preview( + panel_test_preview(age), + self.snapshot.global.master_brightness, + ); + return (preview, PreviewSource::PanelTest); + } + } + + let panel_count = self.snapshot.panels.len(); + let current = + self.registry + .render_preview(&self.current_scene, panel_index, panel_count, elapsed_ms); + let mut source = PreviewSource::Scene; + let mut preview = if let Some(transition) = &self.active_transition { + let from = self.registry.render_preview( + &transition.from_scene, + panel_index, + panel_count, + elapsed_ms, + ); + let progress = self + .registry + .transition_snapshot(&self.current_scene, transition) + .progress; + source = PreviewSource::Transition; + blend_previews(&from, ¤t, progress) + } else { + current + }; + + if let Some(group_id) = &self.current_scene.target_group { + let membership_key = panel_membership_key( + &panel.target.node_id, + panel_position_label(&panel.target.panel_position), + ); + let active_in_group = self + .group_members + .get(group_id) + .map(|members| members.contains(&membership_key)) + .unwrap_or(false); + preview = apply_group_gate(&preview, active_in_group); + } + + ( + scale_preview(preview, self.snapshot.global.master_brightness), + source, + ) + } + + fn rebuild_catalog(&mut self) { + self.snapshot.catalog = self.show_store.catalog(&self.registry); + } + + fn persisted_global_state(&self) -> PersistedGlobalState { + PersistedGlobalState { + blackout: self.snapshot.global.blackout, + master_brightness: self.snapshot.global.master_brightness, + transition_duration_ms: self.snapshot.global.transition_duration_ms, + transition_style: self.snapshot.global.transition_style, + } + } + + fn persist_runtime_state(&mut self) -> Result<(), HostCommandError> { + let Some(storage) = &self.runtime_storage else { + return Ok(()); + }; + let storage_path = storage.path().to_path_buf(); + + let runtime_state = self + .show_store + .persisted_runtime(&self.current_scene, self.persisted_global_state()); + if let Err(error) = storage.save(&runtime_state) { + let command_error = HostCommandError::new( + "persist_failed", + format!( + "runtime state could not be saved to {}: {error}", + storage_path.display() + ), + ); + self.push_event( + StatusEventKind::Error, + Some(command_error.code.clone()), + command_error.message.clone(), + ); + return Err(command_error); + } + Ok(()) + } + + fn push_event(&mut self, kind: StatusEventKind, code: Option, message: String) { + self.snapshot.recent_events.insert( + 0, + StatusEvent { + at_millis: self.elapsed_millis(), + kind, + code, + message, + }, + ); + self.snapshot.recent_events.truncate(MAX_RECENT_EVENTS); + } + + fn elapsed_millis(&self) -> u64 { + self.started_at.elapsed().as_millis() as u64 + } +} + +fn unavailable_snapshot() -> HostSnapshot { + HostSnapshot { + api_version: HOST_API_VERSION, + backend_label: "simulation-unavailable".to_string(), + generated_at_millis: 0, + system: SystemSnapshot { + project_name: "Unavailable".to_string(), + schema_version: 0, + topology_label: "unknown".to_string(), + }, + global: GlobalControlSnapshot { + blackout: true, + master_brightness: 0.0, + selected_pattern: "unavailable".to_string(), + selected_group: None, + transition_duration_ms: 0, + transition_style: SceneTransitionStyle::Snap, + }, + engine: EngineSnapshot { + logic_hz: 0, + frame_hz: 0, + preview_hz: 0, + uptime_ms: 0, + frame_index: 0, + dropped_frames: 0, + active_transition: None, + }, + catalog: CatalogSnapshot { + patterns: Vec::new(), + presets: Vec::new(), + groups: Vec::new(), + creative_snapshots: Vec::new(), + }, + active_scene: crate::control::ActiveSceneSnapshot { + preset_id: None, + pattern_id: "unavailable".to_string(), + seed: 0, + palette: Vec::new(), + parameters: Vec::new(), + target_group: None, + blackout: true, + }, + preview: crate::control::PreviewSnapshot { panels: Vec::new() }, + available_patterns: Vec::new(), + nodes: Vec::new(), + panels: Vec::new(), + recent_events: vec![StatusEvent { + at_millis: 0, + kind: StatusEventKind::Error, + code: Some("service_unavailable".to_string()), + message: "simulation service lock was unavailable".to_string(), + }], + } +} + +fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState { + match index { + 4 => { + if tick_count % 24 < 8 { + NodeConnectionState::Degraded + } else { + NodeConnectionState::Online + } + } + 5 => { + if tick_count % 32 < 7 { + NodeConnectionState::Offline + } else { + NodeConnectionState::Online + } + } + _ => NodeConnectionState::Online, + } +} + +fn simulated_last_contact_ms( + index: usize, + tick_count: u64, + connection: NodeConnectionState, +) -> u64 { + match connection { + NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6), + NodeConnectionState::Degraded => 180 + (tick_count % 90), + NodeConnectionState::Offline => 2_500 + (tick_count % 700), + } +} + +fn simulated_error_status(connection: NodeConnectionState) -> Option { + match connection { + NodeConnectionState::Online => None, + NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()), + NodeConnectionState::Offline => Some("awaiting reconnect".to_string()), + } +} + +fn panel_position_label(position: &PanelPosition) -> &'static str { + match position { + PanelPosition::Top => "top", + PanelPosition::Middle => "middle", + PanelPosition::Bottom => "bottom", + } +} + +fn scale_preview(mut preview: RenderedPreview, factor: f32) -> RenderedPreview { + let factor = factor.clamp(0.0, 1.0); + preview.representative_color_hex = scale_hex_color(&preview.representative_color_hex, factor); + preview.sample_led_hex = preview + .sample_led_hex + .into_iter() + .map(|hex| scale_hex_color(&hex, factor)) + .collect(); + preview.energy_percent = ((preview.energy_percent as f32) * factor).round() as u8; + preview +} + +fn scale_hex_color(hex: &str, factor: f32) -> String { + let raw = hex.trim().trim_start_matches('#'); + if raw.len() == 6 { + if let Ok(value) = u32::from_str_radix(raw, 16) { + let r = (((value >> 16) & 0xff) as f32 * factor).round() as u8; + let g = (((value >> 8) & 0xff) as f32 * factor).round() as u8; + let b = ((value & 0xff) as f32 * factor).round() as u8; + return format!("#{:02X}{:02X}{:02X}", r, g, b); + } + } + "#000000".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SceneParameterValue; + 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 simulation_service_exposes_catalog_and_preview() { + let service = SimulationHostService::new(sample_project()); + let snapshot = service.snapshot(); + assert!(snapshot + .catalog + .presets + .iter() + .any(|preset| preset.preset_id == "amber_chase_top")); + assert_eq!(snapshot.preview.panels.len(), 18); + assert_eq!(snapshot.nodes.len(), 6); + } + + #[test] + fn commands_update_scene_and_group() { + let service = SimulationHostService::new(sample_project()); + let _ = service.send_command(HostCommand::SelectGroup { + group_id: Some("top_panels".to_string()), + }); + let _ = service.send_command(HostCommand::RecallPreset { + preset_id: "mapping_walk_test".to_string(), + }); + let _ = service.send_command(HostCommand::SetSceneParameter { + key: "speed".to_string(), + value: SceneParameterValue::Scalar(2.0), + }); + + let snapshot = service.snapshot(); + assert_eq!(snapshot.active_scene.pattern_id, "walking_pixel"); + assert_eq!( + snapshot.global.selected_group.as_deref(), + Some("all_panels") + ); + assert!(snapshot + .active_scene + .parameters + .iter() + .any(|parameter| parameter.key == "speed")); + } + + #[test] + fn unknown_group_returns_typed_error_and_warning_event() { + let service = SimulationHostService::new(sample_project()); + let error = service + .send_command(HostCommand::SelectGroup { + group_id: Some("does_not_exist".to_string()), + }) + .expect_err("unknown group should fail"); + + let snapshot = service.snapshot(); + assert_eq!(error.code, "unknown_group"); + assert!(snapshot + .recent_events + .iter() + .any(|event| event.kind == StatusEventKind::Warning + && event.code.as_deref() == Some("unknown_group"))); + } + + #[test] + fn persistent_service_recovers_from_invalid_runtime_state_with_warning_event() { + let path = std::env::temp_dir().join(format!( + "infinity_vis_simulation_invalid_{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_millis() + )); + std::fs::write(&path, "{ broken").expect("invalid runtime state should write"); + + let service = SimulationHostService::try_new_with_persistence(sample_project(), &path) + .expect("service should recover from invalid runtime state"); + let snapshot = service.snapshot(); + + assert!(snapshot.recent_events.iter().any(|event| { + event.kind == StatusEventKind::Warning + && event.code.as_deref() == Some("runtime_state_parse_failed") + })); + assert!(snapshot.recent_events.iter().any(|event| { + event.kind == StatusEventKind::Info + && event.code.as_deref() == Some("runtime_state_persistence_enabled") + })); + assert_eq!(snapshot.active_scene.pattern_id, "solid_color"); + + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/infinity_host_api/Cargo.toml b/crates/infinity_host_api/Cargo.toml new file mode 100644 index 0000000..ecb4a3d --- /dev/null +++ b/crates/infinity_host_api/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "infinity_host_api" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +infinity_config = { path = "../infinity_config" } +infinity_host = { path = "../infinity_host" } + diff --git a/crates/infinity_host_api/src/dto.rs b/crates/infinity_host_api/src/dto.rs new file mode 100644 index 0000000..8211321 --- /dev/null +++ b/crates/infinity_host_api/src/dto.rs @@ -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, + pub presets: Vec, + pub groups: Vec, + pub creative_snapshots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPresetListResponse { + pub api_version: &'static str, + pub presets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiGroupListResponse { + pub api_version: &'static str, + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCommandRequest { + #[serde(default)] + pub request_id: Option, + pub command: ApiCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCommandResponse { + pub api_version: &'static str, + pub accepted: bool, + pub request_id: Option, + pub generated_at_millis: u64, + pub 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, + pub panels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPreviewSnapshot { + pub generated_at_millis: u64, + pub panels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiStreamEnvelope { + pub api_version: &'static str, + pub sequence: u64, + pub generated_at_millis: u64, + pub message: ApiStreamMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case", tag = "type", content = "payload")] +pub enum ApiStreamMessage { + Snapshot(ApiStateSnapshot), + Preview(ApiPreviewSnapshot), + Event(ApiEventNotice), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiEventNotice { + pub kind: ApiEventKind, + pub code: Option, + 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, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiTransitionState { + pub style: ApiTransitionStyle, + pub from_pattern_id: String, + pub to_pattern_id: String, + pub duration_ms: u32, + pub progress: f32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiTransitionStyle { + Snap, + Crossfade, + Chase, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiActiveScene { + pub preset_id: Option, + pub pattern_id: String, + pub seed: u64, + pub palette: Vec, + pub parameters: Vec, + pub target_group: Option, + pub blackout: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiSceneParameter { + pub key: String, + pub label: String, + pub kind: ApiParameterKind, + pub value: ApiParameterValue, + pub min_scalar: Option, + pub max_scalar: Option, + pub step: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPatternCatalogEntry { + pub pattern_id: String, + pub display_name: String, + pub description: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPatternParameter { + pub key: String, + pub label: String, + pub kind: ApiParameterKind, + pub min_scalar: Option, + pub max_scalar: Option, + pub step: Option, + pub default_value: ApiParameterValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPresetSummary { + pub preset_id: String, + pub pattern_id: String, + pub target_group: Option, + pub transition_duration_ms: u32, + 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, + pub source: ApiCatalogSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiCreativeSnapshotSummary { + pub snapshot_id: String, + pub label: Option, + pub pattern_id: String, + pub target_group: Option, + 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, + pub connection: ApiConnectionState, + pub last_contact_ms: u64, + pub error_status: Option, + pub panel_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPanelStatus { + pub node_id: String, + pub panel_position: ApiPanelPosition, + pub physical_output_name: String, + pub driver_reference: String, + pub led_count: u16, + pub direction: ApiLedDirection, + pub color_order: ApiColorOrder, + pub enabled: bool, + pub validation_state: ApiValidationState, + pub connection: ApiConnectionState, + pub last_test_trigger_ms: Option, + pub error_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiPreviewPanel { + pub node_id: String, + pub panel_position: ApiPanelPosition, + pub representative_color_hex: String, + pub sample_led_hex: Vec, + pub energy_percent: u8, + pub source: ApiPreviewSource, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiPanelPosition { + Top, + Middle, + Bottom, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiConnectionState { + Online, + Degraded, + Offline, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiPreviewSource { + Scene, + Transition, + PanelTest, + Blackout, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApiParameterKind { + Scalar, + Toggle, + Text, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum 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, + }, + 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, + overwrite: bool, + }, + RecallCreativeSnapshot { + snapshot_id: String, + }, + UpsertGroup { + group_id: String, + tags: Vec, + members: Vec, + 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 { + 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, message: impl Into) -> Self { + Self { + api_version: API_VERSION, + error: ApiErrorBody { + code: code.into(), + message: message.into(), + }, + } + } +} + +impl From for ApiEventNotice { + fn from(event: infinity_host::StatusEvent) -> Self { + Self { + kind: map_event_kind(event.kind), + code: event.code, + message: event.message, + } + } +} diff --git a/crates/infinity_host_api/src/lib.rs b/crates/infinity_host_api/src/lib.rs new file mode 100644 index 0000000..774078a --- /dev/null +++ b/crates/infinity_host_api/src/lib.rs @@ -0,0 +1,6 @@ +mod dto; +mod server; +mod websocket; + +pub use dto::*; +pub use server::*; diff --git a/crates/infinity_host_api/src/main.rs b/crates/infinity_host_api/src/main.rs new file mode 100644 index 0000000..52ac59c --- /dev/null +++ b/crates/infinity_host_api/src/main.rs @@ -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> { + let cli = Cli::parse(); + let project = load_project(&cli.config)?; + let service: Arc = + 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> { + Ok(load_project_from_path(path)?) +} diff --git a/crates/infinity_host_api/src/server.rs b/crates/infinity_host_api/src/server.rs new file mode 100644 index 0000000..116f83a --- /dev/null +++ b/crates/infinity_host_api/src/server.rs @@ -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, + accept_thread: Option>, +} + +#[derive(Debug)] +struct ApiRequestError { + status: u16, + code: String, + message: String, +} + +impl HostApiServer { + pub fn bind(bind: &str, service: Arc) -> io::Result { + let listener = TcpListener::bind(bind)?; + listener.set_nonblocking(true)?; + let local_addr = listener.local_addr()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let thread_shutdown = Arc::clone(&shutdown); + let accept_thread = thread::spawn(move || accept_loop(listener, service, thread_shutdown)); + Ok(Self { + local_addr, + shutdown, + accept_thread: Some(accept_thread), + }) + } + + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + pub fn shutdown(mut self) { + self.shutdown.store(true, Ordering::SeqCst); + if let Some(handle) = self.accept_thread.take() { + let _ = handle.join(); + } + } +} + +impl Drop for HostApiServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + if let Some(handle) = self.accept_thread.take() { + let _ = handle.join(); + } + } +} + +fn accept_loop(listener: TcpListener, service: Arc, shutdown: Arc) { + while !shutdown.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + let service = Arc::clone(&service); + thread::spawn(move || { + let _ = handle_connection(stream, service); + }); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(25)); + } + Err(_) => break, + } + } +} + +fn handle_connection(mut stream: TcpStream, service: Arc) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let request = read_request(&mut stream)?; + if request.path == "/api/v1/stream" && request.is_websocket() { + return handle_websocket(stream, request, service); + } + + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/api/v1/snapshot") => { + let snapshot = service.snapshot(); + respond_json( + &mut stream, + 200, + &ApiSnapshotResponse::from_snapshot(&snapshot), + ) + } + ("GET", "/api/v1/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, +) -> Result<(), ApiRequestError> { + let parsed = serde_json::from_slice::(&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, +) -> 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::; + let mut last_event_signatures = Vec::<(Option, 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::>(); + 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( + stream: &mut TcpStream, + status: u16, + body: &T, +) -> io::Result<()> { + let payload = serde_json::to_string_pretty(body) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + respond_text(stream, status, "application/json; charset=utf-8", &payload) +} + +fn respond_error( + stream: &mut TcpStream, + status: u16, + code: impl Into, + message: impl Into, +) -> io::Result<()> { + respond_json(stream, status, &ApiErrorResponse::new(code, message)) +} + +fn respond_text( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &str, +) -> io::Result<()> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 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, + body: Vec, +} + +impl HttpRequest { + fn header(&self, key: &str) -> Option<&str> { + self.headers + .get(&key.to_ascii_lowercase()) + .map(|value| value.as_str()) + } + + fn is_websocket(&self) -> bool { + self.header("upgrade") + .map(|value| value.eq_ignore_ascii_case("websocket")) + .unwrap_or(false) + } +} + +fn read_request(stream: &mut TcpStream) -> io::Result { + let mut buffer = Vec::new(); + let mut temp = [0u8; 4096]; + let mut header_end = None; + let mut expected_len = None; + + loop { + let read = stream.read(&mut temp)?; + if read == 0 { + break; + } + buffer.extend_from_slice(&temp[..read]); + if header_end.is_none() { + header_end = find_header_end(&buffer); + if let Some(end) = header_end { + let header_text = String::from_utf8_lossy(&buffer[..end]); + expected_len = parse_content_length(&header_text); + if expected_len == Some(0) || expected_len.is_none() { + break; + } + } + } + + if let (Some(end), Some(content_len)) = (header_end, expected_len) { + if buffer.len() >= end + 4 + content_len { + break; + } + } + } + + let header_end = header_end + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing header end"))?; + let header_text = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = header_text.lines(); + let request_line = lines + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? + .to_string(); + let path = request_parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? + .split('?') + .next() + .unwrap_or("/") + .to_string(); + let mut headers = HashMap::new(); + for line in lines { + if let Some((key, value)) = line.split_once(':') { + headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + let body_start = header_end + 4; + let body = buffer.get(body_start..).unwrap_or_default().to_vec(); + Ok(HttpRequest { + method, + path, + headers, + body, + }) +} + +fn parse_content_length(header_text: &str) -> Option { + header_text.lines().find_map(|line| { + line.split_once(':').and_then(|(key, value)| { + if key.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} diff --git a/crates/infinity_host_api/src/websocket.rs b/crates/infinity_host_api/src/websocket.rs new file mode 100644 index 0000000..7d0f417 --- /dev/null +++ b/crates/infinity_host_api/src/websocket.rs @@ -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="); + } +} diff --git a/crates/infinity_host_api/tests/contract.rs b/crates/infinity_host_api/tests/contract.rs new file mode 100644 index 0000000..fe49e1c --- /dev/null +++ b/crates/infinity_host_api/tests/contract.rs @@ -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 = 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 = 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, + 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::() + .expect("valid status code"); + let headers = lines + .filter_map(|line| line.split_once(':')) + .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect::>(); + + HttpResponse { + status_code, + headers, + body, + } +} + +fn open_websocket(addr: SocketAddr) -> TcpStream { + let mut stream = TcpStream::connect(addr).expect("websocket connection"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("read timeout"); + let request = format!( + "GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + host = addr + ); + stream + .write_all(request.as_bytes()) + .expect("write handshake"); + + let header = read_until_header_end(&mut stream); + let header_text = String::from_utf8(header).expect("handshake utf8"); + assert!(header_text.starts_with("HTTP/1.1 101")); + assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); + stream +} + +fn read_until_header_end(stream: &mut TcpStream) -> Vec { + let mut buffer = Vec::new(); + loop { + let mut byte = [0u8; 1]; + let read = stream.read(&mut byte).expect("read handshake"); + assert!(read > 0, "unexpected eof while reading handshake"); + buffer.push(byte[0]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + let end = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("header end") + + 4; + return buffer[..end].to_vec(); + } + } +} + +fn read_websocket_text_frame(stream: &mut TcpStream) -> String { + let mut header = [0u8; 2]; + stream.read_exact(&mut header).expect("frame header"); + assert_eq!(header[0] & 0x0f, 0x1, "expected text frame"); + + let payload_len = match header[1] & 0x7f { + len @ 0..=125 => len as usize, + 126 => { + let mut extended = [0u8; 2]; + stream.read_exact(&mut extended).expect("extended payload"); + u16::from_be_bytes(extended) as usize + } + 127 => { + let mut extended = [0u8; 8]; + stream.read_exact(&mut extended).expect("extended payload"); + u64::from_be_bytes(extended) as usize + } + _ => unreachable!("masked length bit should already be stripped"), + }; + + let mut payload = vec![0u8; payload_len]; + stream.read_exact(&mut payload).expect("frame payload"); + String::from_utf8(payload).expect("frame utf8") +} + +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")) +} diff --git a/crates/infinity_host_ui/Cargo.toml b/crates/infinity_host_ui/Cargo.toml new file mode 100644 index 0000000..7f35ec3 --- /dev/null +++ b/crates/infinity_host_ui/Cargo.toml @@ -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" } + diff --git a/crates/infinity_host_ui/src/app.rs b/crates/infinity_host_ui/src/app.rs new file mode 100644 index 0000000..f7f6751 --- /dev/null +++ b/crates/infinity_host_ui/src/app.rs @@ -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, + snapshot: HostSnapshot, +} + +impl HostUiApp { + pub fn new(service: Arc) -> 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) { + 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) { + 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" + } +} diff --git a/crates/infinity_host_ui/src/main.rs b/crates/infinity_host_ui/src/main.rs new file mode 100644 index 0000000..dfd0ec0 --- /dev/null +++ b/crates/infinity_host_ui/src/main.rs @@ -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, +} + +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") +} diff --git a/crates/infinity_protocol/Cargo.toml b/crates/infinity_protocol/Cargo.toml new file mode 100644 index 0000000..937a3ea --- /dev/null +++ b/crates/infinity_protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "infinity_protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true + diff --git a/crates/infinity_protocol/src/lib.rs b/crates/infinity_protocol/src/lib.rs new file mode 100644 index 0000000..a58c743 --- /dev/null +++ b/crates/infinity_protocol/src/lib.rs @@ -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, + }, + 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, + 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, + pub temperature_celsius: Option, +} + +#[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, + pub effect: String, + pub seed: u64, + pub palette: Vec, + pub master_brightness: f32, + pub speed: f32, + pub intensity: f32, + pub target_group: Option, + pub target_outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PixelFrame { + pub node_id: String, + pub frame_index: u64, + pub target_outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OutputFrame { + pub panel: PanelAddress, + pub pixels: Vec, +} + +#[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); + } +} diff --git a/docs/acceptance_template.md b/docs/acceptance_template.md new file mode 100644 index 0000000..24df88f --- /dev/null +++ b/docs/acceptance_template.md @@ -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: + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fe62b76 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/build_and_deploy.md b/docs/build_and_deploy.md new file mode 100644 index 0000000..6d57c24 --- /dev/null +++ b/docs/build_and_deploy.md @@ -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 flash monitor` + +The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`. diff --git a/docs/config_schema.md b/docs/config_schema.md new file mode 100644 index 0000000..4889f77 --- /dev/null +++ b/docs/config_schema.md @@ -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` + diff --git a/docs/host_api.md b/docs/host_api.md new file mode 100644 index 0000000..bc16d5a --- /dev/null +++ b/docs/host_api.md @@ -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. diff --git a/docs/legacy_xml_reference.md b/docs/legacy_xml_reference.md new file mode 100644 index 0000000..30a5c17 --- /dev/null +++ b/docs/legacy_xml_reference.md @@ -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 + diff --git a/docs/local_software_only_runbook.md b/docs/local_software_only_runbook.md new file mode 100644 index 0000000..6be5bce --- /dev/null +++ b/docs/local_software_only_runbook.md @@ -0,0 +1,50 @@ +# Local Software-Only Runbook + +## Voraussetzungen + +- Rust `stable` Toolchain mit `cargo`, `rustc`, `rustfmt` und `clippy` +- dieses Repo ist lokal aktuell **kein echter Git-Clone**, sondern nur ein Arbeitsbaum ohne `.git` +- keine Hardware ist fuer den software-only Betrieb noetig + +Beispiel fuer eine user-lokale Rust-Installation: + +```bash +curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh +sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable +. "$HOME/.cargo/env" +rustup component add rustfmt clippy +``` + +## Start + +```bash +. "$HOME/.cargo/env" +cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json +``` + +## Lokale URLs + +- Creative Web-UI: `http://127.0.0.1:9001/` +- State API: `http://127.0.0.1:9001/api/v1/state` +- Preview API: `http://127.0.0.1:9001/api/v1/preview` +- Snapshot API: `http://127.0.0.1:9001/api/v1/snapshot` +- WebSocket-Stream: `ws://127.0.0.1:9001/api/v1/stream` + +## Minimale Smoke-Checks + +1. Web-UI laedt unter `http://127.0.0.1:9001/`. +2. `GET /api/v1/state` antwortet mit `api_version: "v1"`. +3. `ws://127.0.0.1:9001/api/v1/stream` verbindet und liefert zuerst `snapshot`, dann `preview`. +4. In der Web-UI oder ueber `POST /api/v1/command` funktionieren diese Basisfluesse: + - preset recall + - preset save / overwrite + - creative snapshot save / recall + - blackout + +## Runtime-State und Recovery + +- Runtime-Persistenz liegt standardmaessig unter `data/runtime_state.json`. +- Beim Schreiben werden aktiver Scene-State, Runtime-Presets, Runtime-Gruppen, Creative Snapshots und globale Steuerwerte persistiert. +- Fehlende Dateien sind okay. +- Leere, defekte oder schema-inkompatible Persistenzdateien blockieren den Serverstart nicht mehr. +- In diesen Recovery-Faellen startet der Host mit Default-State und erzeugt Warning-/Info-Events im Eventfeed statt abzubrechen. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..f115b21 --- /dev/null +++ b/docs/protocol.md @@ -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. + diff --git a/docs/show_control_primitives.md b/docs/show_control_primitives.md new file mode 100644 index 0000000..a75d721 --- /dev/null +++ b/docs/show_control_primitives.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a280f75 --- /dev/null +++ b/docs/testing.md @@ -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 + diff --git a/docs/validation_open_points.md b/docs/validation_open_points.md new file mode 100644 index 0000000..16bafa7 --- /dev/null +++ b/docs/validation_open_points.md @@ -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. + diff --git a/firmware/esp32_node/CMakeLists.txt b/firmware/esp32_node/CMakeLists.txt new file mode 100644 index 0000000..10c2c38 --- /dev/null +++ b/firmware/esp32_node/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(infinity_vis_esp32_node) + diff --git a/firmware/esp32_node/README.md b/firmware/esp32_node/README.md new file mode 100644 index 0000000..7728bdc --- /dev/null +++ b/firmware/esp32_node/README.md @@ -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. + diff --git a/firmware/esp32_node/main/CMakeLists.txt b/firmware/esp32_node/main/CMakeLists.txt new file mode 100644 index 0000000..526b6e7 --- /dev/null +++ b/firmware/esp32_node/main/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "app_main.c" + "panel_driver.c" + INCLUDE_DIRS + "." +) + diff --git a/firmware/esp32_node/main/app_main.c b/firmware/esp32_node/main/app_main.c new file mode 100644 index 0000000..c3af6ea --- /dev/null +++ b/firmware/esp32_node/main/app_main.c @@ -0,0 +1,141 @@ +#include +#include +#include + +#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)); + } +} diff --git a/firmware/esp32_node/main/panel_driver.c b/firmware/esp32_node/main/panel_driver.c new file mode 100644 index 0000000..54142c3 --- /dev/null +++ b/firmware/esp32_node/main/panel_driver.c @@ -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); +} + diff --git a/firmware/esp32_node/main/panel_driver.h b/firmware/esp32_node/main/panel_driver.h new file mode 100644 index 0000000..d50cdc8 --- /dev/null +++ b/firmware/esp32_node/main/panel_driver.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +#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); + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8546a37 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] + diff --git a/web/v1/app.js b/web/v1/app.js new file mode 100644 index 0000000..63fb47b --- /dev/null +++ b/web/v1/app.js @@ -0,0 +1,1212 @@ +(function () { + const apiState = { + stateResponse: null, + previewResponse: null, + catalog: null, + events: [], + ws: null, + commandTimers: new Map(), + controlClient: null, + }; + + const dom = { + projectName: document.getElementById("project-name"), + topologyLabel: document.getElementById("topology-label"), + connectionPill: document.getElementById("connection-pill"), + previewUpdated: document.getElementById("preview-updated"), + refreshButton: document.getElementById("refresh-button"), + controlModePill: document.getElementById("control-mode-pill"), + pendingCommitPill: document.getElementById("pending-commit-pill"), + pendingSessionSummary: document.getElementById("pending-session-summary"), + primitiveErrorBanner: document.getElementById("primitive-error-banner"), + triggerTransitionButton: document.getElementById("trigger-transition-button"), + clearStagedButton: document.getElementById("clear-staged-button"), + patternSelect: document.getElementById("pattern-select"), + transitionSlider: document.getElementById("transition-slider"), + transitionValue: document.getElementById("transition-value"), + transitionStyleSelect: document.getElementById("transition-style-select"), + brightnessSlider: document.getElementById("brightness-slider"), + brightnessValue: document.getElementById("brightness-value"), + blackoutButton: document.getElementById("blackout-button"), + presetList: document.getElementById("preset-list"), + presetIdInput: document.getElementById("preset-id-input"), + presetOverwriteInput: document.getElementById("preset-overwrite-input"), + savePresetButton: document.getElementById("save-preset-button"), + groupFilterInput: document.getElementById("group-filter-input"), + groupList: document.getElementById("group-list"), + snapshotIdInput: document.getElementById("snapshot-id-input"), + snapshotLabelInput: document.getElementById("snapshot-label-input"), + snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"), + saveSnapshotButton: document.getElementById("save-snapshot-button"), + snapshotList: document.getElementById("snapshot-list"), + sceneParams: document.getElementById("scene-params"), + previewGrid: document.getElementById("preview-grid"), + summaryCards: document.getElementById("summary-cards"), + snapshotJson: document.getElementById("snapshot-json"), + eventKindFilter: document.getElementById("event-kind-filter"), + eventSearchFilter: document.getElementById("event-search-filter"), + eventList: document.getElementById("event-list"), + }; + + function init() { + apiState.controlClient = createShowControlClient(); + bindControls(); + refreshAll(); + connectStream(); + } + + function bindControls() { + dom.refreshButton.addEventListener("click", () => refreshAll()); + + dom.patternSelect.addEventListener("change", async (event) => { + await handlePrimitive( + { + primitive: "set_pattern", + payload: { pattern_id: event.target.value }, + }, + { rerenderState: true } + ); + }); + + dom.transitionSlider.addEventListener("input", async (event) => { + const value = Number(event.target.value); + dom.transitionValue.textContent = `${value} ms`; + await handlePrimitive( + { + primitive: "set_transition_style", + payload: { + style: dom.transitionStyleSelect.value, + duration_ms: value, + }, + }, + { announceBuffered: false } + ); + }); + + dom.transitionStyleSelect.addEventListener("change", async (event) => { + await handlePrimitive( + { + primitive: "set_transition_style", + payload: { + style: event.target.value, + duration_ms: Number(dom.transitionSlider.value), + }, + }, + { rerenderState: true } + ); + }); + + dom.brightnessSlider.addEventListener("input", (event) => { + const value = Number(event.target.value); + dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; + debounceCommand("brightness", async () => { + await handlePrimitive({ + primitive: "set_master_brightness", + payload: { value }, + }); + }); + }); + + dom.blackoutButton.addEventListener("click", async () => { + const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false); + await handlePrimitive({ + primitive: "blackout", + payload: { enabled }, + }); + }); + + dom.triggerTransitionButton.addEventListener("click", async () => { + await handlePrimitive({ primitive: "trigger_transition" }, { rerenderState: true }); + }); + + dom.clearStagedButton.addEventListener("click", () => { + apiState.controlClient.clearPending(); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "staged_transition_cleared", + message: "Staged transition buffer cleared.", + }); + renderAll(); + }); + + dom.savePresetButton.addEventListener("click", async () => { + const presetId = dom.presetIdInput.value.trim(); + if (!presetId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "preset_id_required", + message: "Preset ID is required before saving.", + }); + return; + } + + try { + await sendCommand({ + type: "save_preset", + payload: { + preset_id: presetId, + overwrite: dom.presetOverwriteInput.checked, + }, + }); + } catch (error) { + handleClientError(error, "save_preset"); + } + }); + + dom.saveSnapshotButton.addEventListener("click", async () => { + const snapshotId = dom.snapshotIdInput.value.trim(); + if (!snapshotId) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "snapshot_id_required", + message: "Snapshot ID is required before saving a creative variant.", + }); + return; + } + + try { + await sendCommand({ + type: "save_creative_snapshot", + payload: { + snapshot_id: snapshotId, + label: dom.snapshotLabelInput.value.trim() || null, + overwrite: dom.snapshotOverwriteInput.checked, + }, + }); + } catch (error) { + handleClientError(error, "save_creative_snapshot"); + } + }); + + dom.groupFilterInput.addEventListener("input", () => renderGroups(apiState.stateResponse?.state?.global)); + dom.eventKindFilter.addEventListener("change", () => renderEvents()); + dom.eventSearchFilter.addEventListener("input", () => renderEvents()); + } + + async function refreshAll() { + setConnectionState("connecting", "loading"); + try { + const [stateResponse, previewResponse, catalog] = await Promise.all([ + fetchJson("/api/v1/state"), + fetchJson("/api/v1/preview"), + fetchJson("/api/v1/catalog"), + ]); + + apiState.stateResponse = stateResponse; + apiState.previewResponse = previewResponse; + apiState.catalog = catalog; + + renderAll(); + setConnectionState("online", "HTTP sync"); + } catch (error) { + console.error(error); + setConnectionState("offline", "snapshot fetch failed"); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "error", + code: error.code || "http_refresh_failed", + message: `HTTP refresh failed: ${error.message}`, + }); + } + } + + function connectStream() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${protocol}//${window.location.host}/api/v1/stream`; + const socket = new WebSocket(url); + apiState.ws = socket; + + socket.addEventListener("open", () => { + setConnectionState("online", "stream connected"); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "stream_connected", + message: "WebSocket stream connected", + }); + }); + + socket.addEventListener("message", (event) => { + const envelope = JSON.parse(event.data); + handleStreamEnvelope(envelope); + }); + + socket.addEventListener("close", () => { + setConnectionState("offline", "stream disconnected"); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "warning", + code: "stream_reconnect", + message: "WebSocket stream closed, retrying", + }); + window.setTimeout(connectStream, 1500); + }); + + socket.addEventListener("error", () => { + setConnectionState("warning", "stream error"); + }); + } + + function handleStreamEnvelope(envelope) { + const message = envelope.message; + if (!message) { + return; + } + + if (message.type === "snapshot") { + apiState.stateResponse = { + api_version: envelope.api_version, + generated_at_millis: envelope.generated_at_millis, + state: message.payload, + }; + renderState(); + return; + } + + if (message.type === "preview") { + apiState.previewResponse = { + api_version: envelope.api_version, + generated_at_millis: envelope.generated_at_millis, + preview: message.payload, + }; + renderPreview(); + renderSnapshotJson(); + return; + } + + if (message.type === "event") { + pushEvent({ + at: `${envelope.generated_at_millis} ms`, + kind: message.payload.kind || "info", + code: message.payload.code || null, + message: message.payload.message, + }); + } + } + + async function handlePrimitive(primitive, options = {}) { + const { announceBuffered = true, rerenderState = false } = options; + try { + const outcome = await apiState.controlClient.applyPrimitive(primitive); + if (outcome.kind === "buffered" && announceBuffered) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: primitive.primitive, + message: outcome.summary, + }); + } else if (outcome.kind === "command" && outcome.summary) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: primitive.primitive, + message: outcome.summary, + }); + } + + if (rerenderState) { + renderState(); + } else { + renderPendingSession(); + renderSnapshotJson(); + } + return outcome; + } catch (error) { + handleClientError(error, primitive.primitive); + renderPendingSession(); + renderSnapshotJson(); + return null; + } + } + + async function sendCommand(command, options = {}) { + const { announce = true, refresh = true } = options; + const response = await fetchJson("/api/v1/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request_id: `web-${Date.now()}`, + command, + }), + }); + + if (announce) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: response.command_type, + message: response.summary, + }); + } + if (refresh) { + await refreshAll(); + } + return response; + } + + function debounceCommand(key, callback) { + const existing = apiState.commandTimers.get(key); + if (existing) { + window.clearTimeout(existing); + } + const timeoutId = window.setTimeout(async () => { + try { + await callback(); + } finally { + apiState.commandTimers.delete(key); + } + }, 120); + apiState.commandTimers.set(key, timeoutId); + } + + async function fetchJson(url, options) { + const response = await window.fetch(url, options); + const body = await response.text(); + let payload = null; + try { + payload = body ? JSON.parse(body) : null; + } catch (error) { + throw createClientError("invalid_json", `Invalid JSON from ${url}`); + } + + if (!response.ok) { + const code = payload?.error?.code || "request_failed"; + const message = payload?.error?.message || payload?.error || response.statusText; + throw createClientError(code, message); + } + return payload; + } + + function createShowControlClient() { + const urlMode = new URLSearchParams(window.location.search).get("show_control_mode"); + const mode = urlMode === "stateless" ? "stateless" : "stateful"; + const client = { + mode, + lastError: null, + pending: createEmptyPendingState(), + clearPending() { + this.pending = createEmptyPendingState(); + this.lastError = null; + }, + hasPending() { + return hasPendingState(this.pending); + }, + effectiveGroupId(liveGroupId) { + return this.pending.hasGroupTarget ? this.pending.groupId : liveGroupId; + }, + stageGroupTarget(groupId) { + ensureStatefulSession(this); + this.pending.hasGroupTarget = true; + this.pending.groupId = groupId; + this.lastError = null; + return { + kind: "buffered", + summary: `group target staged: ${groupId || "all_panels"}`, + }; + }, + async applyPrimitive(primitive) { + this.lastError = null; + try { + const outcome = await applyPrimitiveWithClient(this, primitive); + this.lastError = null; + return outcome; + } catch (error) { + this.lastError = { + code: error.code || "primitive_failed", + message: error.message, + }; + throw error; + } + }, + }; + return client; + } + + async function applyPrimitiveWithClient(client, primitive) { + switch (primitive.primitive) { + case "blackout": + return { + kind: "direct", + response: await sendCommand({ + type: "set_blackout", + payload: primitive.payload, + }), + }; + case "recall_preset": + return { + kind: "direct", + response: await sendCommand({ + type: "recall_preset", + payload: primitive.payload, + }), + }; + case "recall_creative_snapshot": + return { + kind: "direct", + response: await sendCommand({ + type: "recall_creative_snapshot", + payload: primitive.payload, + }), + }; + case "set_master_brightness": + return { + kind: "direct", + response: await sendCommand({ + type: "set_master_brightness", + payload: primitive.payload, + }), + }; + case "upsert_group": + return { + kind: "direct", + response: await sendCommand({ + type: "upsert_group", + payload: primitive.payload, + }), + }; + case "request_snapshot": + return { + kind: "snapshot", + snapshot: await fetchJson("/api/v1/snapshot"), + }; + case "set_pattern": + ensureStatefulSession(client); + if (!primitive.payload.pattern_id || !primitive.payload.pattern_id.trim()) { + throw createClientError("invalid_pattern_id", "pattern_id must not be empty"); + } + client.pending.patternId = primitive.payload.pattern_id; + return { + kind: "buffered", + summary: `pattern staged: ${primitive.payload.pattern_id}`, + }; + case "set_group_parameter": + ensureStatefulSession(client); + if (!primitive.payload.key || !primitive.payload.key.trim()) { + throw createClientError( + "invalid_group_parameter_key", + "group parameter key must not be empty" + ); + } + client.pending.hasGroupTarget = true; + client.pending.groupId = primitive.payload.group_id ?? null; + client.pending.parameters[primitive.payload.key] = primitive.payload.value; + return { + kind: "buffered", + summary: `group parameter staged: ${primitive.payload.key} for ${primitive.payload.group_id || "all_panels"}`, + }; + case "set_transition_style": + ensureStatefulSession(client); + client.pending.transitionStyle = primitive.payload.style; + if (typeof primitive.payload.duration_ms === "number") { + client.pending.transitionDurationMs = primitive.payload.duration_ms; + } + return { + kind: "buffered", + summary: `transition style staged: ${primitive.payload.style}`, + }; + case "trigger_transition": + ensureStatefulSession(client); + if (!client.pending.patternId) { + throw createClientError( + "transition_pattern_required", + "trigger_transition requires a staged pattern" + ); + } + + if (client.pending.hasGroupTarget) { + await sendCommand( + { + type: "select_group", + payload: { group_id: client.pending.groupId }, + }, + { announce: false, refresh: false } + ); + } + if (client.pending.transitionDurationMs !== null) { + await sendCommand( + { + type: "set_transition_duration_ms", + payload: { duration_ms: client.pending.transitionDurationMs }, + }, + { announce: false, refresh: false } + ); + } + if (client.pending.transitionStyle) { + await sendCommand( + { + type: "set_transition_style", + payload: { style: client.pending.transitionStyle }, + }, + { announce: false, refresh: false } + ); + } + + await sendCommand( + { + type: "select_pattern", + payload: { pattern_id: client.pending.patternId }, + }, + { announce: false, refresh: false } + ); + + const parameterEntries = Object.entries(client.pending.parameters); + for (const [key, value] of parameterEntries) { + await sendCommand( + { + type: "set_scene_parameter", + payload: { key, value }, + }, + { announce: false, refresh: false } + ); + } + + const summary = client.pending.hasGroupTarget + ? `transition triggered: ${client.pending.patternId} on ${client.pending.groupId || "all_panels"}` + : `transition triggered: ${client.pending.patternId}`; + + client.clearPending(); + await refreshAll(); + return { + kind: "command", + summary, + }; + default: + throw createClientError("unknown_primitive", `unknown primitive '${primitive.primitive}'`); + } + } + + function ensureStatefulSession(client) { + if (client.mode !== "stateful") { + throw createClientError( + "show_control_session_required", + "staged show-control primitives require a stateful session" + ); + } + } + + function createEmptyPendingState() { + return { + patternId: null, + hasGroupTarget: false, + groupId: null, + parameters: {}, + transitionStyle: null, + transitionDurationMs: null, + }; + } + + function hasPendingState(pending) { + return Boolean( + pending.patternId || + pending.hasGroupTarget || + Object.keys(pending.parameters).length || + pending.transitionStyle || + pending.transitionDurationMs !== null + ); + } + + function createClientError(code, message) { + const error = new Error(message); + error.code = code; + return error; + } + + function handleClientError(error, fallbackCode) { + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "error", + code: error.code || fallbackCode || "primitive_failed", + message: error.message, + }); + } + + function renderAll() { + renderState(); + renderPreview(); + renderPendingSession(); + renderEvents(); + } + + function renderState() { + const state = apiState.stateResponse?.state; + if (!state) { + renderPendingSession(); + return; + } + + const global = state.global; + const scene = state.active_scene; + const pending = apiState.controlClient.pending; + const displayedPatternId = pending.patternId || global.selected_pattern; + const displayedTransitionStyle = pending.transitionStyle || global.transition_style; + const displayedTransitionDuration = + pending.transitionDurationMs !== null + ? pending.transitionDurationMs + : global.transition_duration_ms; + + dom.projectName.textContent = state.system.project_name; + dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; + + dom.patternSelect.innerHTML = ""; + (apiState.catalog?.patterns || []).forEach((pattern) => { + const option = document.createElement("option"); + option.value = pattern.pattern_id; + option.textContent = `${pattern.display_name} (${pattern.pattern_id})`; + option.selected = pattern.pattern_id === displayedPatternId; + dom.patternSelect.appendChild(option); + }); + dom.patternSelect.value = displayedPatternId; + + dom.transitionSlider.value = String(displayedTransitionDuration); + dom.transitionValue.textContent = `${displayedTransitionDuration} ms`; + dom.transitionStyleSelect.value = displayedTransitionStyle; + dom.brightnessSlider.value = String(global.master_brightness); + dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; + dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; + dom.blackoutButton.classList.toggle("is-active", global.blackout); + + renderPresets(scene); + renderGroups(global); + renderCreativeSnapshots(); + renderSceneParameters(scene, global); + renderSummaryCards(state); + renderPendingSession(); + renderSnapshotJson(); + } + + function renderPendingSession() { + const client = apiState.controlClient; + const pending = client.pending; + + dom.controlModePill.textContent = client.mode; + dom.controlModePill.className = + client.mode === "stateful" ? "pill pill-online" : "pill pill-warning"; + + dom.pendingCommitPill.textContent = client.hasPending() ? "staged" : "idle"; + dom.pendingCommitPill.className = client.hasPending() ? "pill pill-warning" : "pill pill-offline"; + + const cards = []; + if (pending.patternId) { + cards.push(renderPendingCard("Pattern", pending.patternId)); + } + if (pending.hasGroupTarget) { + cards.push(renderPendingCard("Target Group", pending.groupId || "all_panels")); + } + if (pending.transitionStyle || pending.transitionDurationMs !== null) { + const detail = [ + pending.transitionStyle || "inherit", + pending.transitionDurationMs !== null + ? `${pending.transitionDurationMs} ms` + : "duration unchanged", + ].join(" / "); + cards.push(renderPendingCard("Transition", detail)); + } + const parameterKeys = Object.keys(pending.parameters); + if (parameterKeys.length) { + cards.push(renderPendingCard("Parameters", `${parameterKeys.length} staged: ${parameterKeys.join(", ")}`)); + } + + dom.pendingSessionSummary.innerHTML = cards.length + ? cards.join("") + : '

No staged transition yet. Stage pattern, group target, parameters or transition config, then commit explicitly.
'; + + if (client.lastError) { + dom.primitiveErrorBanner.classList.remove("hidden"); + dom.primitiveErrorBanner.innerHTML = ` + ${escapeHtml(client.lastError.code)} + ${escapeHtml(client.lastError.message)} + `; + } else { + dom.primitiveErrorBanner.classList.add("hidden"); + dom.primitiveErrorBanner.innerHTML = ""; + } + + dom.triggerTransitionButton.classList.toggle("staged", client.hasPending()); + dom.clearStagedButton.disabled = !client.hasPending(); + } + + function renderPendingCard(label, detail) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(detail)} +
+ `; + } + + function renderPresets(scene) { + dom.presetList.innerHTML = ""; + const presets = apiState.catalog?.presets || []; + if (!presets.length) { + dom.presetList.innerHTML = '
No presets available.
'; + return; + } + + presets.forEach((preset) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "preset-button"; + button.classList.toggle("active", scene.preset_id === preset.preset_id); + button.innerHTML = ` + ${preset.preset_id} +
${preset.pattern_id} / ${preset.transition_style} / ${preset.source}
+ `; + button.addEventListener("click", async () => { + await handlePrimitive({ + primitive: "recall_preset", + payload: { preset_id: preset.preset_id }, + }); + }); + dom.presetList.appendChild(button); + }); + } + + function renderGroups(global) { + dom.groupList.innerHTML = ""; + if (!global) { + return; + } + const filterValue = dom.groupFilterInput.value.trim().toLowerCase(); + const stagedGroupId = apiState.controlClient.pending.hasGroupTarget + ? apiState.controlClient.pending.groupId + : undefined; + const groups = (apiState.catalog?.groups || []).filter((group) => { + if (!filterValue) { + return true; + } + return ( + group.group_id.toLowerCase().includes(filterValue) || + (group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue)) + ); + }); + + const allButton = document.createElement("button"); + allButton.type = "button"; + allButton.className = "group-button"; + allButton.classList.toggle( + "active", + stagedGroupId !== undefined ? stagedGroupId === null : !global.selected_group + ); + allButton.classList.toggle("staged", stagedGroupId === null); + allButton.innerHTML = + "all_panels
target group for next commit
"; + allButton.addEventListener("click", () => { + try { + const outcome = apiState.controlClient.stageGroupTarget(null); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "stage_group_target", + message: outcome.summary, + }); + renderGroups(global); + renderPendingSession(); + renderSnapshotJson(); + } catch (error) { + apiState.controlClient.lastError = { + code: error.code || "stage_group_target_failed", + message: error.message, + }; + handleClientError(error, "stage_group_target"); + renderPendingSession(); + } + }); + dom.groupList.appendChild(allButton); + + if (!groups.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "No groups match the current filter."; + dom.groupList.appendChild(empty); + return; + } + + groups.forEach((group) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "group-button"; + button.classList.toggle( + "active", + stagedGroupId !== undefined + ? group.group_id === stagedGroupId + : group.group_id === global.selected_group + ); + button.classList.toggle("staged", group.group_id === stagedGroupId); + button.innerHTML = ` + ${group.group_id} +
${group.member_count} members / ${group.source}
+ `; + button.addEventListener("click", () => { + try { + const outcome = apiState.controlClient.stageGroupTarget(group.group_id); + pushEvent({ + at: new Date().toLocaleTimeString(), + kind: "info", + code: "stage_group_target", + message: outcome.summary, + }); + renderGroups(global); + renderPendingSession(); + renderSnapshotJson(); + } catch (error) { + apiState.controlClient.lastError = { + code: error.code || "stage_group_target_failed", + message: error.message, + }; + handleClientError(error, "stage_group_target"); + renderPendingSession(); + } + }); + dom.groupList.appendChild(button); + }); + } + + function renderCreativeSnapshots() { + dom.snapshotList.innerHTML = ""; + const snapshots = apiState.catalog?.creative_snapshots || []; + if (!snapshots.length) { + dom.snapshotList.innerHTML = + '
No creative snapshots saved yet.
'; + return; + } + + snapshots.forEach((snapshot) => { + const card = document.createElement("article"); + card.className = "snapshot-card"; + card.innerHTML = ` +
+
+ ${snapshot.label || snapshot.snapshot_id} +
${snapshot.snapshot_id}
+
+ +
+
+ ${snapshot.pattern_id} + ${snapshot.transition_style} + ${snapshot.transition_duration_ms} ms + ${snapshot.target_group || "all_panels"} +
+ `; + card.querySelector("button").addEventListener("click", async () => { + await handlePrimitive({ + primitive: "recall_creative_snapshot", + payload: { snapshot_id: snapshot.snapshot_id }, + }); + }); + dom.snapshotList.appendChild(card); + }); + } + + function renderSceneParameters(scene, global) { + dom.sceneParams.innerHTML = ""; + const parameters = scene.parameters || []; + if (!parameters.length) { + dom.sceneParams.innerHTML = + '
This pattern has no exposed scene parameters.
'; + return; + } + + parameters.forEach((parameter) => { + const stagedValue = apiState.controlClient.pending.parameters[parameter.key]; + const displayValue = stagedValue || parameter.value; + const card = document.createElement("div"); + card.className = "parameter-card"; + card.classList.toggle("staged", Boolean(stagedValue)); + + if (parameter.kind === "scalar") { + const currentValue = Number(displayValue.value || 0); + card.innerHTML = ` + + `; + const slider = card.querySelector("input"); + const readout = card.querySelector("span:last-of-type"); + slider.addEventListener("input", async (event) => { + const value = Number(event.target.value); + readout.textContent = value.toFixed(2); + await handlePrimitive( + { + primitive: "set_group_parameter", + payload: { + group_id: apiState.controlClient.effectiveGroupId(global.selected_group), + key: parameter.key, + value: { kind: "scalar", value }, + }, + }, + { announceBuffered: false } + ); + }); + } else if (parameter.kind === "toggle") { + const checked = Boolean(displayValue.value); + card.innerHTML = ` + + `; + const checkbox = card.querySelector("input"); + checkbox.addEventListener("change", async (event) => { + await handlePrimitive( + { + primitive: "set_group_parameter", + payload: { + group_id: apiState.controlClient.effectiveGroupId(global.selected_group), + key: parameter.key, + value: { kind: "toggle", value: event.target.checked }, + }, + }, + { announceBuffered: false } + ); + }); + } else { + const currentValue = displayValue.value || ""; + card.innerHTML = ` + + `; + const input = card.querySelector("input"); + input.addEventListener("change", async (event) => { + await handlePrimitive( + { + primitive: "set_group_parameter", + payload: { + group_id: apiState.controlClient.effectiveGroupId(global.selected_group), + key: parameter.key, + value: { kind: "text", value: event.target.value }, + }, + }, + { announceBuffered: false } + ); + }); + } + + dom.sceneParams.appendChild(card); + }); + } + + function renderPreview() { + const preview = apiState.previewResponse?.preview; + dom.previewGrid.innerHTML = ""; + if (!preview?.panels?.length) { + dom.previewGrid.innerHTML = + '
Preview stream is waiting for panel snapshots.
'; + return; + } + + dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`; + const panels = [...preview.panels].sort(comparePreviewPanels); + panels.forEach((panel) => { + const card = document.createElement("article"); + card.className = "preview-card"; + card.style.setProperty("--preview-color", panel.representative_color_hex); + card.innerHTML = ` +
+
+

${panel.node_id}

+
${panel.panel_position} / ${panel.source}
+
+ ${panel.energy_percent}% +
+
+
+
+ ${panel.sample_led_hex + .map( + (hex) => + `` + ) + .join("")} +
+ `; + dom.previewGrid.appendChild(card); + }); + } + + function renderSummaryCards(state) { + const scene = state.active_scene; + const global = state.global; + const engine = state.engine; + const nodeStats = summarizeNodes(state.nodes || []); + const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length; + + const cards = [ + { + label: "Active Pattern", + value: scene.pattern_id, + detail: scene.preset_id ? `Preset ${scene.preset_id}` : "live scene", + }, + { + label: "Group Target", + value: scene.target_group || "all_panels", + detail: `${(apiState.catalog?.groups || []).length} groups available`, + }, + { + label: "Transition", + value: `${global.transition_style} / ${global.transition_duration_ms} ms`, + detail: engine.active_transition + ? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%` + : "idle", + }, + { + label: "Brightness", + value: `${Math.round(global.master_brightness * 100)}%`, + detail: global.blackout ? "blackout active" : "output live", + }, + { + label: "Engine", + value: `${engine.frame_hz} fps target`, + detail: `${engine.logic_hz} hz logic / frame ${engine.frame_index}`, + }, + { + label: "Nodes", + value: `${nodeStats.online}/${state.nodes.length} online`, + detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, + }, + { + label: "Creative Snapshots", + value: `${creativeSnapshotCount}`, + detail: `${(apiState.catalog?.presets || []).length} presets in library`, + }, + ]; + + dom.summaryCards.innerHTML = cards + .map( + (card) => ` +
+ ${card.value} + ${card.label} +
${card.detail}
+
+ ` + ) + .join(""); + } + + function renderSnapshotJson() { + dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2); + } + + function buildComposedSnapshot() { + return { + api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1", + generated_at_millis: + apiState.previewResponse?.generated_at_millis || + apiState.stateResponse?.generated_at_millis || + 0, + state: apiState.stateResponse?.state || null, + preview: apiState.previewResponse?.preview || null, + catalog: apiState.catalog || null, + show_control_client: { + mode: apiState.controlClient.mode, + pending: apiState.controlClient.pending, + last_error: apiState.controlClient.lastError, + }, + }; + } + + function pushEvent(entry) { + apiState.events.unshift({ + kind: entry.kind || "info", + code: entry.code || null, + ...entry, + }); + apiState.events = apiState.events.slice(0, 50); + renderEvents(); + } + + function renderEvents() { + const kindFilter = dom.eventKindFilter.value; + const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase(); + const filtered = apiState.events.filter((entry) => { + const kindMatches = kindFilter === "all" || entry.kind === kindFilter; + const searchMatches = + !searchFilter || + (entry.message || "").toLowerCase().includes(searchFilter) || + (entry.code || "").toLowerCase().includes(searchFilter); + return kindMatches && searchMatches; + }); + + if (!filtered.length) { + dom.eventList.innerHTML = '
No events match the current filter.
'; + return; + } + + dom.eventList.innerHTML = filtered + .map( + (entry) => ` +
+
${entry.at}
+ ${entry.code ? `${entry.code}` : ""} + ${entry.message} +
+ ` + ) + .join(""); + } + + function setConnectionState(kind, message) { + dom.connectionPill.textContent = message; + dom.connectionPill.className = + kind === "online" + ? "pill pill-online" + : kind === "warning" + ? "pill pill-warning" + : "pill pill-offline"; + } + + function summarizeNodes(nodes) { + return nodes.reduce( + (summary, node) => { + summary[node.connection] += 1; + return summary; + }, + { online: 0, degraded: 0, offline: 0 } + ); + } + + function comparePreviewPanels(left, right) { + const leftNode = left.node_id.localeCompare(right.node_id); + if (leftNode !== 0) { + return leftNode; + } + return panelPositionRank(left.panel_position) - panelPositionRank(right.panel_position); + } + + function panelPositionRank(position) { + if (position === "top") { + return 0; + } + if (position === "middle") { + return 1; + } + return 2; + } + + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + } + + init(); +})(); diff --git a/web/v1/index.html b/web/v1/index.html new file mode 100644 index 0000000..20d5ce9 --- /dev/null +++ b/web/v1/index.html @@ -0,0 +1,224 @@ + + + + + + Infinity Vis Creative Console + + + +
+
+
+

Infinity Vis / Creative Surface

+

Loading project...

+

+ Shared host API bootstrap in progress. +

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

Global Look

+

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

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

Pending Transition

+

Stage primitives locally and commit them with one explicit trigger.

+
+
+
+
+ Control mode + stateful +
+
+ Commit state + idle +
+
+
+ +
+ + +
+
+
+ +
+
+

Presets

+

Recall look snapshots without leaving the creative console.

+
+
+
+ +
+
+

Preset Capture

+

Store or overwrite the current scene as a reusable preset through the same API.

+
+
+ + + +
+
+ +
+
+

Groups

+

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

+
+ +
+
+ +
+
+

Creative Snapshots

+

Capture exploratory variants without replacing curated presets.

+
+
+ + + + +
+
+
+ +
+
+

Scene Parameters

+

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

+
+
+
+
+ +
+
+

Preview

+

Live panel previews from the host snapshot and stream feed.

+
+
+
+ +
+
+

Snapshot

+

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

+
+
+

+        
+ +
+
+

Event Stream

+

Recent notices from the websocket feed.

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