From 557cceb4988e9aafc3fee61fff570e2608bd01a1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 18 Jun 2026 21:43:59 +0200 Subject: [PATCH] add all crates: domain, protocol, application, client, adapters, ESP32 firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: domain (entities, value objects, ports), protocol (postcard wire types), application (config service, data projection), adapters (config-memory, tcp-server), bootstrap (composition root with fake data). Client: client-domain (layout engine, render tree, HAL ports), client-application (message handling, repaint commands), adapters (tcp-client, display-terminal), client-desktop (end-to-end working). ESP32: client-esp32 firmware with ILI9341 display over SPI, WiFi networking. Display test verified on hardware — landscape orientation, text rendering works. 60 workspace tests, all passing. --- .gitignore | 1 + Cargo.lock | 300 +++ Cargo.toml | 29 +- crates/adapters/config-memory/Cargo.toml | 7 + crates/adapters/config-memory/src/lib.rs | 119 ++ crates/adapters/display-terminal/Cargo.toml | 7 + crates/adapters/display-terminal/src/lib.rs | 38 + crates/adapters/tcp-client/Cargo.toml | 8 + crates/adapters/tcp-client/src/lib.rs | 82 + crates/adapters/tcp-server/Cargo.toml | 10 + crates/adapters/tcp-server/src/lib.rs | 150 ++ crates/application/Cargo.toml | 10 + crates/application/src/config_service.rs | 118 ++ crates/application/src/data_projection.rs | 42 + crates/application/src/lib.rs | 5 + .../application/tests/config_service_tests.rs | 151 ++ .../tests/data_projection_tests.rs | 82 + crates/application/tests/support/mod.rs | 126 ++ crates/bootstrap/Cargo.toml | 12 + crates/bootstrap/src/main.rs | 94 + crates/client-application/Cargo.toml | 11 + crates/client-application/src/client_app.rs | 110 + crates/client-application/src/lib.rs | 3 + .../tests/client_app_tests.rs | 154 ++ crates/client-desktop/Cargo.toml | 12 + crates/client-desktop/src/main.rs | 85 + crates/client-domain/Cargo.toml | 9 + crates/client-domain/src/bounding_box.rs | 17 + crates/client-domain/src/layout_engine.rs | 96 + crates/client-domain/src/lib.rs | 9 + crates/client-domain/src/ports/display.rs | 11 + crates/client-domain/src/ports/mod.rs | 7 + crates/client-domain/src/ports/network.rs | 9 + crates/client-domain/src/ports/storage.rs | 11 + crates/client-domain/src/render_tree.rs | 29 + .../tests/layout_engine_tests.rs | 151 ++ .../client-domain/tests/render_tree_tests.rs | 85 + crates/client-esp32/.cargo/config.toml | 13 + crates/client-esp32/.gitignore | 2 + crates/client-esp32/Cargo.lock | 1820 +++++++++++++++++ crates/client-esp32/Cargo.toml | 30 + crates/client-esp32/build.rs | 3 + crates/client-esp32/rust-toolchain.toml | 2 + crates/client-esp32/sdkconfig.defaults | 16 + crates/client-esp32/src/adapters/display.rs | 124 ++ crates/client-esp32/src/adapters/mod.rs | 2 + crates/client-esp32/src/adapters/network.rs | 85 + crates/client-esp32/src/config.rs | 22 + crates/client-esp32/src/hal/display.rs | 58 + crates/client-esp32/src/hal/mod.rs | 2 + crates/client-esp32/src/hal/wifi.rs | 38 + crates/client-esp32/src/main.rs | 45 + crates/client-esp32/src/tasks/mod.rs | 2 + crates/client-esp32/src/tasks/network.rs | 50 + crates/client-esp32/src/tasks/render.rs | 46 + crates/domain/Cargo.toml | 8 + crates/domain/src/entities/data_source.rs | 69 + crates/domain/src/entities/layout_preset.rs | 10 + crates/domain/src/entities/mod.rs | 7 + crates/domain/src/entities/widget_config.rs | 70 + crates/domain/src/events/mod.rs | 16 + crates/domain/src/lib.rs | 19 + crates/domain/src/ports/broadcast.rs | 17 + crates/domain/src/ports/config_repository.rs | 26 + crates/domain/src/ports/data_source_port.rs | 8 + crates/domain/src/ports/event.rs | 7 + crates/domain/src/ports/mod.rs | 9 + .../domain/src/value_objects/key_mapping.rs | 14 + crates/domain/src/value_objects/layout.rs | 92 + crates/domain/src/value_objects/mod.rs | 11 + crates/domain/src/value_objects/value.rs | 58 + .../domain/src/value_objects/widget_state.rs | 21 + crates/domain/tests/data_source_tests.rs | 51 + crates/domain/tests/key_mapping_tests.rs | 33 + crates/domain/tests/layout_tests.rs | 66 + crates/domain/tests/value_tests.rs | 77 + crates/domain/tests/widget_tests.rs | 110 + crates/protocol/Cargo.toml | 11 + crates/protocol/src/frame.rs | 54 + crates/protocol/src/lib.rs | 13 + crates/protocol/src/wire.rs | 232 +++ crates/protocol/tests/conversion_tests.rs | 82 + crates/protocol/tests/round_trip_tests.rs | 94 + 83 files changed, 5844 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 crates/adapters/config-memory/Cargo.toml create mode 100644 crates/adapters/config-memory/src/lib.rs create mode 100644 crates/adapters/display-terminal/Cargo.toml create mode 100644 crates/adapters/display-terminal/src/lib.rs create mode 100644 crates/adapters/tcp-client/Cargo.toml create mode 100644 crates/adapters/tcp-client/src/lib.rs create mode 100644 crates/adapters/tcp-server/Cargo.toml create mode 100644 crates/adapters/tcp-server/src/lib.rs create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/config_service.rs create mode 100644 crates/application/src/data_projection.rs create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/tests/config_service_tests.rs create mode 100644 crates/application/tests/data_projection_tests.rs create mode 100644 crates/application/tests/support/mod.rs create mode 100644 crates/bootstrap/Cargo.toml create mode 100644 crates/bootstrap/src/main.rs create mode 100644 crates/client-application/Cargo.toml create mode 100644 crates/client-application/src/client_app.rs create mode 100644 crates/client-application/src/lib.rs create mode 100644 crates/client-application/tests/client_app_tests.rs create mode 100644 crates/client-desktop/Cargo.toml create mode 100644 crates/client-desktop/src/main.rs create mode 100644 crates/client-domain/Cargo.toml create mode 100644 crates/client-domain/src/bounding_box.rs create mode 100644 crates/client-domain/src/layout_engine.rs create mode 100644 crates/client-domain/src/lib.rs create mode 100644 crates/client-domain/src/ports/display.rs create mode 100644 crates/client-domain/src/ports/mod.rs create mode 100644 crates/client-domain/src/ports/network.rs create mode 100644 crates/client-domain/src/ports/storage.rs create mode 100644 crates/client-domain/src/render_tree.rs create mode 100644 crates/client-domain/tests/layout_engine_tests.rs create mode 100644 crates/client-domain/tests/render_tree_tests.rs create mode 100644 crates/client-esp32/.cargo/config.toml create mode 100644 crates/client-esp32/.gitignore create mode 100644 crates/client-esp32/Cargo.lock create mode 100644 crates/client-esp32/Cargo.toml create mode 100644 crates/client-esp32/build.rs create mode 100644 crates/client-esp32/rust-toolchain.toml create mode 100644 crates/client-esp32/sdkconfig.defaults create mode 100644 crates/client-esp32/src/adapters/display.rs create mode 100644 crates/client-esp32/src/adapters/mod.rs create mode 100644 crates/client-esp32/src/adapters/network.rs create mode 100644 crates/client-esp32/src/config.rs create mode 100644 crates/client-esp32/src/hal/display.rs create mode 100644 crates/client-esp32/src/hal/mod.rs create mode 100644 crates/client-esp32/src/hal/wifi.rs create mode 100644 crates/client-esp32/src/main.rs create mode 100644 crates/client-esp32/src/tasks/mod.rs create mode 100644 crates/client-esp32/src/tasks/network.rs create mode 100644 crates/client-esp32/src/tasks/render.rs create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/entities/data_source.rs create mode 100644 crates/domain/src/entities/layout_preset.rs create mode 100644 crates/domain/src/entities/mod.rs create mode 100644 crates/domain/src/entities/widget_config.rs create mode 100644 crates/domain/src/events/mod.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/ports/broadcast.rs create mode 100644 crates/domain/src/ports/config_repository.rs create mode 100644 crates/domain/src/ports/data_source_port.rs create mode 100644 crates/domain/src/ports/event.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/value_objects/key_mapping.rs create mode 100644 crates/domain/src/value_objects/layout.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/value.rs create mode 100644 crates/domain/src/value_objects/widget_state.rs create mode 100644 crates/domain/tests/data_source_tests.rs create mode 100644 crates/domain/tests/key_mapping_tests.rs create mode 100644 crates/domain/tests/layout_tests.rs create mode 100644 crates/domain/tests/value_tests.rs create mode 100644 crates/domain/tests/widget_tests.rs create mode 100644 crates/protocol/Cargo.toml create mode 100644 crates/protocol/src/frame.rs create mode 100644 crates/protocol/src/lib.rs create mode 100644 crates/protocol/src/wire.rs create mode 100644 crates/protocol/tests/conversion_tests.rs create mode 100644 crates/protocol/tests/round_trip_tests.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad0fedf --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,300 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "domain", + "tokio", +] + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "application", + "config-memory", + "domain", + "protocol", + "tcp-server", + "tokio", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "client-application" +version = "0.1.0" +dependencies = [ + "client-domain", + "domain", + "protocol", +] + +[[package]] +name = "client-desktop" +version = "0.1.0" +dependencies = [ + "client-application", + "client-domain", + "display-terminal", + "domain", + "protocol", + "tcp-client", +] + +[[package]] +name = "client-domain" +version = "0.1.0" +dependencies = [ + "domain", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "config-memory" +version = "0.1.0" +dependencies = [ + "domain", +] + +[[package]] +name = "display-terminal" +version = "0.1.0" +dependencies = [ + "client-domain", +] + +[[package]] +name = "domain" +version = "0.1.0" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[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 = "protocol" +version = "0.1.0" +dependencies = [ + "domain", + "postcard", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tcp-client" +version = "0.1.0" +dependencies = [ + "client-domain", + "protocol", +] + +[[package]] +name = "tcp-server" +version = "0.1.0" +dependencies = [ + "domain", + "postcard", + "protocol", + "tokio", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 1356cd7..cfcf118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,32 @@ [workspace] -members = [] +members = [ + "crates/domain", + "crates/protocol", + "crates/application", + "crates/client-domain", + "crates/client-application", + "crates/adapters/config-memory", + "crates/adapters/tcp-server", + "crates/adapters/tcp-client", + "crates/adapters/display-terminal", + "crates/bootstrap", + "crates/client-desktop", +] +exclude = [ + "crates/client-esp32", +] resolver = "2" [workspace.dependencies] +domain = { path = "crates/domain" } +protocol = { path = "crates/protocol" } +application = { path = "crates/application" } +client-domain = { path = "crates/client-domain" } +client-application = { path = "crates/client-application" } +config-memory = { path = "crates/adapters/config-memory" } +tcp-server = { path = "crates/adapters/tcp-server" } +tcp-client = { path = "crates/adapters/tcp-client" } +display-terminal = { path = "crates/adapters/display-terminal" } +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +postcard = { version = "1.1", default-features = false, features = ["alloc"] } +tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } diff --git a/crates/adapters/config-memory/Cargo.toml b/crates/adapters/config-memory/Cargo.toml new file mode 100644 index 0000000..dd494db --- /dev/null +++ b/crates/adapters/config-memory/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "config-memory" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true diff --git a/crates/adapters/config-memory/src/lib.rs b/crates/adapters/config-memory/src/lib.rs new file mode 100644 index 0000000..f2c9d0c --- /dev/null +++ b/crates/adapters/config-memory/src/lib.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use domain::{ + ConfigRepository, + DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, + WidgetConfig, WidgetId, +}; + +#[derive(Debug)] +pub enum MemoryConfigError { + LockPoisoned, +} + +impl std::fmt::Display for MemoryConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MemoryConfigError::LockPoisoned => write!(f, "lock poisoned"), + } + } +} + +pub struct MemoryConfigStore { + widgets: RwLock>, + data_sources: RwLock>, + layout: RwLock>, + presets: RwLock>, +} + +impl MemoryConfigStore { + pub fn new() -> Self { + Self { + widgets: RwLock::new(HashMap::new()), + data_sources: RwLock::new(HashMap::new()), + layout: RwLock::new(None), + presets: RwLock::new(HashMap::new()), + } + } +} + +impl ConfigRepository for MemoryConfigStore { + type Error = MemoryConfigError; + + async fn get_widget(&self, id: WidgetId) -> Result, Self::Error> { + let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.get(&id).cloned()) + } + + async fn list_widgets(&self) -> Result, Self::Error> { + let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.values().cloned().collect()) + } + + async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> { + let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.insert(config.id, config.clone()); + Ok(()) + } + + async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> { + let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.remove(&id); + Ok(()) + } + + async fn get_data_source(&self, id: DataSourceId) -> Result, Self::Error> { + let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.get(&id).cloned()) + } + + async fn list_data_sources(&self) -> Result, Self::Error> { + let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.values().cloned().collect()) + } + + async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> { + let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.insert(source.id, source.clone()); + Ok(()) + } + + async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> { + let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.remove(&id); + Ok(()) + } + + async fn get_layout(&self) -> Result, Self::Error> { + let guard = self.layout.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.clone()) + } + + async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> { + let mut guard = self.layout.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + *guard = Some(layout.clone()); + Ok(()) + } + + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error> { + let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.get(&id).cloned()) + } + + async fn list_presets(&self) -> Result, Self::Error> { + let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.values().cloned().collect()) + } + + async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> { + let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.insert(preset.id, preset.clone()); + Ok(()) + } + + async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { + let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.remove(&id); + Ok(()) + } +} diff --git a/crates/adapters/display-terminal/Cargo.toml b/crates/adapters/display-terminal/Cargo.toml new file mode 100644 index 0000000..66f7060 --- /dev/null +++ b/crates/adapters/display-terminal/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "display-terminal" +version = "0.1.0" +edition = "2024" + +[dependencies] +client-domain.workspace = true diff --git a/crates/adapters/display-terminal/src/lib.rs b/crates/adapters/display-terminal/src/lib.rs new file mode 100644 index 0000000..5d65bdc --- /dev/null +++ b/crates/adapters/display-terminal/src/lib.rs @@ -0,0 +1,38 @@ +use client_domain::{BoundingBox, DisplayPort}; + +pub struct TerminalDisplay; + +impl TerminalDisplay { + pub fn new() -> Self { + Self + } +} + +impl DisplayPort for TerminalDisplay { + type Error = std::io::Error; + + fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> { + println!("[CLEAR] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height); + Ok(()) + } + + fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> { + println!("[TEXT] ({x}, {y}) in {}x{}: \"{text}\"", bounds.width, bounds.height); + Ok(()) + } + + fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> { + println!("[ICON] ({x}, {y}): {icon}"); + Ok(()) + } + + fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> { + println!("[BG] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height); + Ok(()) + } + + fn flush(&mut self) -> Result<(), Self::Error> { + println!("[FLUSH]"); + Ok(()) + } +} diff --git a/crates/adapters/tcp-client/Cargo.toml b/crates/adapters/tcp-client/Cargo.toml new file mode 100644 index 0000000..d157cb3 --- /dev/null +++ b/crates/adapters/tcp-client/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tcp-client" +version = "0.1.0" +edition = "2024" + +[dependencies] +client-domain.workspace = true +protocol.workspace = true diff --git a/crates/adapters/tcp-client/src/lib.rs b/crates/adapters/tcp-client/src/lib.rs new file mode 100644 index 0000000..e9d06cd --- /dev/null +++ b/crates/adapters/tcp-client/src/lib.rs @@ -0,0 +1,82 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; +use client_domain::NetworkPort; +use protocol::MAX_FRAME_SIZE; + +#[derive(Debug)] +pub enum TcpClientError { + Io(std::io::Error), + NotConnected, + FrameTooLarge(usize), +} + +impl std::fmt::Display for TcpClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TcpClientError::Io(e) => write!(f, "io: {e}"), + TcpClientError::NotConnected => write!(f, "not connected"), + TcpClientError::FrameTooLarge(n) => write!(f, "frame too large: {n}"), + } + } +} + +pub struct StdTcpClient { + stream: Option, +} + +impl StdTcpClient { + pub fn new() -> Self { + Self { stream: None } + } +} + +impl NetworkPort for StdTcpClient { + type Error = TcpClientError; + + fn connect(&mut self, addr: &str) -> Result<(), Self::Error> { + let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?; + stream.set_nonblocking(true).map_err(TcpClientError::Io)?; + stream.set_read_timeout(Some(Duration::from_millis(10))).map_err(TcpClientError::Io)?; + self.stream = Some(stream); + Ok(()) + } + + fn disconnect(&mut self) -> Result<(), Self::Error> { + self.stream = None; + Ok(()) + } + + fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> { + let stream = self.stream.as_mut().ok_or(TcpClientError::NotConnected)?; + stream.write_all(data).map_err(TcpClientError::Io) + } + + fn receive(&mut self) -> Result>, Self::Error> { + let stream = self.stream.as_mut().ok_or(TcpClientError::NotConnected)?; + + let mut len_buf = [0u8; 4]; + match stream.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => return Ok(None), + Err(e) => return Err(TcpClientError::Io(e)), + } + + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME_SIZE { + return Err(TcpClientError::FrameTooLarge(len)); + } + + let mut payload = vec![0u8; len]; + stream.set_nonblocking(false).map_err(TcpClientError::Io)?; + stream.read_exact(&mut payload).map_err(TcpClientError::Io)?; + stream.set_nonblocking(true).map_err(TcpClientError::Io)?; + + Ok(Some(payload)) + } + + fn is_connected(&self) -> bool { + self.stream.is_some() + } +} diff --git a/crates/adapters/tcp-server/Cargo.toml b/crates/adapters/tcp-server/Cargo.toml new file mode 100644 index 0000000..73a7365 --- /dev/null +++ b/crates/adapters/tcp-server/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tcp-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +protocol.workspace = true +tokio.workspace = true +postcard.workspace = true diff --git a/crates/adapters/tcp-server/src/lib.rs b/crates/adapters/tcp-server/src/lib.rs new file mode 100644 index 0000000..84fbc89 --- /dev/null +++ b/crates/adapters/tcp-server/src/lib.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::broadcast; +use tokio::io::AsyncWriteExt; +use domain::{ + BroadcastPort, EventPublisher, DomainEvent, + Layout, WidgetId, WidgetState, +}; +use protocol::{ + ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, + encode, +}; + +#[derive(Debug)] +pub enum TcpServerError { + Io(std::io::Error), + Encode(postcard::Error), +} + +impl std::fmt::Display for TcpServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TcpServerError::Io(e) => write!(f, "io: {e}"), + TcpServerError::Encode(e) => write!(f, "encode: {e}"), + } + } +} + +pub struct TcpBroadcaster { + tx: broadcast::Sender>, +} + +impl TcpBroadcaster { + pub fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver> { + self.tx.subscribe() + } + + fn send_frame(&self, frame: Vec) -> Result<(), TcpServerError> { + let _ = self.tx.send(frame); + Ok(()) + } +} + +impl BroadcastPort for TcpBroadcaster { + type Error = TcpServerError; + + async fn push_screen_update( + &self, + layout: &Layout, + widgets: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error> { + let wire_layout: WireLayoutNode = (&layout.root).into(); + let wire_widgets: Vec = widgets.iter().map(|(id, state)| { + WidgetDescriptor { + id: *id, + display_hint: WireDisplayHint::IconValue, + state: state.into(), + } + }).collect(); + + let msg = ServerMessage::ScreenUpdate { + layout: wire_layout, + widgets: wire_widgets, + }; + + let frame = encode(&msg).map_err(TcpServerError::Encode)?; + self.send_frame(frame) + } + + async fn push_data_update( + &self, + updates: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error> { + let wire_widgets: Vec = updates.iter().map(|(id, state)| { + WidgetDescriptor { + id: *id, + display_hint: WireDisplayHint::IconValue, + state: state.into(), + } + }).collect(); + + let msg = ServerMessage::DataUpdate { + widgets: wire_widgets, + }; + + let frame = encode(&msg).map_err(TcpServerError::Encode)?; + self.send_frame(frame) + } +} + +pub struct TcpEventBus { + tx: broadcast::Sender, +} + +impl TcpEventBus { + pub fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +impl EventPublisher for TcpEventBus { + type Error = TcpServerError; + + async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> { + let _ = self.tx.send(event); + Ok(()) + } +} + +pub async fn run_tcp_server( + addr: &str, + broadcaster: Arc, +) -> Result<(), TcpServerError> { + let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?; + println!("TCP server listening on {addr}"); + + loop { + let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?; + println!("Client connected: {peer}"); + + let mut rx = broadcaster.subscribe(); + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(frame) => { + if socket.write_all(&frame).await.is_err() { + println!("Client disconnected: {peer}"); + break; + } + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(n)) => { + println!("Client {peer} lagged by {n} messages"); + } + } + } + }); + } +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..10084c2 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/application/src/config_service.rs b/crates/application/src/config_service.rs new file mode 100644 index 0000000..918b868 --- /dev/null +++ b/crates/application/src/config_service.rs @@ -0,0 +1,118 @@ +use std::fmt; +use domain::{ + ConfigRepository, EventPublisher, DomainEvent, + WidgetConfig, WidgetId, + DataSource, DataSourceId, DataSourceValidationError, + Layout, LayoutPreset, LayoutPresetId, +}; + +pub struct ConfigService<'a, C, E> { + config: &'a C, + events: &'a E, +} + +#[derive(Debug)] +pub enum ConfigError { + Repository(C), + Event(E), + Validation(Vec), + NotFound, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::Repository(e) => write!(f, "repository error: {:?}", e), + ConfigError::Event(e) => write!(f, "event error: {:?}", e), + ConfigError::Validation(errors) => write!(f, "validation errors: {:?}", errors), + ConfigError::NotFound => write!(f, "not found"), + } + } +} + +impl<'a, C, E> ConfigService<'a, C, E> +where + C: ConfigRepository, + C::Error: fmt::Debug, + E: EventPublisher, + E::Error: fmt::Debug, +{ + pub fn new(config: &'a C, events: &'a E) -> Self { + Self { config, events } + } + + pub async fn create_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError> { + self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::WidgetCreated { id: widget.id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn update_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError> { + self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::WidgetUpdated { id: widget.id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError> { + self.config.delete_widget(id).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::WidgetDeleted { id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn create_data_source(&self, source: DataSource) -> Result<(), ConfigError> { + let errors = source.validate(); + if !errors.is_empty() { + return Err(ConfigError::Validation(errors)); + } + self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::DataSourceAdded { id: source.id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn update_data_source(&self, source: DataSource) -> Result<(), ConfigError> { + let errors = source.validate(); + if !errors.is_empty() { + return Err(ConfigError::Validation(errors)); + } + self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::DataSourceUpdated { id: source.id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn delete_data_source(&self, id: DataSourceId) -> Result<(), ConfigError> { + self.config.delete_data_source(id).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::DataSourceRemoved { id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn update_layout(&self, layout: Layout) -> Result<(), ConfigError> { + self.config.save_layout(&layout).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::LayoutChanged { layout }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn save_preset(&self, preset: LayoutPreset) -> Result<(), ConfigError> { + self.config.save_preset(&preset).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::LayoutPresetSaved { id: preset.id }).await.map_err(ConfigError::Event)?; + Ok(()) + } + + pub async fn load_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError> { + let preset = self.config.get_preset(id).await + .map_err(ConfigError::Repository)? + .ok_or(ConfigError::NotFound)?; + + self.events.publish(DomainEvent::LayoutPresetLoaded { id }).await.map_err(ConfigError::Event)?; + + self.config.save_layout(&preset.layout).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::LayoutChanged { layout: preset.layout }).await.map_err(ConfigError::Event)?; + + Ok(()) + } + + pub async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError> { + self.config.delete_preset(id).await.map_err(ConfigError::Repository)?; + self.events.publish(DomainEvent::LayoutPresetDeleted { id }).await.map_err(ConfigError::Event)?; + Ok(()) + } +} diff --git a/crates/application/src/data_projection.rs b/crates/application/src/data_projection.rs new file mode 100644 index 0000000..ce0c894 --- /dev/null +++ b/crates/application/src/data_projection.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; +use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState}; + +pub struct DataProjection { + current: HashMap, +} + +impl DataProjection { + pub fn new() -> Self { + Self { + current: HashMap::new(), + } + } + + pub fn apply_poll_result( + &mut self, + data_source_id: DataSourceId, + raw: &Value, + widget_configs: &[WidgetConfig], + ) -> Vec<(WidgetId, WidgetState)> { + let mut changed = Vec::new(); + + for config in widget_configs { + if config.data_source_id != data_source_id { + continue; + } + + let new_state = config.extract(raw); + + let is_changed = self.current + .get(&config.id) + .map_or(true, |prev| *prev != new_state); + + if is_changed { + self.current.insert(config.id, new_state.clone()); + changed.push((config.id, new_state)); + } + } + + changed + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..1e2cd82 --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,5 @@ +mod config_service; +mod data_projection; + +pub use config_service::ConfigService; +pub use data_projection::DataProjection; diff --git a/crates/application/tests/config_service_tests.rs b/crates/application/tests/config_service_tests.rs new file mode 100644 index 0000000..304c7dd --- /dev/null +++ b/crates/application/tests/config_service_tests.rs @@ -0,0 +1,151 @@ +mod support; + +use std::time::Duration; +use domain::{ + ConfigRepository, DisplayHint, DomainEvent, KeyMapping, WidgetConfig, + DataSource, DataSourceConfig, DataSourceType, + Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing, + LayoutPreset, +}; +use application::ConfigService; +use support::{InMemoryConfigRepository, InMemoryEventPublisher}; + +#[tokio::test] +async fn create_widget_persists_and_emits_event() { + let repo = InMemoryConfigRepository::new(); + let events = InMemoryEventPublisher::new(); + let service = ConfigService::new(&repo, &events); + + let config = WidgetConfig::new( + 1, + "weather".into(), + DisplayHint::IconValue, + 1, + vec![ + KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, + ], + ); + + service.create_widget(config).await.unwrap(); + + let stored = repo.get_widget(1).await.unwrap(); + assert!(stored.is_some()); + + let emitted = events.emitted(); + assert_eq!(emitted.len(), 1); + assert!(matches!(emitted[0], DomainEvent::WidgetCreated { id: 1 })); +} + +#[tokio::test] +async fn create_data_source_rejects_invalid() { + let repo = InMemoryConfigRepository::new(); + let events = InMemoryEventPublisher::new(); + let service = ConfigService::new(&repo, &events); + + let source = DataSource { + id: 1, + name: "bad".into(), + source_type: DataSourceType::HttpJson, + poll_interval: Duration::from_secs(60), + config: DataSourceConfig { + url: None, + headers: vec![], + api_key: None, + }, + }; + + let result = service.create_data_source(source).await; + assert!(result.is_err()); + assert!(events.emitted().is_empty()); +} + +#[tokio::test] +async fn create_data_source_persists_valid_and_emits_event() { + let repo = InMemoryConfigRepository::new(); + let events = InMemoryEventPublisher::new(); + let service = ConfigService::new(&repo, &events); + + let source = DataSource { + id: 1, + name: "weather".into(), + source_type: DataSourceType::Weather, + poll_interval: Duration::from_secs(300), + config: DataSourceConfig { + url: Some("https://api.weather.com".into()), + headers: vec![], + api_key: None, + }, + }; + + service.create_data_source(source).await.unwrap(); + + let stored = repo.get_data_source(1).await.unwrap(); + assert!(stored.is_some()); + + let emitted = events.emitted(); + assert_eq!(emitted.len(), 1); + assert!(matches!(emitted[0], DomainEvent::DataSourceAdded { id: 1 })); +} + +#[tokio::test] +async fn update_layout_persists_and_emits_event() { + let repo = InMemoryConfigRepository::new(); + let events = InMemoryEventPublisher::new(); + let service = ConfigService::new(&repo, &events); + + let layout = Layout { + root: LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 4, + padding: 2, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }), + }; + + service.update_layout(layout.clone()).await.unwrap(); + + let stored = repo.get_layout().await.unwrap(); + assert_eq!(stored, Some(layout)); + + assert_eq!(events.emitted().len(), 1); + assert!(matches!(events.emitted()[0], DomainEvent::LayoutChanged { .. })); +} + +#[tokio::test] +async fn load_preset_replaces_active_layout() { + let repo = InMemoryConfigRepository::new(); + let events = InMemoryEventPublisher::new(); + let service = ConfigService::new(&repo, &events); + + let preset_layout = Layout { + root: LayoutNode::Container(ContainerNode { + direction: Direction::Column, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(5) }, + ], + }), + }; + + let preset = LayoutPreset { + id: 1, + name: "vertical".into(), + layout: preset_layout.clone(), + }; + + repo.save_preset(&preset).await.unwrap(); + + service.load_preset(1).await.unwrap(); + + let stored = repo.get_layout().await.unwrap(); + assert_eq!(stored, Some(preset_layout)); + + let emitted = events.emitted(); + assert_eq!(emitted.len(), 2); + assert!(matches!(emitted[0], DomainEvent::LayoutPresetLoaded { id: 1 })); + assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. })); +} diff --git a/crates/application/tests/data_projection_tests.rs b/crates/application/tests/data_projection_tests.rs new file mode 100644 index 0000000..4797a6a --- /dev/null +++ b/crates/application/tests/data_projection_tests.rs @@ -0,0 +1,82 @@ +use std::collections::BTreeMap; +use domain::{ + DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState, +}; +use application::DataProjection; + +fn weather_widget() -> WidgetConfig { + WidgetConfig::new( + 1, + "weather".into(), + DisplayHint::IconValue, + 10, + vec![ + KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, + KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() }, + ], + ) +} + +fn weather_response(temp: f64) -> Value { + Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(temp)), + ("icon".into(), Value::String("sunny".into())), + ])) +} + +#[test] +fn apply_poll_result_detects_new_widget_state() { + let mut projection = DataProjection::new(); + let widgets = vec![weather_widget()]; + + let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); + + assert_eq!(changed.len(), 1); + assert_eq!(changed[0].0, 1); + assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(5.4))); +} + +#[test] +fn apply_poll_result_returns_empty_when_nothing_changed() { + let mut projection = DataProjection::new(); + let widgets = vec![weather_widget()]; + + projection.apply_poll_result(10, &weather_response(5.4), &widgets); + let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); + + assert!(changed.is_empty()); +} + +#[test] +fn apply_poll_result_detects_changed_value() { + let mut projection = DataProjection::new(); + let widgets = vec![weather_widget()]; + + projection.apply_poll_result(10, &weather_response(5.4), &widgets); + let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets); + + assert_eq!(changed.len(), 1); + assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(6.1))); +} + +#[test] +fn apply_poll_result_only_updates_widgets_bound_to_source() { + let mut projection = DataProjection::new(); + let widgets = vec![ + weather_widget(), + WidgetConfig::new( + 2, + "portfolio".into(), + DisplayHint::KeyValue, + 20, + vec![ + KeyMapping { source_path: "$.value".into(), target_key: "amount".into() }, + ], + ), + ]; + + let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); + + assert_eq!(changed.len(), 1); + assert_eq!(changed[0].0, 1); +} diff --git a/crates/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs new file mode 100644 index 0000000..199d78f --- /dev/null +++ b/crates/application/tests/support/mod.rs @@ -0,0 +1,126 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use domain::{ + ConfigRepository, EventPublisher, + DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, + WidgetConfig, WidgetId, DomainEvent, +}; + +pub struct InMemoryConfigRepository { + pub widgets: RefCell>, + pub data_sources: RefCell>, + pub layout: RefCell>, + pub presets: RefCell>, +} + +impl InMemoryConfigRepository { + pub fn new() -> Self { + Self { + widgets: RefCell::new(HashMap::new()), + data_sources: RefCell::new(HashMap::new()), + layout: RefCell::new(None), + presets: RefCell::new(HashMap::new()), + } + } +} + +#[derive(Debug)] +pub struct Never; + +impl std::fmt::Display for Never { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + unreachable!() + } +} + +impl ConfigRepository for InMemoryConfigRepository { + type Error = Never; + + async fn get_widget(&self, id: WidgetId) -> Result, Self::Error> { + Ok(self.widgets.borrow().get(&id).cloned()) + } + + async fn list_widgets(&self) -> Result, Self::Error> { + Ok(self.widgets.borrow().values().cloned().collect()) + } + + async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> { + self.widgets.borrow_mut().insert(config.id, config.clone()); + Ok(()) + } + + async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> { + self.widgets.borrow_mut().remove(&id); + Ok(()) + } + + async fn get_data_source(&self, id: DataSourceId) -> Result, Self::Error> { + Ok(self.data_sources.borrow().get(&id).cloned()) + } + + async fn list_data_sources(&self) -> Result, Self::Error> { + Ok(self.data_sources.borrow().values().cloned().collect()) + } + + async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> { + self.data_sources.borrow_mut().insert(source.id, source.clone()); + Ok(()) + } + + async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> { + self.data_sources.borrow_mut().remove(&id); + Ok(()) + } + + async fn get_layout(&self) -> Result, Self::Error> { + Ok(self.layout.borrow().clone()) + } + + async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> { + *self.layout.borrow_mut() = Some(layout.clone()); + Ok(()) + } + + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error> { + Ok(self.presets.borrow().get(&id).cloned()) + } + + async fn list_presets(&self) -> Result, Self::Error> { + Ok(self.presets.borrow().values().cloned().collect()) + } + + async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> { + self.presets.borrow_mut().insert(preset.id, preset.clone()); + Ok(()) + } + + async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { + self.presets.borrow_mut().remove(&id); + Ok(()) + } +} + +pub struct InMemoryEventPublisher { + pub events: RefCell>, +} + +impl InMemoryEventPublisher { + pub fn new() -> Self { + Self { + events: RefCell::new(Vec::new()), + } + } + + pub fn emitted(&self) -> Vec { + self.events.borrow().clone() + } +} + +impl EventPublisher for InMemoryEventPublisher { + type Error = Never; + + async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> { + self.events.borrow_mut().push(event); + Ok(()) + } +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 0000000..76de982 --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +protocol.workspace = true +application.workspace = true +config-memory.workspace = true +tcp-server.workspace = true +tokio.workspace = true diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs new file mode 100644 index 0000000..cf01807 --- /dev/null +++ b/crates/bootstrap/src/main.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; +use std::time::Duration; +use domain::{ + ConfigRepository, BroadcastPort, + WidgetConfig, DisplayHint, KeyMapping, + Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing, + Value, WidgetState, +}; +use application::{ConfigService, DataProjection}; +use config_memory::MemoryConfigStore; +use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server}; + +#[tokio::main] +async fn main() { + let config_store = Arc::new(MemoryConfigStore::new()); + let event_bus = Arc::new(TcpEventBus::new(64)); + let broadcaster = Arc::new(TcpBroadcaster::new(64)); + + let service = ConfigService::new(config_store.as_ref(), event_bus.as_ref()); + + service.create_widget(WidgetConfig::new( + 1, "weather".into(), DisplayHint::IconValue, 1, + vec![ + KeyMapping { source_path: "$.temperature".into(), target_key: "value".into() }, + KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() }, + ], + )).await.unwrap(); + + service.create_widget(WidgetConfig::new( + 2, "portfolio".into(), DisplayHint::KeyValue, 2, + vec![ + KeyMapping { source_path: "$.amount".into(), target_key: "value".into() }, + ], + )).await.unwrap(); + + let layout = Layout { + root: LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 4, + padding: 2, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }), + }; + service.update_layout(layout).await.unwrap(); + + let bc = broadcaster.clone(); + tokio::spawn(async move { + run_tcp_server("0.0.0.0:2699", bc).await.unwrap(); + }); + + println!("Server running on :2699"); + println!("Sending fake data every 3 seconds..."); + + let mut projection = DataProjection::new(); + let mut counter = 0u32; + + loop { + tokio::time::sleep(Duration::from_secs(3)).await; + counter += 1; + + let widgets = config_store.list_widgets().await.unwrap(); + let layout = config_store.get_layout().await.unwrap(); + + let weather_data = Value::Object(std::collections::BTreeMap::from([ + ("temperature".into(), Value::String(format!("{}.{}°C", 5 + counter % 10, counter % 10))), + ("icon".into(), Value::String("sunny".into())), + ])); + + let portfolio_data = Value::Object(std::collections::BTreeMap::from([ + ("amount".into(), Value::String(format!("{}.{} PLN", 100 + counter, counter % 100))), + ])); + + let changed_weather = projection.apply_poll_result(1, &weather_data, &widgets); + let changed_portfolio = projection.apply_poll_result(2, &portfolio_data, &widgets); + + let mut all_changed: Vec<(u16, WidgetState)> = Vec::new(); + all_changed.extend(changed_weather); + all_changed.extend(changed_portfolio); + + if !all_changed.is_empty() { + if counter == 1 { + if let Some(l) = &layout { + broadcaster.push_screen_update(l, &all_changed).await.unwrap(); + } + } else { + broadcaster.push_data_update(&all_changed).await.unwrap(); + } + println!("Pushed {} widget updates (tick {counter})", all_changed.len()); + } + } +} diff --git a/crates/client-application/Cargo.toml b/crates/client-application/Cargo.toml new file mode 100644 index 0000000..2c57201 --- /dev/null +++ b/crates/client-application/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "client-application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +client-domain.workspace = true +protocol.workspace = true + +[dev-dependencies] diff --git a/crates/client-application/src/client_app.rs b/crates/client-application/src/client_app.rs new file mode 100644 index 0000000..56f12de --- /dev/null +++ b/crates/client-application/src/client_app.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; +use domain::LayoutNode; +use client_domain::{BoundingBox, LayoutEngine, RenderTree}; +use protocol::{ + ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode, +}; + +pub struct ClientApp { + screen: BoundingBox, + render_tree: Option, + widget_states: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RepaintCommand { + pub widget_id: u16, + pub bounds: BoundingBox, + pub display_hint: WireDisplayHint, + pub state: WireWidgetState, +} + +impl ClientApp { + pub fn new(screen: BoundingBox) -> Self { + Self { + screen, + render_tree: None, + widget_states: HashMap::new(), + } + } + + pub fn handle_message(&mut self, msg: ServerMessage) -> Vec { + match msg { + ServerMessage::ScreenUpdate { layout, widgets } => { + self.handle_screen_update(layout, widgets) + } + ServerMessage::DataUpdate { widgets } => { + self.handle_data_update(widgets) + } + ServerMessage::Heartbeat => Vec::new(), + } + } + + fn handle_screen_update( + &mut self, + wire_layout: WireLayoutNode, + widgets: Vec, + ) -> Vec { + let layout: LayoutNode = wire_layout.into(); + let new_tree = LayoutEngine::compute(&layout, self.screen); + + self.widget_states.clear(); + for w in &widgets { + self.widget_states.insert(w.id, (w.display_hint.clone(), w.state.clone())); + } + + let repaints = self.build_repaints_for_all(&new_tree); + self.render_tree = Some(new_tree); + repaints + } + + fn handle_data_update( + &mut self, + widgets: Vec, + ) -> Vec { + let tree = match &self.render_tree { + Some(t) => t, + None => return Vec::new(), + }; + + let mut repaints = Vec::new(); + + for w in widgets { + let changed = self.widget_states + .get(&w.id) + .map_or(true, |(_, prev_state)| *prev_state != w.state); + + if changed { + if let Some(bounds) = tree.get_widget_bounds(w.id) { + repaints.push(RepaintCommand { + widget_id: w.id, + bounds: *bounds, + display_hint: w.display_hint.clone(), + state: w.state.clone(), + }); + } + self.widget_states.insert(w.id, (w.display_hint, w.state)); + } + } + + repaints + } + + fn build_repaints_for_all(&self, tree: &RenderTree) -> Vec { + let mut repaints = Vec::new(); + + for (id, (hint, state)) in &self.widget_states { + if let Some(bounds) = tree.get_widget_bounds(*id) { + repaints.push(RepaintCommand { + widget_id: *id, + bounds: *bounds, + display_hint: hint.clone(), + state: state.clone(), + }); + } + } + + repaints.sort_by_key(|r| r.widget_id); + repaints + } +} diff --git a/crates/client-application/src/lib.rs b/crates/client-application/src/lib.rs new file mode 100644 index 0000000..9f7e106 --- /dev/null +++ b/crates/client-application/src/lib.rs @@ -0,0 +1,3 @@ +mod client_app; + +pub use client_app::{ClientApp, RepaintCommand}; diff --git a/crates/client-application/tests/client_app_tests.rs b/crates/client-application/tests/client_app_tests.rs new file mode 100644 index 0000000..bf185ae --- /dev/null +++ b/crates/client-application/tests/client_app_tests.rs @@ -0,0 +1,154 @@ +use client_application::{ClientApp, RepaintCommand}; +use client_domain::BoundingBox; +use protocol::{ + ServerMessage, WidgetDescriptor, + WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild, + WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue, +}; + +fn screen() -> BoundingBox { + BoundingBox::screen(240, 320) +} + +fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor { + WidgetDescriptor { + id, + display_hint: WireDisplayHint::IconValue, + state: WireWidgetState { + data: vec![ + WireKeyValue { key: "temperature".into(), value: WireValue::String(temp.into()) }, + ], + error: None, + }, + } +} + +fn two_widget_layout() -> WireLayoutNode { + WireLayoutNode::Container(WireContainerNode { + direction: WireDirection::Row, + gap: 0, + padding: 0, + children: vec![ + WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) }, + WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) }, + ], + }) +} + +#[test] +fn screen_update_repaints_all_widgets() { + let mut app = ClientApp::new(screen()); + + let msg = ServerMessage::ScreenUpdate { + layout: two_widget_layout(), + widgets: vec![ + weather_descriptor(1, "5.4°C"), + weather_descriptor(2, "20°C"), + ], + }; + + let repaints = app.handle_message(msg); + + assert_eq!(repaints.len(), 2); + assert_eq!(repaints[0].widget_id, 1); + assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 120, 320)); + assert_eq!(repaints[1].widget_id, 2); + assert_eq!(repaints[1].bounds, BoundingBox::new(120, 0, 120, 320)); +} + +#[test] +fn data_update_only_repaints_changed_widgets() { + let mut app = ClientApp::new(screen()); + + app.handle_message(ServerMessage::ScreenUpdate { + layout: two_widget_layout(), + widgets: vec![ + weather_descriptor(1, "5.4°C"), + weather_descriptor(2, "20°C"), + ], + }); + + let repaints = app.handle_message(ServerMessage::DataUpdate { + widgets: vec![weather_descriptor(1, "6.1°C")], + }); + + assert_eq!(repaints.len(), 1); + assert_eq!(repaints[0].widget_id, 1); + assert_eq!( + repaints[0].state.data[0].value, + WireValue::String("6.1°C".into()) + ); +} + +#[test] +fn data_update_with_unchanged_data_produces_no_repaints() { + let mut app = ClientApp::new(screen()); + + app.handle_message(ServerMessage::ScreenUpdate { + layout: two_widget_layout(), + widgets: vec![ + weather_descriptor(1, "5.4°C"), + weather_descriptor(2, "20°C"), + ], + }); + + let repaints = app.handle_message(ServerMessage::DataUpdate { + widgets: vec![weather_descriptor(1, "5.4°C")], + }); + + assert!(repaints.is_empty()); +} + +#[test] +fn second_screen_update_repaints_all_widgets_with_new_layout() { + let mut app = ClientApp::new(screen()); + + app.handle_message(ServerMessage::ScreenUpdate { + layout: two_widget_layout(), + widgets: vec![ + weather_descriptor(1, "5.4°C"), + weather_descriptor(2, "20°C"), + ], + }); + + let column_layout = WireLayoutNode::Container(WireContainerNode { + direction: WireDirection::Column, + gap: 0, + padding: 0, + children: vec![ + WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) }, + WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) }, + ], + }); + + let repaints = app.handle_message(ServerMessage::ScreenUpdate { + layout: column_layout, + widgets: vec![ + weather_descriptor(1, "5.4°C"), + weather_descriptor(2, "20°C"), + ], + }); + + assert_eq!(repaints.len(), 2); + assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 240, 160)); + assert_eq!(repaints[1].bounds, BoundingBox::new(0, 160, 240, 160)); +} + +#[test] +fn data_update_before_screen_update_produces_no_repaints() { + let mut app = ClientApp::new(screen()); + + let repaints = app.handle_message(ServerMessage::DataUpdate { + widgets: vec![weather_descriptor(1, "5.4°C")], + }); + + assert!(repaints.is_empty()); +} + +#[test] +fn heartbeat_produces_no_repaints() { + let mut app = ClientApp::new(screen()); + + let repaints = app.handle_message(ServerMessage::Heartbeat); + assert!(repaints.is_empty()); +} diff --git a/crates/client-desktop/Cargo.toml b/crates/client-desktop/Cargo.toml new file mode 100644 index 0000000..761abc4 --- /dev/null +++ b/crates/client-desktop/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "client-desktop" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +protocol.workspace = true +client-domain.workspace = true +client-application.workspace = true +tcp-client.workspace = true +display-terminal.workspace = true diff --git a/crates/client-desktop/src/main.rs b/crates/client-desktop/src/main.rs new file mode 100644 index 0000000..6db2f8a --- /dev/null +++ b/crates/client-desktop/src/main.rs @@ -0,0 +1,85 @@ +use std::thread; +use std::sync::mpsc; +use std::time::Duration; +use client_domain::{BoundingBox, DisplayPort, NetworkPort}; +use client_application::ClientApp; +use tcp_client::StdTcpClient; +use display_terminal::TerminalDisplay; +use protocol::decode_server_message; + +fn main() { + let screen = BoundingBox::screen(240, 320); + let mut app = ClientApp::new(screen); + let mut display = TerminalDisplay::new(); + + println!("=== K-Frame Desktop Client ==="); + println!("Screen: {}x{}", screen.width, screen.height); + + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || { + let server_addr = "127.0.0.1:2699"; + let mut net = StdTcpClient::new(); + + loop { + if !net.is_connected() { + println!("[NET] Connecting to {server_addr}..."); + match net.connect(server_addr) { + Ok(()) => println!("[NET] Connected!"), + Err(e) => { + println!("[NET] Connection failed: {e}, retrying in 2s..."); + thread::sleep(Duration::from_secs(2)); + continue; + } + } + } + + match net.receive() { + Ok(Some(payload)) => { + match decode_server_message(&payload) { + Ok(msg) => { let _ = tx.send(msg); } + Err(e) => println!("[NET] Decode error: {e}"), + } + } + Ok(None) => { + thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + println!("[NET] Receive error: {e}, reconnecting..."); + let _ = net.disconnect(); + thread::sleep(Duration::from_secs(2)); + } + } + } + }); + + println!("[RENDER] Render loop started"); + + loop { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(msg) => { + let repaints = app.handle_message(msg); + if !repaints.is_empty() { + println!("\n--- Repaint ({} widgets) ---", repaints.len()); + for cmd in &repaints { + display.clear_region(cmd.bounds).unwrap(); + display.fill_background(cmd.bounds).unwrap(); + + for kv in &cmd.state.data { + if let protocol::WireValue::String(s) = &kv.value { + display.draw_text( + &format!("{}: {s}", kv.key), + cmd.bounds.x, cmd.bounds.y, + cmd.bounds, + ).unwrap(); + } + } + } + display.flush().unwrap(); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } +} diff --git a/crates/client-domain/Cargo.toml b/crates/client-domain/Cargo.toml new file mode 100644 index 0000000..2cce64c --- /dev/null +++ b/crates/client-domain/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "client-domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true + +[dev-dependencies] diff --git a/crates/client-domain/src/bounding_box.rs b/crates/client-domain/src/bounding_box.rs new file mode 100644 index 0000000..d67d36c --- /dev/null +++ b/crates/client-domain/src/bounding_box.rs @@ -0,0 +1,17 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BoundingBox { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl BoundingBox { + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self { + Self { x, y, width, height } + } + + pub fn screen(width: u16, height: u16) -> Self { + Self { x: 0, y: 0, width, height } + } +} diff --git a/crates/client-domain/src/layout_engine.rs b/crates/client-domain/src/layout_engine.rs new file mode 100644 index 0000000..d938592 --- /dev/null +++ b/crates/client-domain/src/layout_engine.rs @@ -0,0 +1,96 @@ +use std::collections::HashMap; +use domain::{LayoutNode, ContainerNode, Direction, Sizing}; +use crate::{BoundingBox, RenderTree}; + +pub struct LayoutEngine; + +impl LayoutEngine { + pub fn compute(layout: &LayoutNode, bounds: BoundingBox) -> RenderTree { + let mut widget_bounds = HashMap::new(); + Self::compute_node(layout, bounds, &mut widget_bounds); + RenderTree { widget_bounds } + } + + fn compute_node( + node: &LayoutNode, + bounds: BoundingBox, + out: &mut HashMap, + ) { + match node { + LayoutNode::Leaf(id) => { + out.insert(*id, bounds); + } + LayoutNode::Container(container) => { + Self::compute_container(container, bounds, out); + } + } + } + + fn compute_container( + container: &ContainerNode, + bounds: BoundingBox, + out: &mut HashMap, + ) { + let inner = BoundingBox::new( + bounds.x + container.padding as u16, + bounds.y + container.padding as u16, + bounds.width.saturating_sub(container.padding as u16 * 2), + bounds.height.saturating_sub(container.padding as u16 * 2), + ); + + let children = &container.children; + if children.is_empty() { + return; + } + + let is_row = container.direction == Direction::Row; + let total_axis = if is_row { inner.width } else { inner.height }; + let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1); + let available = total_axis.saturating_sub(total_gap); + + let fixed_total: u16 = children.iter().map(|c| match c.sizing { + Sizing::Fixed(px) => px, + Sizing::Flex(_) => 0, + }).sum(); + + let flex_space = available.saturating_sub(fixed_total); + let flex_total: u16 = children.iter().map(|c| match c.sizing { + Sizing::Flex(w) => w as u16, + Sizing::Fixed(_) => 0, + }).sum(); + + let mut offset = 0u16; + + for child in children { + let child_size = match child.sizing { + Sizing::Fixed(px) => px, + Sizing::Flex(w) => { + if flex_total > 0 { + (flex_space as u32 * w as u32 / flex_total as u32) as u16 + } else { + 0 + } + } + }; + + let child_bounds = if is_row { + BoundingBox::new( + inner.x + offset, + inner.y, + child_size, + inner.height, + ) + } else { + BoundingBox::new( + inner.x, + inner.y + offset, + inner.width, + child_size, + ) + }; + + Self::compute_node(&child.node, child_bounds, out); + offset += child_size + container.gap as u16; + } + } +} diff --git a/crates/client-domain/src/lib.rs b/crates/client-domain/src/lib.rs new file mode 100644 index 0000000..8419a63 --- /dev/null +++ b/crates/client-domain/src/lib.rs @@ -0,0 +1,9 @@ +mod bounding_box; +mod layout_engine; +mod render_tree; +pub mod ports; + +pub use bounding_box::BoundingBox; +pub use layout_engine::LayoutEngine; +pub use render_tree::RenderTree; +pub use ports::{DisplayPort, NetworkPort, StoragePort, ClientConfig}; diff --git a/crates/client-domain/src/ports/display.rs b/crates/client-domain/src/ports/display.rs new file mode 100644 index 0000000..4dd7df9 --- /dev/null +++ b/crates/client-domain/src/ports/display.rs @@ -0,0 +1,11 @@ +use crate::BoundingBox; + +pub trait DisplayPort { + type Error; + + fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>; + fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error>; + fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>; + fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>; + fn flush(&mut self) -> Result<(), Self::Error>; +} diff --git a/crates/client-domain/src/ports/mod.rs b/crates/client-domain/src/ports/mod.rs new file mode 100644 index 0000000..948ef16 --- /dev/null +++ b/crates/client-domain/src/ports/mod.rs @@ -0,0 +1,7 @@ +mod display; +mod network; +mod storage; + +pub use display::DisplayPort; +pub use network::NetworkPort; +pub use storage::{StoragePort, ClientConfig}; diff --git a/crates/client-domain/src/ports/network.rs b/crates/client-domain/src/ports/network.rs new file mode 100644 index 0000000..d633478 --- /dev/null +++ b/crates/client-domain/src/ports/network.rs @@ -0,0 +1,9 @@ +pub trait NetworkPort { + type Error; + + fn connect(&mut self, addr: &str) -> Result<(), Self::Error>; + fn disconnect(&mut self) -> Result<(), Self::Error>; + fn send(&mut self, data: &[u8]) -> Result<(), Self::Error>; + fn receive(&mut self) -> Result>, Self::Error>; + fn is_connected(&self) -> bool; +} diff --git a/crates/client-domain/src/ports/storage.rs b/crates/client-domain/src/ports/storage.rs new file mode 100644 index 0000000..bb50017 --- /dev/null +++ b/crates/client-domain/src/ports/storage.rs @@ -0,0 +1,11 @@ +pub struct ClientConfig { + pub wifi_ssid: String, + pub wifi_password: String, + pub server_addr: String, +} + +pub trait StoragePort { + type Error; + + fn load_config(&self) -> Result; +} diff --git a/crates/client-domain/src/render_tree.rs b/crates/client-domain/src/render_tree.rs new file mode 100644 index 0000000..3fc0ad2 --- /dev/null +++ b/crates/client-domain/src/render_tree.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; +use domain::WidgetId; +use crate::BoundingBox; + +pub struct RenderTree { + pub widget_bounds: HashMap, +} + +impl RenderTree { + pub fn get_widget_bounds(&self, id: WidgetId) -> Option<&BoundingBox> { + self.widget_bounds.get(&id) + } + + pub fn diff(&self, other: &RenderTree) -> Vec { + let mut changed = Vec::new(); + for (id, bounds) in &self.widget_bounds { + match other.widget_bounds.get(id) { + Some(prev) if prev == bounds => {} + _ => changed.push(*id), + } + } + for id in other.widget_bounds.keys() { + if !self.widget_bounds.contains_key(id) { + changed.push(*id); + } + } + changed + } +} diff --git a/crates/client-domain/tests/layout_engine_tests.rs b/crates/client-domain/tests/layout_engine_tests.rs new file mode 100644 index 0000000..d6b5cf3 --- /dev/null +++ b/crates/client-domain/tests/layout_engine_tests.rs @@ -0,0 +1,151 @@ +use domain::{ + LayoutNode, ContainerNode, LayoutChild, Direction, Sizing, +}; +use client_domain::{BoundingBox, LayoutEngine, RenderTree}; + +fn screen() -> BoundingBox { + BoundingBox::screen(240, 320) +} + +fn leaf(id: u16) -> LayoutChild { + LayoutChild { + sizing: Sizing::Flex(1), + node: LayoutNode::Leaf(id), + } +} + +fn leaf_fixed(id: u16, size: u16) -> LayoutChild { + LayoutChild { + sizing: Sizing::Fixed(size), + node: LayoutNode::Leaf(id), + } +} + +fn row(children: Vec) -> LayoutNode { + LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children, + }) +} + +fn column(children: Vec) -> LayoutNode { + LayoutNode::Container(ContainerNode { + direction: Direction::Column, + gap: 0, + padding: 0, + children, + }) +} + +fn row_with_gap(gap: u8, children: Vec) -> LayoutNode { + LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap, + padding: 0, + children, + }) +} + +fn row_with_padding(padding: u8, children: Vec) -> LayoutNode { + LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding, + children, + }) +} + +#[test] +fn single_leaf_fills_screen() { + let layout = LayoutNode::Leaf(1); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(0, 0, 240, 320)) + ); +} + +#[test] +fn row_splits_width_among_equal_flex_children() { + let layout = row(vec![leaf(1), leaf(2), leaf(3)]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 80, 320))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(80, 0, 80, 320))); + assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(160, 0, 80, 320))); +} + +#[test] +fn column_splits_height_among_equal_flex_children() { + let layout = column(vec![leaf(1), leaf(2)]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 240, 160))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(0, 160, 240, 160))); +} + +#[test] +fn fixed_and_flex_children_coexist() { + let layout = row(vec![leaf_fixed(1, 40), leaf(2)]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(40, 0, 200, 320))); +} + +#[test] +fn gap_is_subtracted_before_distributing_space() { + // 240px wide, 2 children, gap=10 → 230px available, 115px each + let layout = row_with_gap(10, vec![leaf(1), leaf(2)]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 115, 320))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(125, 0, 115, 320))); +} + +#[test] +fn padding_insets_available_area() { + // padding=10 → inner area starts at (10,10), size (220,300) + let layout = row_with_padding(10, vec![leaf(1)]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(10, 10, 220, 300))); +} + +#[test] +fn nested_containers_compute_correctly() { + // Row with [leaf(1), column([leaf(2), leaf(3)])] + let inner_col = LayoutChild { + sizing: Sizing::Flex(1), + node: column(vec![leaf(2), leaf(3)]), + }; + let layout = row(vec![leaf(1), inner_col]); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 120, 320))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 120, 160))); + assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(120, 160, 120, 160))); +} + +#[test] +fn weighted_flex_distributes_proportionally() { + // weights [1, 2, 1] → 25%, 50%, 25% of 240 = 60, 120, 60 + let layout = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(2), node: LayoutNode::Leaf(2) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(3) }, + ], + }); + let tree = LayoutEngine::compute(&layout, screen()); + + assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 60, 320))); + assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(60, 0, 120, 320))); + assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(180, 0, 60, 320))); +} diff --git a/crates/client-domain/tests/render_tree_tests.rs b/crates/client-domain/tests/render_tree_tests.rs new file mode 100644 index 0000000..ca0bd77 --- /dev/null +++ b/crates/client-domain/tests/render_tree_tests.rs @@ -0,0 +1,85 @@ +use domain::{ + LayoutNode, ContainerNode, LayoutChild, Direction, Sizing, +}; +use client_domain::{BoundingBox, LayoutEngine}; + +fn screen() -> BoundingBox { + BoundingBox::screen(240, 320) +} + +#[test] +fn diff_detects_moved_widget_after_layout_change() { + let layout_a = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }); + + let layout_b = LayoutNode::Container(ContainerNode { + direction: Direction::Column, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }); + + let tree_a = LayoutEngine::compute(&layout_a, screen()); + let tree_b = LayoutEngine::compute(&layout_b, screen()); + + let mut changed = tree_b.diff(&tree_a); + changed.sort(); + assert_eq!(changed, vec![1, 2]); +} + +#[test] +fn diff_returns_empty_for_identical_layouts() { + let layout = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }); + + let tree_a = LayoutEngine::compute(&layout, screen()); + let tree_b = LayoutEngine::compute(&layout, screen()); + + assert!(tree_b.diff(&tree_a).is_empty()); +} + +#[test] +fn diff_detects_added_and_removed_widgets() { + let layout_a = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, + ], + }); + + let layout_b = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children: vec![ + LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, + ], + }); + + let tree_a = LayoutEngine::compute(&layout_a, screen()); + let tree_b = LayoutEngine::compute(&layout_b, screen()); + + let mut changed = tree_b.diff(&tree_a); + changed.sort(); + // widget 1 was removed (in old but not new), widget 2 was added (in new but not old) + assert_eq!(changed, vec![1, 2]); +} diff --git a/crates/client-esp32/.cargo/config.toml b/crates/client-esp32/.cargo/config.toml new file mode 100644 index 0000000..55ebbaa --- /dev/null +++ b/crates/client-esp32/.cargo/config.toml @@ -0,0 +1,13 @@ +[build] +target = "xtensa-esp32-espidf" + +[target.xtensa-esp32-espidf] +linker = "ldproxy" +runner = "espflash flash --monitor" + +[unstable] +build-std = ["std", "panic_abort"] + +[env] +MCU = "esp32" +ESP_IDF_VERSION = "v5.4" diff --git a/crates/client-esp32/.gitignore b/crates/client-esp32/.gitignore new file mode 100644 index 0000000..8b4a816 --- /dev/null +++ b/crates/client-esp32/.gitignore @@ -0,0 +1,2 @@ +target/ +.embuild/ diff --git a/crates/client-esp32/Cargo.lock b/crates/client-esp32/Cargo.lock new file mode 100644 index 0000000..05bd673 --- /dev/null +++ b/crates/client-esp32/Cargo.lock @@ -0,0 +1,1820 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[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 = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "build-time" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1219c19fc29b7bfd74b7968b420aff5bc951cf517800176e795d6b2300dd382" +dependencies = [ + "chrono", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "camino" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "client-application" +version = "0.1.0" +dependencies = [ + "client-domain", + "domain", + "protocol", +] + +[[package]] +name = "client-domain" +version = "0.1.0" +dependencies = [ + "domain", +] + +[[package]] +name = "client-esp32" +version = "0.1.0" +dependencies = [ + "client-application", + "client-domain", + "domain", + "embedded-graphics", + "embedded-hal-bus", + "embedded-text", + "embuild", + "esp-idf-hal", + "esp-idf-svc", + "esp-idf-sys", + "log", + "mipidsi", + "postcard", + "protocol", + "serde", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "domain" +version = "0.1.0" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-graphics" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8da660bb0c829b34a56a965490597f82a55e767b91f9543be80ce8ccb416fe" +dependencies = [ + "az", + "byteorder", + "embedded-graphics-core", + "float-cmp", + "micromath", +] + +[[package]] +name = "embedded-graphics-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95743bef3ff70fcba3930246c4e6872882bbea0dcc6da2ca860112e0cd4bd09f" +dependencies = [ + "az", + "byteorder", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-bus" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-svc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfc6d05bac4af70b0795d1f1b6ddd44aa85ad04b05d34d64b1b9fce36de107e" +dependencies = [ + "defmt", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "enumset", + "heapless 0.9.3", + "num_enum", + "serde", + "strum 0.27.2", +] + +[[package]] +name = "embedded-text" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba" +dependencies = [ + "az", + "embedded-graphics", + "object-chain", +] + +[[package]] +name = "embuild" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e188ad2bbe82afa841ea4a29880651e53ab86815db036b2cb9f8de3ac32dad75" +dependencies = [ + "anyhow", + "bindgen", + "bitflags 1.3.2", + "cmake", + "filetime", + "globwalk", + "home", + "log", + "regex", + "remove_dir_all", + "serde", + "serde_json", + "shlex 1.3.0", + "strum 0.24.1", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[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 = "esp-idf-hal" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2585c80f488be431ad14e199883a05ff31576adaa23014befa0f040fd91490b8" +dependencies = [ + "atomic-waker", + "embassy-sync", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embuild", + "enumset", + "esp-idf-sys", + "heapless 0.9.3", + "log", + "nb 1.1.0", +] + +[[package]] +name = "esp-idf-svc" +version = "0.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033432e951f49284a4b8874cdb2d7fb940a3b0181460f452ca7414ebe66babca" +dependencies = [ + "embassy-futures", + "embedded-hal-async", + "embedded-svc", + "embuild", + "enumset", + "esp-idf-hal", + "futures-io", + "heapless 0.9.3", + "log", + "num_enum", + "uncased", +] + +[[package]] +name = "esp-idf-sys" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8df62993f242eb05d9ddb63e503624fb22f24a11d6c680e85cf0169ee14421f" +dependencies = [ + "anyhow", + "build-time", + "cargo_metadata", + "cmake", + "const_format", + "embuild", + "envy", + "libc", + "regex", + "serde", + "strum 0.24.1", + "which", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + +[[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-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-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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 = "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 = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mipidsi" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790ebd28bd67addbccf41b1c0c188c26bb9f5bdcd91d4d6da9bd558e20d97a1d" +dependencies = [ + "embedded-graphics-core", + "embedded-hal 1.0.0", + "heapless 0.8.0", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.118", +] + +[[package]] +name = "object-chain" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[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.118", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "protocol" +version = "0.1.0" +dependencies = [ + "domain", + "postcard", + "serde", +] + +[[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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "remove_dir_all" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a694f9e0eb3104451127f6cc1e5de55f59d3b1fc8c5ddfaeb6f1e716479ceb4a" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "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.13.0", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[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.118", +] + +[[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.118", +] + +[[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.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[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", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[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 = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[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 = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/client-esp32/Cargo.toml b/crates/client-esp32/Cargo.toml new file mode 100644 index 0000000..b709cd1 --- /dev/null +++ b/crates/client-esp32/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "client-esp32" +version = "0.1.0" +edition = "2021" + +[dependencies] +domain = { path = "../domain" } +protocol = { path = "../protocol" } +client-domain = { path = "../client-domain" } +client-application = { path = "../client-application" } + +esp-idf-hal = "0.46" +esp-idf-svc = { version = "0.52", features = ["experimental"] } +esp-idf-sys = "0.37" + +mipidsi = "0.10" +embedded-graphics = "0.8" +embedded-text = "0.7" +embedded-hal-bus = "0.3" + +serde = { version = "1.0", default-features = false, features = [ + "derive", + "alloc", +] } +postcard = { version = "1.1", default-features = false, features = ["alloc"] } + +log = "0.4" + +[build-dependencies] +embuild = "0.33" diff --git a/crates/client-esp32/build.rs b/crates/client-esp32/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/crates/client-esp32/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/crates/client-esp32/rust-toolchain.toml b/crates/client-esp32/rust-toolchain.toml new file mode 100644 index 0000000..a2f5ab5 --- /dev/null +++ b/crates/client-esp32/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "esp" diff --git a/crates/client-esp32/sdkconfig.defaults b/crates/client-esp32/sdkconfig.defaults new file mode 100644 index 0000000..a1680b2 --- /dev/null +++ b/crates/client-esp32/sdkconfig.defaults @@ -0,0 +1,16 @@ +# K-Frame ESP32 firmware config + +# WiFi +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32 + +# Task stack sizes +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=8192 + +# SPI +CONFIG_SPI_MASTER_IN_IRAM=y + +# Use single large app partition (no OTA) +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/crates/client-esp32/src/adapters/display.rs b/crates/client-esp32/src/adapters/display.rs new file mode 100644 index 0000000..eb65fe3 --- /dev/null +++ b/crates/client-esp32/src/adapters/display.rs @@ -0,0 +1,124 @@ +use client_domain::{BoundingBox, DisplayPort}; +use embedded_graphics::{ + mono_font::{ascii::FONT_6X10, ascii::FONT_10X20, MonoTextStyle}, + pixelcolor::Rgb565, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::Text, +}; +use embedded_text::{TextBox, style::TextBoxStyleBuilder}; + +#[derive(Debug)] +pub enum DisplayError { + Draw(String), +} + +impl std::fmt::Display for DisplayError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DisplayError::Draw(e) => write!(f, "draw: {e}"), + } + } +} + +pub struct Esp32DisplayAdapter { + inner: Box, +} + +trait ErasedDisplay { + fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>; + fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), DisplayError>; + fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError>; + fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>; + fn flush(&mut self) -> Result<(), DisplayError>; +} + +impl ErasedDisplay for D +where + D: DrawTarget, + D::Error: std::fmt::Debug, +{ + fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> { + Rectangle::new( + Point::new(bounds.x as i32, bounds.y as i32), + Size::new(bounds.width as u32, bounds.height as u32), + ) + .into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK)) + .draw(self) + .map_err(|e| DisplayError::Draw(format!("{e:?}")))?; + Ok(()) + } + + fn draw_text(&mut self, text: &str, _x: u16, _y: u16, bounds: BoundingBox) -> Result<(), DisplayError> { + let style = MonoTextStyle::new(&FONT_6X10, Rgb565::WHITE); + let textbox_style = TextBoxStyleBuilder::new().build(); + let rect = Rectangle::new( + Point::new(bounds.x as i32, bounds.y as i32), + Size::new(bounds.width as u32, bounds.height as u32), + ); + TextBox::with_textbox_style(text, rect, style, textbox_style) + .draw(self) + .map_err(|e| DisplayError::Draw(format!("{e:?}")))?; + Ok(()) + } + + fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError> { + let style = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE); + let icon_char = match icon { + "sunny" | "clear" => "*", + "cloud_rain" | "rain" => "~", + "cloud" | "cloudy" => "=", + "dollar" | "money" => "$", + "music" | "note" => "#", + _ => "?", + }; + Text::new(icon_char, Point::new(x as i32, y as i32 + 20), style) + .draw(self) + .map_err(|e| DisplayError::Draw(format!("{e:?}")))?; + Ok(()) + } + + fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> { + self.clear_region(bounds) + } + + fn flush(&mut self) -> Result<(), DisplayError> { + Ok(()) + } +} + +impl Esp32DisplayAdapter { + pub fn new(display: D) -> Self + where + D: DrawTarget + 'static, + D::Error: std::fmt::Debug, + { + Self { + inner: Box::new(display), + } + } +} + +impl DisplayPort for Esp32DisplayAdapter { + type Error = DisplayError; + + fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> { + self.inner.clear_region(bounds) + } + + fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> { + self.inner.draw_text(text, x, y, bounds) + } + + fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> { + self.inner.draw_icon(icon, x, y) + } + + fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> { + self.inner.fill_background(bounds) + } + + fn flush(&mut self) -> Result<(), Self::Error> { + self.inner.flush() + } +} diff --git a/crates/client-esp32/src/adapters/mod.rs b/crates/client-esp32/src/adapters/mod.rs new file mode 100644 index 0000000..fbbb020 --- /dev/null +++ b/crates/client-esp32/src/adapters/mod.rs @@ -0,0 +1,2 @@ +pub mod display; +pub mod network; diff --git a/crates/client-esp32/src/adapters/network.rs b/crates/client-esp32/src/adapters/network.rs new file mode 100644 index 0000000..525e6c4 --- /dev/null +++ b/crates/client-esp32/src/adapters/network.rs @@ -0,0 +1,85 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; +use client_domain::NetworkPort; +use protocol::MAX_FRAME_SIZE; +use crate::config::NET_READ_TIMEOUT; +use log::info; + +#[derive(Debug)] +pub enum NetworkError { + Io(std::io::Error), + NotConnected, + FrameTooLarge(usize), +} + +impl std::fmt::Display for NetworkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NetworkError::Io(e) => write!(f, "io: {e}"), + NetworkError::NotConnected => write!(f, "not connected"), + NetworkError::FrameTooLarge(n) => write!(f, "frame too large: {n}"), + } + } +} + +pub struct Esp32Network { + stream: Option, +} + +impl Esp32Network { + pub fn new() -> Self { + Self { stream: None } + } +} + +impl NetworkPort for Esp32Network { + type Error = NetworkError; + + fn connect(&mut self, addr: &str) -> Result<(), Self::Error> { + info!("TCP connecting to {addr}..."); + let stream = TcpStream::connect(addr).map_err(NetworkError::Io)?; + stream.set_nonblocking(true).map_err(NetworkError::Io)?; + stream.set_read_timeout(Some(NET_READ_TIMEOUT)).map_err(NetworkError::Io)?; + self.stream = Some(stream); + info!("TCP connected"); + Ok(()) + } + + fn disconnect(&mut self) -> Result<(), Self::Error> { + self.stream = None; + Ok(()) + } + + fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> { + let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?; + stream.write_all(data).map_err(NetworkError::Io) + } + + fn receive(&mut self) -> Result>, Self::Error> { + let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?; + + let mut len_buf = [0u8; 4]; + match stream.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => return Ok(None), + Err(e) => return Err(NetworkError::Io(e)), + } + + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME_SIZE { + return Err(NetworkError::FrameTooLarge(len)); + } + + let mut payload = vec![0u8; len]; + stream.set_nonblocking(false).map_err(NetworkError::Io)?; + stream.read_exact(&mut payload).map_err(NetworkError::Io)?; + stream.set_nonblocking(true).map_err(NetworkError::Io)?; + + Ok(Some(payload)) + } + + fn is_connected(&self) -> bool { + self.stream.is_some() + } +} diff --git a/crates/client-esp32/src/config.rs b/crates/client-esp32/src/config.rs new file mode 100644 index 0000000..16ebc73 --- /dev/null +++ b/crates/client-esp32/src/config.rs @@ -0,0 +1,22 @@ +use std::time::Duration; +use esp_idf_hal::units::Hertz; +use client_domain::BoundingBox; + +pub const SCREEN_WIDTH: u16 = 320; +pub const SCREEN_HEIGHT: u16 = 240; +pub const SCREEN: BoundingBox = BoundingBox { + x: 0, + y: 0, + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, +}; + +pub const SPI_BAUDRATE: Hertz = Hertz(26_000_000); +pub const SPI_BUFFER_SIZE: usize = 512; + +pub const NET_THREAD_STACK_SIZE: usize = 8192; +pub const NET_READ_TIMEOUT: Duration = Duration::from_millis(10); +pub const NET_POLL_INTERVAL: Duration = Duration::from_millis(50); +pub const NET_RECONNECT_DELAY: Duration = Duration::from_secs(2); + +pub const RENDER_POLL_INTERVAL: Duration = Duration::from_millis(100); diff --git a/crates/client-esp32/src/hal/display.rs b/crates/client-esp32/src/hal/display.rs new file mode 100644 index 0000000..4f0e0e0 --- /dev/null +++ b/crates/client-esp32/src/hal/display.rs @@ -0,0 +1,58 @@ +use esp_idf_hal::delay::{Delay, Ets}; +use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver}; +use esp_idf_hal::spi::{SpiDeviceDriver, SpiDriver, SpiDriverConfig, SPI2, config::Config as SpiConfig}; +use mipidsi::{Builder, models::ILI9341Rgb565, options::{Orientation, Rotation}, interface::SpiInterface}; +use embedded_graphics::pixelcolor::Rgb565; +use embedded_graphics::prelude::*; +use log::info; + +use crate::config::{SCREEN_WIDTH, SCREEN_HEIGHT, SPI_BAUDRATE, SPI_BUFFER_SIZE}; +use crate::adapters::display::Esp32DisplayAdapter; + +pub struct DisplayHardware<'d> { + pub spi: SPI2<'d>, + pub sclk: AnyOutputPin<'d>, + pub mosi: AnyOutputPin<'d>, + pub cs: AnyOutputPin<'d>, + pub dc: AnyOutputPin<'d>, + pub rst: AnyOutputPin<'d>, +} + +pub fn init(hw: DisplayHardware<'static>) -> Esp32DisplayAdapter { + let spi_driver = SpiDriver::new( + hw.spi, hw.sclk, hw.mosi, + None::, + &SpiDriverConfig::new(), + ).expect("SPI driver init failed"); + + let spi_config = SpiConfig::new().baudrate(SPI_BAUDRATE); + let spi_device = SpiDeviceDriver::new(spi_driver, Some(hw.cs), &spi_config) + .expect("SPI device init failed"); + + let dc_pin = PinDriver::output(hw.dc).expect("DC pin failed"); + let mut rst_pin = PinDriver::output(hw.rst).expect("RST pin failed"); + + info!("Hardware reset..."); + rst_pin.set_high().unwrap(); + Ets::delay_ms(10); + rst_pin.set_low().unwrap(); + Ets::delay_ms(10); + rst_pin.set_high().unwrap(); + Ets::delay_ms(120); + + // Keep RST pin high — dropping PinDriver may release the GPIO + let rst_pin: &'static mut _ = Box::leak(Box::new(rst_pin)); + + let buf: &'static mut [u8; SPI_BUFFER_SIZE] = Box::leak(Box::new([0u8; SPI_BUFFER_SIZE])); + let di = SpiInterface::new(spi_device, dc_pin, buf); + + info!("Initializing ILI9341..."); + let mut raw_display = Builder::new(ILI9341Rgb565, di) + .display_size(240, 320) + .orientation(Orientation { rotation: Rotation::Deg90, mirrored: true }) + .init(&mut Delay::new_default()) + .expect("Display init failed"); + + raw_display.clear(Rgb565::BLACK).unwrap(); + Esp32DisplayAdapter::new(raw_display) +} diff --git a/crates/client-esp32/src/hal/mod.rs b/crates/client-esp32/src/hal/mod.rs new file mode 100644 index 0000000..134399c --- /dev/null +++ b/crates/client-esp32/src/hal/mod.rs @@ -0,0 +1,2 @@ +pub mod display; +pub mod wifi; diff --git a/crates/client-esp32/src/hal/wifi.rs b/crates/client-esp32/src/hal/wifi.rs new file mode 100644 index 0000000..62a1f17 --- /dev/null +++ b/crates/client-esp32/src/hal/wifi.rs @@ -0,0 +1,38 @@ +use esp_idf_hal::modem::Modem; +use esp_idf_svc::eventloop::EspSystemEventLoop; +use esp_idf_svc::nvs::EspDefaultNvsPartition; +use esp_idf_svc::wifi::{ + AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi, +}; +use log::info; + +pub fn init<'d>( + modem: Modem<'d>, + sysloop: EspSystemEventLoop, + nvs: EspDefaultNvsPartition, + ssid: &str, + password: &str, +) -> Result>, String> { + let esp_wifi = EspWifi::new(modem, sysloop.clone(), Some(nvs)) + .map_err(|e| format!("wifi new: {e:?}"))?; + + let mut wifi = BlockingWifi::wrap(esp_wifi, sysloop) + .map_err(|e| format!("wifi wrap: {e:?}"))?; + + let config = Configuration::Client(ClientConfiguration { + ssid: ssid.try_into().unwrap(), + password: password.try_into().unwrap(), + auth_method: AuthMethod::WPA2Personal, + ..Default::default() + }); + + wifi.set_configuration(&config).map_err(|e| format!("wifi config: {e:?}"))?; + wifi.start().map_err(|e| format!("wifi start: {e:?}"))?; + + info!("WiFi started, connecting..."); + wifi.connect().map_err(|e| format!("wifi connect: {e:?}"))?; + wifi.wait_netif_up().map_err(|e| format!("wifi netif: {e:?}"))?; + + info!("WiFi connected"); + Ok(wifi) +} diff --git a/crates/client-esp32/src/main.rs b/crates/client-esp32/src/main.rs new file mode 100644 index 0000000..009e128 --- /dev/null +++ b/crates/client-esp32/src/main.rs @@ -0,0 +1,45 @@ +mod adapters; +mod config; +mod hal; +mod tasks; + +use client_domain::{BoundingBox, DisplayPort}; +use esp_idf_hal::peripherals::Peripherals; +use log::info; + +fn main() { + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + info!("=== K-Frame ESP32 ==="); + + let peripherals = Peripherals::take().unwrap(); + + let mut display = hal::display::init(hal::display::DisplayHardware { + spi: peripherals.spi2, + sclk: peripherals.pins.gpio18.into(), + mosi: peripherals.pins.gpio23.into(), + cs: peripherals.pins.gpio26.into(), + dc: peripherals.pins.gpio21.into(), + rst: peripherals.pins.gpio22.into(), + }); + info!("Display initialized"); + + display.fill_background(config::SCREEN).unwrap(); + display.draw_text( + "K-Frame", + 10, 10, + BoundingBox::new(10, 10, 220, 40), + ).unwrap(); + display.draw_text( + "Display test OK", + 10, 60, + BoundingBox::new(10, 60, 220, 40), + ).unwrap(); + display.flush().unwrap(); + + info!("Display test complete — looping forever"); + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + } +} diff --git a/crates/client-esp32/src/tasks/mod.rs b/crates/client-esp32/src/tasks/mod.rs new file mode 100644 index 0000000..4ea0ac9 --- /dev/null +++ b/crates/client-esp32/src/tasks/mod.rs @@ -0,0 +1,2 @@ +pub mod network; +pub mod render; diff --git a/crates/client-esp32/src/tasks/network.rs b/crates/client-esp32/src/tasks/network.rs new file mode 100644 index 0000000..605f9c8 --- /dev/null +++ b/crates/client-esp32/src/tasks/network.rs @@ -0,0 +1,50 @@ +use std::sync::mpsc; +use std::thread; +use client_domain::NetworkPort; +use protocol::{ServerMessage, decode_server_message}; +use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY}; +use crate::adapters::network::Esp32Network; +use log::*; + +pub fn spawn(server_addr: String, tx: mpsc::Sender) { + thread::Builder::new() + .stack_size(NET_THREAD_STACK_SIZE) + .name("net".into()) + .spawn(move || run(server_addr, tx)) + .expect("failed to spawn network thread"); +} + +fn run(server_addr: String, tx: mpsc::Sender) { + let mut net = Esp32Network::new(); + + loop { + if !net.is_connected() { + info!("Connecting to server {server_addr}..."); + match net.connect(&server_addr) { + Ok(()) => info!("Server connected"), + Err(e) => { + error!("Connection failed: {e}, retrying..."); + thread::sleep(NET_RECONNECT_DELAY); + continue; + } + } + } + + match net.receive() { + Ok(Some(payload)) => { + match decode_server_message(&payload) { + Ok(msg) => { let _ = tx.send(msg); } + Err(e) => error!("Decode error: {e}"), + } + } + Ok(None) => { + thread::sleep(NET_POLL_INTERVAL); + } + Err(e) => { + error!("Receive error: {e}, reconnecting..."); + let _ = net.disconnect(); + thread::sleep(NET_RECONNECT_DELAY); + } + } + } +} diff --git a/crates/client-esp32/src/tasks/render.rs b/crates/client-esp32/src/tasks/render.rs new file mode 100644 index 0000000..956cf7b --- /dev/null +++ b/crates/client-esp32/src/tasks/render.rs @@ -0,0 +1,46 @@ +use std::sync::mpsc; +use client_domain::{BoundingBox, DisplayPort}; +use client_application::ClientApp; +use protocol::ServerMessage; +use crate::config::RENDER_POLL_INTERVAL; +use crate::adapters::display::Esp32DisplayAdapter; +use log::*; + +pub fn run( + screen: BoundingBox, + mut display: Esp32DisplayAdapter, + rx: mpsc::Receiver, +) { + let mut app = ClientApp::new(screen); + info!("Render loop started"); + + loop { + match rx.recv_timeout(RENDER_POLL_INTERVAL) { + Ok(msg) => { + let repaints = app.handle_message(msg); + for cmd in &repaints { + display.clear_region(cmd.bounds).unwrap(); + + for kv in &cmd.state.data { + if let protocol::WireValue::String(s) = &kv.value { + display.draw_text( + &format!("{}: {s}", kv.key), + cmd.bounds.x, + cmd.bounds.y, + cmd.bounds, + ).unwrap(); + } + } + } + if !repaints.is_empty() { + display.flush().unwrap(); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + error!("Network thread died"); + break; + } + } + } +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..907f941 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[dev-dependencies] diff --git a/crates/domain/src/entities/data_source.rs b/crates/domain/src/entities/data_source.rs new file mode 100644 index 0000000..0cfbf15 --- /dev/null +++ b/crates/domain/src/entities/data_source.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +pub type DataSourceId = u16; + +#[derive(Debug, Clone, PartialEq)] +pub enum DataSourceType { + Weather, + Media, + Xtb, + Rss, + HttpJson, + Webhook, +} + +#[derive(Debug, Clone)] +pub struct DataSourceConfig { + pub url: Option, + pub headers: Vec<(String, String)>, + pub api_key: Option, +} + +#[derive(Debug, Clone)] +pub struct DataSource { + pub id: DataSourceId, + pub name: String, + pub source_type: DataSourceType, + pub poll_interval: Duration, + pub config: DataSourceConfig, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DataSourceValidationError { + UrlRequired, + PollIntervalNotAllowed, + PollIntervalRequired, +} + +impl DataSource { + pub fn validate(&self) -> Vec { + let mut errors = Vec::new(); + + let is_webhook = self.source_type == DataSourceType::Webhook; + + if is_webhook { + if !self.poll_interval.is_zero() { + errors.push(DataSourceValidationError::PollIntervalNotAllowed); + } + } else { + if self.poll_interval.is_zero() { + errors.push(DataSourceValidationError::PollIntervalRequired); + } + if self.requires_url() && self.config.url.is_none() { + errors.push(DataSourceValidationError::UrlRequired); + } + } + + errors + } + + fn requires_url(&self) -> bool { + matches!( + self.source_type, + DataSourceType::Weather + | DataSourceType::Media + | DataSourceType::Rss + | DataSourceType::HttpJson + ) + } +} diff --git a/crates/domain/src/entities/layout_preset.rs b/crates/domain/src/entities/layout_preset.rs new file mode 100644 index 0000000..a56c4c6 --- /dev/null +++ b/crates/domain/src/entities/layout_preset.rs @@ -0,0 +1,10 @@ +use crate::value_objects::Layout; + +pub type LayoutPresetId = u16; + +#[derive(Debug, Clone)] +pub struct LayoutPreset { + pub id: LayoutPresetId, + pub name: String, + pub layout: Layout, +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs new file mode 100644 index 0000000..1e6b981 --- /dev/null +++ b/crates/domain/src/entities/mod.rs @@ -0,0 +1,7 @@ +mod widget_config; +mod data_source; +mod layout_preset; + +pub use widget_config::{WidgetConfig, WidgetId}; +pub use data_source::{DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError}; +pub use layout_preset::{LayoutPreset, LayoutPresetId}; diff --git a/crates/domain/src/entities/widget_config.rs b/crates/domain/src/entities/widget_config.rs new file mode 100644 index 0000000..8e5e6c1 --- /dev/null +++ b/crates/domain/src/entities/widget_config.rs @@ -0,0 +1,70 @@ +use std::collections::BTreeMap; +use crate::value_objects::{DisplayHint, KeyMapping, Value, WidgetState}; + +pub type WidgetId = u16; +pub type DataSourceId = u16; + +const DEFAULT_MAX_DATA_SIZE: u16 = 2048; + +#[derive(Debug, Clone)] +pub struct WidgetConfig { + pub id: WidgetId, + pub name: String, + pub display_hint: DisplayHint, + pub data_source_id: DataSourceId, + pub mappings: Vec, + pub max_data_size: u16, +} + +impl WidgetConfig { + pub fn new( + id: WidgetId, + name: String, + display_hint: DisplayHint, + data_source_id: DataSourceId, + mappings: Vec, + ) -> Self { + Self { + id, + name, + display_hint, + data_source_id, + mappings, + max_data_size: DEFAULT_MAX_DATA_SIZE, + } + } + + pub fn extract(&self, raw: &Value) -> WidgetState { + let budget = self.max_data_size as usize; + let mut used = 0usize; + let mut data = BTreeMap::new(); + + for mapping in &self.mappings { + if let Some((key, value)) = mapping.extract(raw) { + let key_cost = key.len(); + let remaining = budget.saturating_sub(used + key_cost); + let value = Self::truncate_value(value, remaining); + used += key_cost + value.estimated_size(); + data.insert(key, value); + if used >= budget { + break; + } + } + } + + WidgetState { data, error: None } + } + + fn truncate_value(value: Value, max_bytes: usize) -> Value { + match value { + Value::String(s) if s.len() > max_bytes => { + let truncated: String = s.char_indices() + .take_while(|(i, _)| *i < max_bytes) + .map(|(_, c)| c) + .collect(); + Value::String(truncated) + } + other => other, + } + } +} diff --git a/crates/domain/src/events/mod.rs b/crates/domain/src/events/mod.rs new file mode 100644 index 0000000..05d6e6b --- /dev/null +++ b/crates/domain/src/events/mod.rs @@ -0,0 +1,16 @@ +use crate::entities::{DataSourceId, LayoutPresetId, WidgetId}; +use crate::value_objects::Layout; + +#[derive(Debug, Clone)] +pub enum DomainEvent { + WidgetCreated { id: WidgetId }, + WidgetUpdated { id: WidgetId }, + WidgetDeleted { id: WidgetId }, + DataSourceAdded { id: DataSourceId }, + DataSourceUpdated { id: DataSourceId }, + DataSourceRemoved { id: DataSourceId }, + LayoutChanged { layout: Layout }, + LayoutPresetSaved { id: LayoutPresetId }, + LayoutPresetLoaded { id: LayoutPresetId }, + LayoutPresetDeleted { id: LayoutPresetId }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..c635da8 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,19 @@ +#![allow(async_fn_in_trait)] + +pub mod entities; +pub mod value_objects; +pub mod events; +pub mod ports; + +pub use entities::{ + WidgetConfig, WidgetId, + DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError, + LayoutPreset, LayoutPresetId, +}; +pub use value_objects::{ + Value, KeyMapping, + WidgetState, WidgetError, DisplayHint, + Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError, +}; +pub use events::DomainEvent; +pub use ports::{ConfigRepository, DataSourcePort, BroadcastPort, EventPublisher}; diff --git a/crates/domain/src/ports/broadcast.rs b/crates/domain/src/ports/broadcast.rs new file mode 100644 index 0000000..6a8a2f8 --- /dev/null +++ b/crates/domain/src/ports/broadcast.rs @@ -0,0 +1,17 @@ +use crate::entities::WidgetId; +use crate::value_objects::{Layout, WidgetState}; + +pub trait BroadcastPort { + type Error; + + async fn push_screen_update( + &self, + layout: &Layout, + widgets: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error>; + + async fn push_data_update( + &self, + updates: &[(WidgetId, WidgetState)], + ) -> Result<(), Self::Error>; +} diff --git a/crates/domain/src/ports/config_repository.rs b/crates/domain/src/ports/config_repository.rs new file mode 100644 index 0000000..7b9a745 --- /dev/null +++ b/crates/domain/src/ports/config_repository.rs @@ -0,0 +1,26 @@ +use crate::entities::{ + DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId, +}; +use crate::value_objects::Layout; + +pub trait ConfigRepository { + type Error; + + async fn get_widget(&self, id: WidgetId) -> Result, Self::Error>; + async fn list_widgets(&self) -> Result, Self::Error>; + async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error>; + async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error>; + + async fn get_data_source(&self, id: DataSourceId) -> Result, Self::Error>; + async fn list_data_sources(&self) -> Result, Self::Error>; + async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error>; + async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error>; + + async fn get_layout(&self) -> Result, Self::Error>; + async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error>; + + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error>; + async fn list_presets(&self) -> Result, Self::Error>; + async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error>; + async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error>; +} diff --git a/crates/domain/src/ports/data_source_port.rs b/crates/domain/src/ports/data_source_port.rs new file mode 100644 index 0000000..93e0379 --- /dev/null +++ b/crates/domain/src/ports/data_source_port.rs @@ -0,0 +1,8 @@ +use crate::entities::DataSource; +use crate::value_objects::Value; + +pub trait DataSourcePort { + type Error; + + async fn poll(&self, source: &DataSource) -> Result; +} diff --git a/crates/domain/src/ports/event.rs b/crates/domain/src/ports/event.rs new file mode 100644 index 0000000..51d9805 --- /dev/null +++ b/crates/domain/src/ports/event.rs @@ -0,0 +1,7 @@ +use crate::events::DomainEvent; + +pub trait EventPublisher { + type Error; + + async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..db2331f --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,9 @@ +mod config_repository; +mod data_source_port; +mod broadcast; +mod event; + +pub use config_repository::ConfigRepository; +pub use data_source_port::DataSourcePort; +pub use broadcast::BroadcastPort; +pub use event::EventPublisher; diff --git a/crates/domain/src/value_objects/key_mapping.rs b/crates/domain/src/value_objects/key_mapping.rs new file mode 100644 index 0000000..6c9a645 --- /dev/null +++ b/crates/domain/src/value_objects/key_mapping.rs @@ -0,0 +1,14 @@ +use super::Value; + +#[derive(Debug, Clone, PartialEq)] +pub struct KeyMapping { + pub source_path: String, + pub target_key: String, +} + +impl KeyMapping { + pub fn extract(&self, raw: &Value) -> Option<(String, Value)> { + let value = raw.get_path(&self.source_path)?; + Some((self.target_key.clone(), value.clone())) + } +} diff --git a/crates/domain/src/value_objects/layout.rs b/crates/domain/src/value_objects/layout.rs new file mode 100644 index 0000000..d9b6bfd --- /dev/null +++ b/crates/domain/src/value_objects/layout.rs @@ -0,0 +1,92 @@ +use std::collections::BTreeSet; +use crate::entities::WidgetId; + +#[derive(Debug, Clone, PartialEq)] +pub enum Sizing { + Fixed(u16), + Flex(u8), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Direction { + Row, + Column, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ContainerNode { + pub direction: Direction, + pub gap: u8, + pub padding: u8, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LayoutChild { + pub sizing: Sizing, + pub node: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LayoutNode { + Container(ContainerNode), + Leaf(WidgetId), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Layout { + pub root: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LayoutValidationError { + UnknownWidget(WidgetId), + EmptyContainer, +} + +impl Layout { + pub fn validate(&self, known_widgets: &BTreeSet) -> Vec { + let mut errors = Vec::new(); + Self::validate_node(&self.root, known_widgets, &mut errors); + errors + } + + fn validate_node( + node: &LayoutNode, + known: &BTreeSet, + errors: &mut Vec, + ) { + match node { + LayoutNode::Leaf(id) => { + if !known.contains(id) { + errors.push(LayoutValidationError::UnknownWidget(*id)); + } + } + LayoutNode::Container(c) => { + if c.children.is_empty() { + errors.push(LayoutValidationError::EmptyContainer); + } + for child in &c.children { + Self::validate_node(&child.node, known, errors); + } + } + } + } + + pub fn widget_ids(&self) -> BTreeSet { + let mut ids = BTreeSet::new(); + Self::collect_ids(&self.root, &mut ids); + ids + } + + fn collect_ids(node: &LayoutNode, ids: &mut BTreeSet) { + match node { + LayoutNode::Leaf(id) => { ids.insert(*id); } + LayoutNode::Container(c) => { + for child in &c.children { + Self::collect_ids(&child.node, ids); + } + } + } + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..70a2b31 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,11 @@ +mod value; +mod key_mapping; +mod widget_state; +mod layout; + +pub use value::Value; +pub use key_mapping::KeyMapping; +pub use widget_state::{WidgetState, WidgetError, DisplayHint}; +pub use layout::{ + Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError, +}; diff --git a/crates/domain/src/value_objects/value.rs b/crates/domain/src/value_objects/value.rs new file mode 100644 index 0000000..79428fc --- /dev/null +++ b/crates/domain/src/value_objects/value.rs @@ -0,0 +1,58 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(BTreeMap), +} + +impl Value { + pub fn estimated_size(&self) -> usize { + match self { + Value::Null | Value::Bool(_) => 1, + Value::Number(_) => 8, + Value::String(s) => s.len(), + Value::Array(arr) => arr.iter().map(|v| v.estimated_size()).sum(), + Value::Object(map) => map + .iter() + .map(|(k, v)| k.len() + v.estimated_size()) + .sum(), + } + } + + pub fn get_path(&self, path: &str) -> Option<&Value> { + let path = path.strip_prefix("$").unwrap_or(path); + let mut current = self; + + for raw_segment in path.split('.').filter(|s| !s.is_empty()) { + if let Some(bracket_pos) = raw_segment.find('[') { + let key = &raw_segment[..bracket_pos]; + let index_str = raw_segment[bracket_pos + 1..].strip_suffix(']')?; + let index: usize = index_str.parse().ok()?; + + if !key.is_empty() { + match current { + Value::Object(map) => current = map.get(key)?, + _ => return None, + } + } + + match current { + Value::Array(arr) => current = arr.get(index)?, + _ => return None, + } + } else { + match current { + Value::Object(map) => current = map.get(raw_segment)?, + _ => return None, + } + } + } + + Some(current) + } +} diff --git a/crates/domain/src/value_objects/widget_state.rs b/crates/domain/src/value_objects/widget_state.rs new file mode 100644 index 0000000..56cab12 --- /dev/null +++ b/crates/domain/src/value_objects/widget_state.rs @@ -0,0 +1,21 @@ +use std::collections::BTreeMap; +use super::Value; + +#[derive(Debug, Clone, PartialEq)] +pub struct WidgetState { + pub data: BTreeMap, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WidgetError { + SourceUnavailable, + ExtractionFailed, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DisplayHint { + IconValue, + TextBlock, + KeyValue, +} diff --git a/crates/domain/tests/data_source_tests.rs b/crates/domain/tests/data_source_tests.rs new file mode 100644 index 0000000..8ea0e3a --- /dev/null +++ b/crates/domain/tests/data_source_tests.rs @@ -0,0 +1,51 @@ +use std::time::Duration; +use domain::{DataSource, DataSourceConfig, DataSourceType, DataSourceValidationError}; + +fn make_source(source_type: DataSourceType, url: Option<&str>, poll: Duration) -> DataSource { + DataSource { + id: 1, + name: "test".into(), + source_type, + poll_interval: poll, + config: DataSourceConfig { + url: url.map(Into::into), + headers: vec![], + api_key: None, + }, + } +} + +#[test] +fn http_json_requires_url() { + let source = make_source(DataSourceType::HttpJson, None, Duration::from_secs(60)); + let errors = source.validate(); + assert!(errors.contains(&DataSourceValidationError::UrlRequired)); +} + +#[test] +fn webhook_does_not_allow_poll_interval() { + let source = make_source(DataSourceType::Webhook, None, Duration::from_secs(60)); + let errors = source.validate(); + assert!(errors.contains(&DataSourceValidationError::PollIntervalNotAllowed)); +} + +#[test] +fn webhook_with_zero_interval_is_valid() { + let source = make_source(DataSourceType::Webhook, None, Duration::ZERO); + let errors = source.validate(); + assert!(errors.is_empty()); +} + +#[test] +fn poll_based_source_requires_nonzero_interval() { + let source = make_source(DataSourceType::Weather, Some("https://api.weather.com"), Duration::ZERO); + let errors = source.validate(); + assert!(errors.contains(&DataSourceValidationError::PollIntervalRequired)); +} + +#[test] +fn valid_poll_source_has_no_errors() { + let source = make_source(DataSourceType::Rss, Some("https://feed.example.com"), Duration::from_secs(300)); + let errors = source.validate(); + assert!(errors.is_empty()); +} diff --git a/crates/domain/tests/key_mapping_tests.rs b/crates/domain/tests/key_mapping_tests.rs new file mode 100644 index 0000000..8c33e12 --- /dev/null +++ b/crates/domain/tests/key_mapping_tests.rs @@ -0,0 +1,33 @@ +use std::collections::BTreeMap; +use domain::{KeyMapping, Value}; + +#[test] +fn extracts_value_at_path_and_renames_key() { + let mapping = KeyMapping { + source_path: "$.main.temp".into(), + target_key: "temperature".into(), + }; + + let raw = Value::Object(BTreeMap::from([ + ("main".into(), Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ]))), + ])); + + let result = mapping.extract(&raw); + assert_eq!(result, Some(("temperature".into(), Value::Number(5.4)))); +} + +#[test] +fn returns_none_when_path_does_not_match() { + let mapping = KeyMapping { + source_path: "$.missing.path".into(), + target_key: "value".into(), + }; + + let raw = Value::Object(BTreeMap::from([ + ("other".into(), Value::Number(1.0)), + ])); + + assert_eq!(mapping.extract(&raw), None); +} diff --git a/crates/domain/tests/layout_tests.rs b/crates/domain/tests/layout_tests.rs new file mode 100644 index 0000000..ce65196 --- /dev/null +++ b/crates/domain/tests/layout_tests.rs @@ -0,0 +1,66 @@ +use std::collections::BTreeSet; +use domain::{ + ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, + WidgetId, +}; + +fn leaf(id: WidgetId) -> LayoutChild { + LayoutChild { + sizing: Sizing::Flex(1), + node: LayoutNode::Leaf(id), + } +} + +fn row(children: Vec) -> LayoutNode { + LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 0, + padding: 0, + children, + }) +} + +#[test] +fn validate_returns_empty_when_all_widgets_exist() { + let layout = Layout { root: row(vec![leaf(1), leaf(2)]) }; + let known = BTreeSet::from([1, 2]); + assert!(layout.validate(&known).is_empty()); +} + +#[test] +fn validate_reports_unknown_widget_ids() { + let layout = Layout { root: row(vec![leaf(1), leaf(99)]) }; + let known = BTreeSet::from([1]); + let errors = layout.validate(&known); + assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(99)]); +} + +#[test] +fn validate_reports_empty_container() { + let layout = Layout { root: row(vec![]) }; + let known = BTreeSet::new(); + let errors = layout.validate(&known); + assert_eq!(errors, vec![LayoutValidationError::EmptyContainer]); +} + +#[test] +fn validate_checks_nested_containers() { + let inner = LayoutChild { + sizing: Sizing::Flex(1), + node: row(vec![leaf(1), leaf(42)]), + }; + let layout = Layout { root: row(vec![inner, leaf(2)]) }; + let known = BTreeSet::from([1, 2]); + let errors = layout.validate(&known); + assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(42)]); +} + +#[test] +fn widget_ids_collects_all_leaf_ids() { + let inner = LayoutChild { + sizing: Sizing::Flex(1), + node: row(vec![leaf(3)]), + }; + let layout = Layout { root: row(vec![leaf(1), inner, leaf(2)]) }; + assert_eq!(layout.widget_ids(), BTreeSet::from([1, 2, 3])); +} diff --git a/crates/domain/tests/value_tests.rs b/crates/domain/tests/value_tests.rs new file mode 100644 index 0000000..c2c5ae2 --- /dev/null +++ b/crates/domain/tests/value_tests.rs @@ -0,0 +1,77 @@ +use std::collections::BTreeMap; +use domain::Value; + +#[test] +fn estimated_size_of_string_is_its_byte_length() { + let v = Value::String("hello".into()); + assert_eq!(v.estimated_size(), 5); +} + +#[test] +fn estimated_size_of_number_is_8_bytes() { + assert_eq!(Value::Number(3.14).estimated_size(), 8); +} + +#[test] +fn estimated_size_of_null_and_bool_is_1() { + assert_eq!(Value::Null.estimated_size(), 1); + assert_eq!(Value::Bool(true).estimated_size(), 1); +} + +#[test] +fn estimated_size_of_nested_structure_sums_recursively() { + let v = Value::Object(BTreeMap::from([ + ("key".into(), Value::String("value".into())), + ("num".into(), Value::Number(1.0)), + ])); + assert_eq!(v.estimated_size(), 19); +} + +#[test] +fn estimated_size_of_array_sums_elements() { + let v = Value::Array(vec![ + Value::String("abc".into()), + Value::Number(1.0), + ]); + assert_eq!(v.estimated_size(), 11); +} + +#[test] +fn get_path_returns_none_for_missing_key() { + let data = Value::Object(BTreeMap::from([ + ("main".into(), Value::Number(1.0)), + ])); + assert_eq!(data.get_path("$.missing"), None); +} + +#[test] +fn get_path_returns_none_when_traversing_non_object() { + let data = Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ])); + assert_eq!(data.get_path("$.temp.nested"), None); +} + +#[test] +fn get_path_accesses_array_by_index() { + let data = Value::Object(BTreeMap::from([ + ("items".into(), Value::Array(vec![ + Value::String("first".into()), + Value::String("second".into()), + ])), + ])); + assert_eq!( + data.get_path("$.items[1]"), + Some(&Value::String("second".into())) + ); +} + +#[test] +fn get_path_traverses_nested_object() { + let data = Value::Object(BTreeMap::from([ + ("main".into(), Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ]))), + ])); + assert_eq!(data.get_path("$.main.temp"), Some(&Value::Number(5.4))); +} diff --git a/crates/domain/tests/widget_tests.rs b/crates/domain/tests/widget_tests.rs new file mode 100644 index 0000000..2d3934f --- /dev/null +++ b/crates/domain/tests/widget_tests.rs @@ -0,0 +1,110 @@ +use std::collections::BTreeMap; +use domain::{DisplayHint, KeyMapping, Value, WidgetConfig}; + +#[test] +fn extract_applies_all_mappings_to_produce_widget_state() { + let config = WidgetConfig { + id: 1, + name: "weather".into(), + display_hint: DisplayHint::IconValue, + data_source_id: 1, + mappings: vec![ + KeyMapping { source_path: "$.main.temp".into(), target_key: "temperature".into() }, + KeyMapping { source_path: "$.weather[0].icon".into(), target_key: "icon".into() }, + ], + max_data_size: 2048, + }; + + let raw = Value::Object(BTreeMap::from([ + ("main".into(), Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ]))), + ("weather".into(), Value::Array(vec![ + Value::Object(BTreeMap::from([ + ("icon".into(), Value::String("cloud_rain".into())), + ])), + ])), + ])); + + let state = config.extract(&raw); + + assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4))); + assert_eq!(state.data.get("icon"), Some(&Value::String("cloud_rain".into()))); + assert_eq!(state.error, None); +} + +#[test] +fn extract_truncates_string_values_exceeding_max_data_size() { + let long_text = "a".repeat(3000); + let config = WidgetConfig { + id: 1, + name: "news".into(), + display_hint: DisplayHint::TextBlock, + data_source_id: 1, + mappings: vec![ + KeyMapping { source_path: "$.text".into(), target_key: "body".into() }, + ], + max_data_size: 100, + }; + + let raw = Value::Object(BTreeMap::from([ + ("text".into(), Value::String(long_text)), + ])); + + let state = config.extract(&raw); + match state.data.get("body") { + Some(Value::String(s)) => assert!(s.len() <= 100), + other => panic!("expected truncated string, got {:?}", other), + } +} + +#[test] +fn extract_respects_max_data_size_across_total_state() { + let config = WidgetConfig { + id: 1, + name: "big".into(), + display_hint: DisplayHint::TextBlock, + data_source_id: 1, + mappings: vec![ + KeyMapping { source_path: "$.a".into(), target_key: "a".into() }, + KeyMapping { source_path: "$.b".into(), target_key: "b".into() }, + KeyMapping { source_path: "$.c".into(), target_key: "c".into() }, + ], + max_data_size: 50, + }; + + let raw = Value::Object(BTreeMap::from([ + ("a".into(), Value::String("x".repeat(20))), + ("b".into(), Value::String("y".repeat(20))), + ("c".into(), Value::String("z".repeat(20))), + ])); + + let state = config.extract(&raw); + let total: usize = state.data.values().map(|v| v.estimated_size()).sum(); + assert!(total <= 50, "total size {total} exceeds max 50"); +} + +#[test] +fn extract_skips_mappings_that_dont_match() { + let config = WidgetConfig { + id: 1, + name: "weather".into(), + display_hint: DisplayHint::IconValue, + data_source_id: 1, + mappings: vec![ + KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, + KeyMapping { source_path: "$.missing".into(), target_key: "gone".into() }, + ], + max_data_size: 2048, + }; + + let raw = Value::Object(BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ])); + + let state = config.extract(&raw); + + assert_eq!(state.data.len(), 1); + assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4))); + assert_eq!(state.data.get("gone"), None); +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 0000000..3733435 --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "protocol" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +serde.workspace = true +postcard.workspace = true + +[dev-dependencies] diff --git a/crates/protocol/src/frame.rs b/crates/protocol/src/frame.rs new file mode 100644 index 0000000..7791618 --- /dev/null +++ b/crates/protocol/src/frame.rs @@ -0,0 +1,54 @@ +use serde::{Serialize, Deserialize}; +use super::wire::{WireLayoutNode, WireWidgetState, WireDisplayHint}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WidgetDescriptor { + pub id: u16, + pub display_hint: WireDisplayHint, + pub state: WireWidgetState, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ServerMessage { + ScreenUpdate { + layout: WireLayoutNode, + widgets: Vec, + }, + DataUpdate { + widgets: Vec, + }, + Heartbeat, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ClientMessage { + Heartbeat, +} + +pub const MAX_FRAME_SIZE: usize = 32 * 1024; + +pub fn encode(msg: &ServerMessage) -> Result, postcard::Error> { + let payload = postcard::to_allocvec(msg)?; + let len = (payload.len() as u32).to_be_bytes(); + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&len); + frame.extend_from_slice(&payload); + Ok(frame) +} + +pub fn decode_server_message(payload: &[u8]) -> Result { + postcard::from_bytes(payload) +} + +pub fn encode_client(msg: &ClientMessage) -> Result, postcard::Error> { + let payload = postcard::to_allocvec(msg)?; + let len = (payload.len() as u32).to_be_bytes(); + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&len); + frame.extend_from_slice(&payload); + Ok(frame) +} + +pub fn decode_client_message(payload: &[u8]) -> Result { + postcard::from_bytes(payload) +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 0000000..2f9cacf --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,13 @@ +mod wire; +mod frame; + +pub use wire::{ + WireValue, WireWidgetState, WireWidgetError, WireDisplayHint, + WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing, + WireKeyValue, +}; +pub use frame::{ + ServerMessage, ClientMessage, WidgetDescriptor, + encode, decode_server_message, encode_client, decode_client_message, + MAX_FRAME_SIZE, +}; diff --git a/crates/protocol/src/wire.rs b/crates/protocol/src/wire.rs new file mode 100644 index 0000000..81ee3db --- /dev/null +++ b/crates/protocol/src/wire.rs @@ -0,0 +1,232 @@ +use std::collections::BTreeMap; +use serde::{Serialize, Deserialize}; +use domain::value_objects::{ + ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value, + WidgetError, WidgetState, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireValue { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(BTreeMap), +} + +impl From<&Value> for WireValue { + fn from(v: &Value) -> Self { + match v { + Value::Null => WireValue::Null, + Value::Bool(b) => WireValue::Bool(*b), + Value::Number(n) => WireValue::Number(*n), + Value::String(s) => WireValue::String(s.clone()), + Value::Array(arr) => WireValue::Array(arr.iter().map(Into::into).collect()), + Value::Object(map) => { + WireValue::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect()) + } + } + } +} + +impl From for Value { + fn from(w: WireValue) -> Self { + match w { + WireValue::Null => Value::Null, + WireValue::Bool(b) => Value::Bool(b), + WireValue::Number(n) => Value::Number(n), + WireValue::String(s) => Value::String(s), + WireValue::Array(arr) => Value::Array(arr.into_iter().map(Into::into).collect()), + WireValue::Object(map) => { + Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireWidgetError { + SourceUnavailable, + ExtractionFailed, +} + +impl From<&WidgetError> for WireWidgetError { + fn from(e: &WidgetError) -> Self { + match e { + WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable, + WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed, + } + } +} + +impl From for WidgetError { + fn from(w: WireWidgetError) -> Self { + match w { + WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable, + WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WireKeyValue { + pub key: String, + pub value: WireValue, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WireWidgetState { + pub data: Vec, + pub error: Option, +} + +impl From<&WidgetState> for WireWidgetState { + fn from(s: &WidgetState) -> Self { + WireWidgetState { + data: s.data.iter().map(|(k, v)| WireKeyValue { + key: k.clone(), + value: v.into(), + }).collect(), + error: s.error.as_ref().map(Into::into), + } + } +} + +impl From for WidgetState { + fn from(w: WireWidgetState) -> Self { + WidgetState { + data: w.data.into_iter().map(|kv| (kv.key, kv.value.into())).collect(), + error: w.error.map(Into::into), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireDisplayHint { + IconValue, + TextBlock, + KeyValue, +} + +impl From<&DisplayHint> for WireDisplayHint { + fn from(h: &DisplayHint) -> Self { + match h { + DisplayHint::IconValue => WireDisplayHint::IconValue, + DisplayHint::TextBlock => WireDisplayHint::TextBlock, + DisplayHint::KeyValue => WireDisplayHint::KeyValue, + } + } +} + +impl From for DisplayHint { + fn from(w: WireDisplayHint) -> Self { + match w { + WireDisplayHint::IconValue => DisplayHint::IconValue, + WireDisplayHint::TextBlock => DisplayHint::TextBlock, + WireDisplayHint::KeyValue => DisplayHint::KeyValue, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireSizing { + Fixed(u16), + Flex(u8), +} + +impl From<&Sizing> for WireSizing { + fn from(s: &Sizing) -> Self { + match s { + Sizing::Fixed(px) => WireSizing::Fixed(*px), + Sizing::Flex(w) => WireSizing::Flex(*w), + } + } +} + +impl From for Sizing { + fn from(w: WireSizing) -> Self { + match w { + WireSizing::Fixed(px) => Sizing::Fixed(px), + WireSizing::Flex(weight) => Sizing::Flex(weight), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireDirection { + Row, + Column, +} + +impl From<&Direction> for WireDirection { + fn from(d: &Direction) -> Self { + match d { + Direction::Row => WireDirection::Row, + Direction::Column => WireDirection::Column, + } + } +} + +impl From for Direction { + fn from(w: WireDirection) -> Self { + match w { + WireDirection::Row => Direction::Row, + WireDirection::Column => Direction::Column, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WireContainerNode { + pub direction: WireDirection, + pub gap: u8, + pub padding: u8, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WireLayoutChild { + pub sizing: WireSizing, + pub node: WireLayoutNode, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WireLayoutNode { + Container(WireContainerNode), + Leaf(u16), +} + +impl From<&LayoutNode> for WireLayoutNode { + fn from(n: &LayoutNode) -> Self { + match n { + LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id), + LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode { + direction: (&c.direction).into(), + gap: c.gap, + padding: c.padding, + children: c.children.iter().map(|ch| WireLayoutChild { + sizing: (&ch.sizing).into(), + node: (&ch.node).into(), + }).collect(), + }), + } + } +} + +impl From for LayoutNode { + fn from(w: WireLayoutNode) -> Self { + match w { + WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id), + WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode { + direction: c.direction.into(), + gap: c.gap, + padding: c.padding, + children: c.children.into_iter().map(|ch| LayoutChild { + sizing: ch.sizing.into(), + node: ch.node.into(), + }).collect(), + }), + } + } +} diff --git a/crates/protocol/tests/conversion_tests.rs b/crates/protocol/tests/conversion_tests.rs new file mode 100644 index 0000000..93b91eb --- /dev/null +++ b/crates/protocol/tests/conversion_tests.rs @@ -0,0 +1,82 @@ +use std::collections::BTreeMap; +use domain::{ + Value, WidgetState, WidgetError, DisplayHint, + LayoutNode, ContainerNode, LayoutChild, Direction, Sizing, +}; +use protocol::{ + WireValue, WireWidgetState, WireWidgetError, WireDisplayHint, + WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing, + WireKeyValue, +}; + +#[test] +fn value_converts_to_wire_and_back() { + let original = Value::Object(BTreeMap::from([ + ("items".into(), Value::Array(vec![ + Value::String("hello".into()), + Value::Number(42.0), + Value::Bool(true), + Value::Null, + ])), + ])); + + let wire: WireValue = (&original).into(); + let roundtripped: Value = wire.into(); + assert_eq!(original, roundtripped); +} + +#[test] +fn widget_state_with_error_converts_to_wire_and_back() { + let original = WidgetState { + data: BTreeMap::from([ + ("temp".into(), Value::Number(5.4)), + ]), + error: Some(WidgetError::SourceUnavailable), + }; + + let wire: WireWidgetState = (&original).into(); + let roundtripped: WidgetState = wire.into(); + assert_eq!(original, roundtripped); +} + +#[test] +fn layout_tree_converts_to_wire_and_back() { + let original = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 4, + padding: 2, + children: vec![ + LayoutChild { + sizing: Sizing::Flex(1), + node: LayoutNode::Leaf(1), + }, + LayoutChild { + sizing: Sizing::Fixed(100), + node: LayoutNode::Container(ContainerNode { + direction: Direction::Column, + gap: 2, + padding: 0, + children: vec![ + LayoutChild { + sizing: Sizing::Flex(1), + node: LayoutNode::Leaf(2), + }, + ], + }), + }, + ], + }); + + let wire: WireLayoutNode = (&original).into(); + let roundtripped: LayoutNode = wire.into(); + assert_eq!(original, roundtripped); +} + +#[test] +fn display_hint_converts_to_wire_and_back() { + for hint in [DisplayHint::IconValue, DisplayHint::TextBlock, DisplayHint::KeyValue] { + let wire: WireDisplayHint = (&hint).into(); + let roundtripped: DisplayHint = wire.into(); + assert_eq!(hint, roundtripped); + } +} diff --git a/crates/protocol/tests/round_trip_tests.rs b/crates/protocol/tests/round_trip_tests.rs new file mode 100644 index 0000000..49d2509 --- /dev/null +++ b/crates/protocol/tests/round_trip_tests.rs @@ -0,0 +1,94 @@ +use protocol::{ + ServerMessage, ClientMessage, WidgetDescriptor, + WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild, + WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue, + encode, decode_server_message, encode_client, decode_client_message, +}; + +#[test] +fn screen_update_round_trips() { + let msg = ServerMessage::ScreenUpdate { + layout: WireLayoutNode::Container(WireContainerNode { + direction: WireDirection::Row, + gap: 4, + padding: 2, + children: vec![ + WireLayoutChild { + sizing: WireSizing::Flex(1), + node: WireLayoutNode::Leaf(1), + }, + WireLayoutChild { + sizing: WireSizing::Fixed(80), + node: WireLayoutNode::Leaf(2), + }, + ], + }), + widgets: vec![ + WidgetDescriptor { + id: 1, + display_hint: WireDisplayHint::IconValue, + state: WireWidgetState { + data: vec![ + WireKeyValue { key: "temperature".into(), value: WireValue::String("5.4°C".into()) }, + WireKeyValue { key: "icon".into(), value: WireValue::String("cloud_rain".into()) }, + ], + error: None, + }, + }, + ], + }; + + let frame = encode(&msg).unwrap(); + let payload = &frame[4..]; + let decoded = decode_server_message(payload).unwrap(); + assert_eq!(msg, decoded); +} + +#[test] +fn data_update_round_trips() { + let msg = ServerMessage::DataUpdate { + widgets: vec![ + WidgetDescriptor { + id: 3, + display_hint: WireDisplayHint::TextBlock, + state: WireWidgetState { + data: vec![ + WireKeyValue { key: "body".into(), value: WireValue::String("Breaking news...".into()) }, + ], + error: None, + }, + }, + ], + }; + + let frame = encode(&msg).unwrap(); + let payload = &frame[4..]; + let decoded = decode_server_message(payload).unwrap(); + assert_eq!(msg, decoded); +} + +#[test] +fn server_heartbeat_round_trips() { + let msg = ServerMessage::Heartbeat; + let frame = encode(&msg).unwrap(); + let payload = &frame[4..]; + let decoded = decode_server_message(payload).unwrap(); + assert_eq!(msg, decoded); +} + +#[test] +fn client_heartbeat_round_trips() { + let msg = ClientMessage::Heartbeat; + let frame = encode_client(&msg).unwrap(); + let payload = &frame[4..]; + let decoded = decode_client_message(payload).unwrap(); + assert_eq!(msg, decoded); +} + +#[test] +fn frame_has_correct_length_prefix() { + let msg = ServerMessage::Heartbeat; + let frame = encode(&msg).unwrap(); + let len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + assert_eq!(len, frame.len() - 4); +}