diff --git a/Cargo.lock b/Cargo.lock index 4cba101a..d66918cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,14 +95,13 @@ dependencies = [ [[package]] name = "aimdb-data-contracts" -version = "1.0.0" +version = "0.1.0" dependencies = [ "aimdb-core", "aimdb-executor", "rand 0.8.5", "serde", "serde_json", - "ts-rs", ] [[package]] @@ -950,7 +949,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "embassy-embedded-hal" -version = "0.5.0" +version = "0.6.0" dependencies = [ "defmt 1.0.1", "embassy-futures", @@ -967,7 +966,7 @@ dependencies = [ [[package]] name = "embassy-executor" -version = "0.9.1" +version = "0.10.0" dependencies = [ "cordyceps", "cortex-m", @@ -980,7 +979,7 @@ dependencies = [ [[package]] name = "embassy-executor-macros" -version = "0.7.0" +version = "0.8.0" dependencies = [ "darling", "proc-macro2", @@ -1074,7 +1073,7 @@ dependencies = [ [[package]] name = "embassy-net" -version = "0.8.0" +version = "0.9.0" dependencies = [ "defmt 1.0.1", "document-features", @@ -1097,7 +1096,7 @@ dependencies = [ [[package]] name = "embassy-stm32" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aligned", "bit_field", @@ -1149,7 +1148,7 @@ dependencies = [ [[package]] name = "embassy-sync" -version = "0.7.2" +version = "0.8.0" dependencies = [ "cfg-if", "critical-section", @@ -1162,7 +1161,7 @@ dependencies = [ [[package]] name = "embassy-time" -version = "0.5.0" +version = "0.5.1" dependencies = [ "cfg-if", "critical-section", @@ -1200,7 +1199,7 @@ dependencies = [ [[package]] name = "embassy-usb-synopsys-otg" -version = "0.3.1" +version = "0.3.2" dependencies = [ "critical-section", "defmt 1.0.1", @@ -3083,8 +3082,8 @@ dependencies = [ [[package]] name = "stm32-metapac" -version = "19.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-0f4c948b5c81ebe421fe902857ccdb39029651f6#da4c008381dbdbee62135dee88aa2e7d7c4e4992" +version = "20.0.0" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-21b543f6cab91a2966c0a4247e418d73a87a4ae7#497b4312537a7f4dd1473eba33a261703cd5da07" dependencies = [ "cortex-m", "cortex-m-rt", @@ -3230,15 +3229,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "testing_table" version = "0.3.0" @@ -3631,29 +3621,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ts-rs" -version = "10.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" -dependencies = [ - "lazy_static", - "thiserror 2.0.17", - "ts-rs-macros", -] - -[[package]] -name = "ts-rs-macros" -version = "10.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", - "termcolor", -] - [[package]] name = "tungstenite" version = "0.26.2" @@ -3940,7 +3907,9 @@ version = "0.1.0" dependencies = [ "aimdb-core", "aimdb-data-contracts", + "rand 0.8.5", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 633ec811..9c689042 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ tokio-test = "0.4" aimdb-tokio-adapter = { path = "./aimdb-tokio-adapter" } # Embassy ecosystem for embedded async -embassy-stm32 = { version = "0.5.0", path = "./_external/embassy/embassy-stm32", features = [ +embassy-stm32 = { version = "0.6.0", path = "./_external/embassy/embassy-stm32", features = [ "defmt", "stm32h563zi", "memory-x", @@ -94,27 +94,27 @@ embassy-stm32 = { version = "0.5.0", path = "./_external/embassy/embassy-stm32", "unstable-pac", "low-power", ] } -embassy-sync = { version = "0.7.2", path = "./_external/embassy/embassy-sync", features = [ +embassy-sync = { version = "0.8.0", path = "./_external/embassy/embassy-sync", features = [ "defmt", ] } -embassy-executor = { version = "0.9.1", path = "./_external/embassy/embassy-executor", features = [ - "arch-cortex-m", +embassy-executor = { version = "0.10.0", path = "./_external/embassy/embassy-executor", features = [ + "platform-cortex-m", "executor-thread", "defmt", ] } -embassy-time = { version = "0.5.0", path = "./_external/embassy/embassy-time", features = [ +embassy-time = { version = "0.5.1", path = "./_external/embassy/embassy-time", features = [ "defmt", "defmt-timestamp-uptime", "tick-hz-32_768", ] } -embassy-net = { version = "0.8.0", path = "./_external/embassy/embassy-net", features = [ +embassy-net = { version = "0.9.0", path = "./_external/embassy/embassy-net", features = [ "defmt", "tcp", "dhcpv4", "medium-ethernet", "proto-ipv6", ] } -embassy-usb = { version = "0.5.1", path = "./_external/embassy/embassy-usb", features = [ +embassy-usb = { version = "0.6.0", path = "./_external/embassy/embassy-usb", features = [ "defmt", ] } embassy-futures = { version = "0.1.2", path = "./_external/embassy/embassy-futures" } diff --git a/Makefile b/Makefile index 120c024a..bedf6f5f 100644 --- a/Makefile +++ b/Makefile @@ -332,73 +332,77 @@ publish: else \ printf "$(BLUE)Running in CI mode - skipping confirmation$(NC)\n"; \ fi - @printf "$(YELLOW) → Publishing aimdb-executor (1/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-executor (1/18)$(NC)\n" @cargo publish -p aimdb-executor @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-derive (2/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-derive (2/18)$(NC)\n" @cargo publish -p aimdb-derive @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-codegen (3/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-codegen (3/18)$(NC)\n" @cargo publish -p aimdb-codegen @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-core (4/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-core (4/18)$(NC)\n" @cargo publish -p aimdb-core @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-tokio-adapter (5/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-data-contracts (5/18)$(NC)\n" + @cargo publish -p aimdb-data-contracts + @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" + @sleep 10 + @printf "$(YELLOW) → Publishing aimdb-tokio-adapter (6/18)$(NC)\n" @cargo publish -p aimdb-tokio-adapter @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-embassy-adapter (6/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-embassy-adapter (7/18)$(NC)\n" @cargo publish -p aimdb-embassy-adapter --no-verify @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-client (7/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-client (8/18)$(NC)\n" @cargo publish -p aimdb-client @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-sync (8/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-sync (9/18)$(NC)\n" @cargo publish -p aimdb-sync @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-persistence (9/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-persistence (10/18)$(NC)\n" @cargo publish -p aimdb-persistence @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-persistence-sqlite (10/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-persistence-sqlite (11/18)$(NC)\n" @cargo publish -p aimdb-persistence-sqlite @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-mqtt-connector (11/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-mqtt-connector (12/18)$(NC)\n" @cargo publish -p aimdb-mqtt-connector @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-knx-connector (12/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-knx-connector (13/18)$(NC)\n" @cargo publish -p aimdb-knx-connector @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-ws-protocol (13/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-ws-protocol (14/18)$(NC)\n" @cargo publish -p aimdb-ws-protocol @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-websocket-connector (14/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-websocket-connector (15/18)$(NC)\n" @cargo publish -p aimdb-websocket-connector @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-wasm-adapter (15/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-wasm-adapter (16/18)$(NC)\n" @cargo publish -p aimdb-wasm-adapter --no-verify @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-cli (16/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-cli (17/18)$(NC)\n" @cargo publish -p aimdb-cli @printf "$(YELLOW) → Waiting 10s for crates.io propagation...$(NC)\n" @sleep 10 - @printf "$(YELLOW) → Publishing aimdb-mcp (17/17)$(NC)\n" + @printf "$(YELLOW) → Publishing aimdb-mcp (18/18)$(NC)\n" @cargo publish -p aimdb-mcp - @printf "$(GREEN)✓ All 17 crates published successfully!$(NC)\n" + @printf "$(GREEN)✓ All 18 crates published successfully!$(NC)\n" @printf "$(BLUE)🎉 AimDB v$(shell grep '^version' Cargo.toml | head -1 | cut -d '"' -f 2) is now live on crates.io!$(NC)\n" ## Convenience commands diff --git a/_external/embassy b/_external/embassy index e63316a9..e7a61a82 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit e63316a9409d15a460a9d2821c344b8eaf93a498 +Subproject commit e7a61a828cb694f1a33abac9a33d75028d1a21db diff --git a/_external/knx-pico b/_external/knx-pico index e60ca5b1..2a1d14a9 160000 --- a/_external/knx-pico +++ b/_external/knx-pico @@ -1 +1 @@ -Subproject commit e60ca5b152a6a14c7ecc6de80aa0e51b56e17ccb +Subproject commit 2a1d14a958991826694652cf9f8ad6ed955cb17f diff --git a/_external/mountain-mqtt b/_external/mountain-mqtt index 67d01f79..10754e83 160000 --- a/_external/mountain-mqtt +++ b/_external/mountain-mqtt @@ -1 +1 @@ -Subproject commit 67d01f79ea3b31054b2e5dbeedbaea03967a174b +Subproject commit 10754e831f3140f7a90e8ce912d1a1483c875f91 diff --git a/aimdb-data-contracts/Cargo.toml b/aimdb-data-contracts/Cargo.toml index e1523910..2e845cd3 100644 --- a/aimdb-data-contracts/Cargo.toml +++ b/aimdb-data-contracts/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "aimdb-data-contracts" -version.workspace = true +version = "0.1.0" edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true -description = "Data contracts for AimDB: portable schemas with built-in versioning and simulation support" +description = "Trait definitions for AimDB data contracts: SchemaType, Streamable, Observable, Linkable, Simulatable, Migratable" keywords = ["aimdb", "iot", "edge", "schema", "contracts"] categories = ["embedded", "no-std"] @@ -18,7 +18,6 @@ linkable = ["alloc", "serde_json"] simulatable = ["rand"] migratable = ["std", "serde_json"] observable = ["alloc", "aimdb-core", "aimdb-executor"] -ts = ["std", "ts-rs"] [dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } @@ -26,10 +25,10 @@ serde_json = { version = "1.0", optional = true } # Optional dependencies for observable feature (log_tap function) # Note: aimdb-core requires alloc feature for core functionality -aimdb-core = { path = "../aimdb-core", optional = true, default-features = false, features = [ +aimdb-core = { version = "1.0.0", path = "../aimdb-core", optional = true, default-features = false, features = [ "alloc", ] } -aimdb-executor = { path = "../aimdb-executor", optional = true, default-features = false } +aimdb-executor = { version = "0.1.0", path = "../aimdb-executor", optional = true, default-features = false } [dependencies.rand] version = "0.8" @@ -37,9 +36,5 @@ optional = true default-features = false features = ["std_rng"] -[dependencies.ts-rs] -version = "10" -optional = true - [dev-dependencies] serde_json = "1.0" diff --git a/aimdb-data-contracts/src/contracts/mod.rs b/aimdb-data-contracts/src/contracts/mod.rs deleted file mode 100644 index 18619228..00000000 --- a/aimdb-data-contracts/src/contracts/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Standard data contracts - -pub mod humidity; -pub mod location; -pub mod temperature; - -pub use humidity::Humidity; -pub use location::GpsLocation; -pub use temperature::{Temperature, TemperatureV1}; diff --git a/aimdb-data-contracts/src/contracts/temperature.rs b/aimdb-data-contracts/src/contracts/temperature.rs deleted file mode 100644 index 34c02a1d..00000000 --- a/aimdb-data-contracts/src/contracts/temperature.rs +++ /dev/null @@ -1,642 +0,0 @@ -//! Temperature sensor schema -//! -//! # Schema Evolution -//! -//! This module demonstrates backward-compatible schema migration with -//! **version-aware payloads** for decoupled deployment: -//! -//! - **v1** (legacy): `{ "schema_version": 1, "temp": f32, "timestamp": u64, "unit": "C"|"F"|"K" }` -//! - **v2** (current): `{ "schema_version": 2, "celsius": f32, "timestamp": u64 }` -//! -//! The `MigrationChain` impl (via `migration_chain!`) reads the `schema_version` -//! from the payload and migrates automatically, allowing nodes and hubs to be updated independently. - -extern crate alloc; - -use crate::{Observable, SchemaType, Settable}; -use serde::{Deserialize, Serialize}; - -#[cfg(feature = "linkable")] -use crate::Linkable; - -#[cfg(feature = "simulatable")] -use crate::{Simulatable, SimulationConfig}; - -#[cfg(feature = "migratable")] -use crate::{MigrationError, MigrationStep}; - -#[cfg(feature = "ts")] -use ts_rs::TS; - -/// Temperature sensor reading in Celsius -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct Temperature { - /// Schema version (always 2 for current format) - #[serde(default = "default_v2_version")] - pub schema_version: u32, - /// Temperature in degrees Celsius - pub celsius: f32, - /// Unix timestamp (milliseconds) when reading was taken - pub timestamp: u64, -} - -fn default_v2_version() -> u32 { - 2 -} - -impl Temperature { - /// Create a new Temperature reading (current schema version) - pub fn new(celsius: f32, timestamp: u64) -> Self { - Self { - schema_version: 2, - celsius, - timestamp, - } - } -} - -impl SchemaType for Temperature { - const NAME: &'static str = "temperature"; - const VERSION: u32 = 2; // v2: celsius + timestamp (v1 had temp + timestamp + unit) -} - -// ═══════════════════════════════════════════════════════════════════ -// LEGACY v1 SCHEMA (for migration purposes) -// ═══════════════════════════════════════════════════════════════════ - -/// Legacy Temperature schema (v1) - for migration from old nodes. -/// -/// v1 format: `{ "schema_version": 1, "temp": f32, "timestamp": u64, "unit": "C"|"F"|"K" }` -/// -/// This is kept in the contracts crate so migration logic can be tested -/// in CI/CD, ensuring backward compatibility is maintained. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TemperatureV1 { - /// Schema version marker (always 1 for v1) - #[serde(default = "default_v1_version")] - pub schema_version: u32, - /// Temperature value in the unit specified by `unit` field - pub temp: f32, - /// Unix timestamp (milliseconds) - pub timestamp: u64, - /// Unit: "C" (Celsius), "F" (Fahrenheit), or "K" (Kelvin) - pub unit: alloc::string::String, -} - -fn default_v1_version() -> u32 { - 1 -} - -impl TemperatureV1 { - /// Create a new v1 temperature reading - pub fn new(temp: f32, timestamp: u64, unit: &str) -> Self { - Self { - schema_version: 1, - temp, - timestamp, - unit: alloc::string::String::from(unit), - } - } - - /// Convert this v1 reading to v2 format (always Celsius) - pub fn to_v2(&self) -> Temperature { - let celsius = match self.unit.as_str() { - "F" => (self.temp - 32.0) * 5.0 / 9.0, - "K" => self.temp - 273.15, - _ => self.temp, // "C" or unknown defaults to Celsius - }; - Temperature { - schema_version: 2, - celsius, - timestamp: self.timestamp, - } - } -} - -impl SchemaType for TemperatureV1 { - const NAME: &'static str = "temperature_v1"; - const VERSION: u32 = 1; -} - -#[cfg(feature = "linkable")] -impl Linkable for TemperatureV1 { - fn from_bytes(data: &[u8]) -> Result { - serde_json::from_slice(data).map_err(|e| alloc::string::ToString::to_string(&e)) - } - - fn to_bytes(&self) -> Result, alloc::string::String> { - serde_json::to_vec(self).map_err(|e| alloc::string::ToString::to_string(&e)) - } -} - -// ═══════════════════════════════════════════════════════════════════ -// TYPE-SAFE MIGRATION (v1 → v2) -// ═══════════════════════════════════════════════════════════════════ - -/// Migration step: Temperature v1 (temp + unit) → v2 (celsius only) -#[cfg(feature = "migratable")] -pub struct TemperatureV1ToV2; - -#[cfg(feature = "migratable")] -impl MigrationStep for TemperatureV1ToV2 { - type Older = TemperatureV1; - type Newer = Temperature; - const FROM_VERSION: u32 = 1; - const TO_VERSION: u32 = 2; - - fn up(v1: TemperatureV1) -> Result { - Ok(v1.to_v2()) - } - - fn down(v2: Temperature) -> Result { - Ok(TemperatureV1 { - schema_version: 1, - temp: v2.celsius, - timestamp: v2.timestamp, - unit: alloc::string::String::from("C"), - }) - } -} - -#[cfg(feature = "migratable")] -crate::migration_chain! { - type Current = Temperature; - version_field = "schema_version"; - steps { - TemperatureV1ToV2: TemperatureV1 => Temperature, - } -} - -impl Observable for Temperature { - type Signal = f32; - const ICON: &'static str = "🌡️"; - const UNIT: &'static str = "°C"; - - fn signal(&self) -> f32 { - self.celsius - } - - fn format_log(&self, node_id: &str) -> alloc::string::String { - alloc::format!( - "{} [{}] Temperature: {:.1}{} at {}", - Self::ICON, - node_id, - self.celsius, - Self::UNIT, - self.timestamp - ) - } -} - -#[cfg(feature = "simulatable")] -impl Simulatable for Temperature { - /// Simulate temperature readings with random walk behavior. - /// - /// # Config params interpretation - /// - `base`: Center temperature value (default: 22.0°C) - /// - `variation`: Maximum deviation from base (default: 3.0°C) - /// - `step`: Random walk step multiplier (default: 0.2) - /// - `trend`: Linear trend per sample (default: 0.0) - fn simulate( - config: &SimulationConfig, - previous: Option<&Self>, - rng: &mut R, - timestamp: u64, - ) -> Self { - let base = config.params.base as f32; - let variation = config.params.variation as f32; - let step = config.params.step as f32; - let trend = config.params.trend as f32; - - // Random walk: small delta from previous value, clamped to range - let current = match previous { - Some(prev) => { - let delta = (rng.gen::() - 0.5) * variation * step; - (prev.celsius + delta + trend).clamp(base - variation, base + variation) - } - None => base + (rng.gen::() - 0.5) * variation, - }; - - Temperature { - schema_version: 2, - celsius: current, - timestamp, - } - } -} - -impl Settable for Temperature { - type Value = f32; - - fn set(value: Self::Value, timestamp: u64) -> Self { - Temperature { - schema_version: 2, - celsius: value, - timestamp, - } - } -} - -// ═══════════════════════════════════════════════════════════════════ -// LINKABLE WITH MIGRATION SUPPORT -// ═══════════════════════════════════════════════════════════════════ - -#[cfg(all(feature = "linkable", feature = "migratable"))] -impl Linkable for Temperature { - fn from_bytes(data: &[u8]) -> Result { - use crate::MigrationChain; - Self::migrate_from_bytes(data).map_err(|e| alloc::format!("Migration error: {}", e)) - } - - fn to_bytes(&self) -> Result, alloc::string::String> { - serde_json::to_vec(self).map_err(|e| alloc::string::ToString::to_string(&e)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_settable() { - let temp = Temperature::set(22.5, 1704326400000); - assert_eq!(temp.schema_version, 2); - assert_eq!(temp.celsius, 22.5); - assert_eq!(temp.timestamp, 1704326400000); - } - - #[test] - fn test_schema_name() { - assert_eq!(Temperature::NAME, "temperature"); - } - - #[test] - fn test_schema_version() { - assert_eq!(Temperature::VERSION, 2); - } - - // ═══════════════════════════════════════════════════════════════════ - // v1 → v2 MIGRATION TESTS - // ═══════════════════════════════════════════════════════════════════ - - #[test] - fn test_v1_to_v2_celsius() { - let v1 = TemperatureV1::new(22.5, 1704326400000, "C"); - let v2 = v1.to_v2(); - assert_eq!(v2.schema_version, 2); - assert_eq!(v2.celsius, 22.5); - assert_eq!(v2.timestamp, 1704326400000); - } - - #[test] - fn test_v1_to_v2_fahrenheit() { - // 68°F = 20°C - let v1 = TemperatureV1::new(68.0, 1704326400000, "F"); - let v2 = v1.to_v2(); - assert!( - (v2.celsius - 20.0).abs() < 0.01, - "Expected ~20°C, got {}", - v2.celsius - ); - } - - #[test] - fn test_v1_to_v2_kelvin() { - // 293.15K = 20°C - let v1 = TemperatureV1::new(293.15, 1704326400000, "K"); - let v2 = v1.to_v2(); - assert!( - (v2.celsius - 20.0).abs() < 0.01, - "Expected ~20°C, got {}", - v2.celsius - ); - } - - #[test] - fn test_v1_to_v2_unknown_unit_defaults_to_celsius() { - let v1 = TemperatureV1::new(22.5, 1704326400000, "X"); - let v2 = v1.to_v2(); - assert_eq!(v2.celsius, 22.5); - } - - // ═══════════════════════════════════════════════════════════════════ - // TYPE-SAFE MIGRATION STEP TESTS - // ═══════════════════════════════════════════════════════════════════ - - #[cfg(feature = "migratable")] - #[test] - fn test_migration_step_up_celsius() { - use crate::MigrationStep; - - let v1 = TemperatureV1::new(22.5, 1704326400000, "C"); - let v2 = TemperatureV1ToV2::up(v1).unwrap(); - assert_eq!(v2.celsius, 22.5); - assert_eq!(v2.timestamp, 1704326400000); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migration_step_up_fahrenheit() { - use crate::MigrationStep; - - let v1 = TemperatureV1::new(68.0, 1704326400000, "F"); - let v2 = TemperatureV1ToV2::up(v1).unwrap(); - assert!( - (v2.celsius - 20.0).abs() < 0.01, - "Expected ~20°C, got {}", - v2.celsius - ); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migration_step_up_kelvin() { - use crate::MigrationStep; - - let v1 = TemperatureV1::new(293.15, 1704326400000, "K"); - let v2 = TemperatureV1ToV2::up(v1).unwrap(); - assert!( - (v2.celsius - 20.0).abs() < 0.01, - "Expected ~20°C, got {}", - v2.celsius - ); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migration_step_down() { - use crate::MigrationStep; - - let v2 = Temperature::new(22.5, 1704326400000); - let v1 = TemperatureV1ToV2::down(v2).unwrap(); - assert_eq!(v1.temp, 22.5); - assert_eq!(v1.timestamp, 1704326400000); - assert_eq!(v1.unit, "C"); - assert_eq!(v1.schema_version, 1); - } - - // ═══════════════════════════════════════════════════════════════════ - // MIGRATION CHAIN TESTS (upgrade from bytes) - // ═══════════════════════════════════════════════════════════════════ - - #[cfg(feature = "migratable")] - #[test] - fn test_migrate_from_bytes_v1() { - use crate::MigrationChain; - - let json = r#"{"schema_version":1,"temp":22.5,"timestamp":1704326400000,"unit":"C"}"#; - let temp = Temperature::migrate_from_bytes(json.as_bytes()).unwrap(); - assert_eq!(temp.celsius, 22.5); - assert_eq!(temp.timestamp, 1704326400000); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migrate_from_bytes_v2_no_migration() { - use crate::MigrationChain; - - let json = r#"{"schema_version":2,"celsius":22.5,"timestamp":1704326400000}"#; - let temp = Temperature::migrate_from_bytes(json.as_bytes()).unwrap(); - assert_eq!(temp.celsius, 22.5); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migrate_from_bytes_version_too_new() { - use crate::MigrationChain; - - let json = r#"{"schema_version":99,"celsius":22.5,"timestamp":100}"#; - let err = Temperature::migrate_from_bytes(json.as_bytes()).unwrap_err(); - assert_eq!( - err, - crate::MigrationError::VersionTooNew { - source: 99, - current: 2 - } - ); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_migrate_from_bytes_missing_version() { - use crate::MigrationChain; - - let json = r#"{"celsius":22.5,"timestamp":100}"#; - let err = Temperature::migrate_from_bytes(json.as_bytes()).unwrap_err(); - assert_eq!(err, crate::MigrationError::MissingVersion); - } - - // ═══════════════════════════════════════════════════════════════════ - // DOWNGRADE TESTS - // ═══════════════════════════════════════════════════════════════════ - - #[cfg(feature = "migratable")] - #[test] - fn test_downgrade_to_v1() { - use crate::MigrationChain; - - let temp = Temperature::new(22.5, 1704326400000); - let v1_bytes = temp.migrate_to_version(1).unwrap(); - let v1: serde_json::Value = serde_json::from_slice(&v1_bytes).unwrap(); - - assert_eq!(v1["temp"], 22.5); - assert_eq!(v1["unit"], "C"); - assert_eq!(v1["schema_version"], 1); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_downgrade_to_current_version() { - use crate::MigrationChain; - - let temp = Temperature::new(22.5, 1704326400000); - let v2_bytes = temp.migrate_to_version(2).unwrap(); - let v2: Temperature = serde_json::from_slice(&v2_bytes).unwrap(); - assert_eq!(v2.celsius, 22.5); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_downgrade_version_too_old() { - use crate::MigrationChain; - - let temp = Temperature::new(22.5, 100); - let err = temp.migrate_to_version(0).unwrap_err(); - assert_eq!( - err, - crate::MigrationError::VersionTooOld { - target: 0, - minimum: 1 - } - ); - } - - #[cfg(feature = "migratable")] - #[test] - fn test_roundtrip_v1_upgrade_downgrade() { - use crate::MigrationChain; - - // Start with v1 JSON - let v1_json = r#"{"schema_version":1,"temp":22.5,"timestamp":1704326400000,"unit":"C"}"#; - - // Upgrade to v2 - let v2 = Temperature::migrate_from_bytes(v1_json.as_bytes()).unwrap(); - assert_eq!(v2.celsius, 22.5); - - // Downgrade back to v1 - let v1_bytes = v2.migrate_to_version(1).unwrap(); - let v1: TemperatureV1 = serde_json::from_slice(&v1_bytes).unwrap(); - assert_eq!(v1.temp, 22.5); - assert_eq!(v1.unit, "C"); - - // Upgrade again — should round-trip - let v2_again = Temperature::migrate_from_bytes(&v1_bytes).unwrap(); - assert_eq!(v2_again.celsius, 22.5); - } - - // ═══════════════════════════════════════════════════════════════════ - // LINKABLE TRAIT TESTS (with auto-migration) - // ═══════════════════════════════════════════════════════════════════ - - #[cfg(all(feature = "linkable", feature = "migratable"))] - #[test] - fn test_from_bytes_v1_with_version_marker() { - use crate::Linkable; - - let json = r#"{"schema_version":1,"temp":68.0,"timestamp":1704326400000,"unit":"F"}"#; - let temp = Temperature::from_bytes(json.as_bytes()).unwrap(); - assert!( - (temp.celsius - 20.0).abs() < 0.01, - "Expected ~20°C from 68°F" - ); - assert_eq!(temp.timestamp, 1704326400000); - } - - #[cfg(all(feature = "linkable", feature = "migratable"))] - #[test] - fn test_from_bytes_v2_with_version_marker() { - use crate::Linkable; - - let json = r#"{"schema_version":2,"celsius":22.5,"timestamp":1704326400000}"#; - let temp = Temperature::from_bytes(json.as_bytes()).unwrap(); - assert_eq!(temp.celsius, 22.5); - assert_eq!(temp.timestamp, 1704326400000); - } - - #[cfg(all(feature = "linkable", feature = "migratable"))] - #[test] - fn test_from_bytes_v1_celsius_unit() { - use crate::Linkable; - - let json = r#"{"schema_version":1,"temp":22.5,"timestamp":1704326400000,"unit":"C"}"#; - let temp = Temperature::from_bytes(json.as_bytes()).unwrap(); - assert_eq!(temp.celsius, 22.5); - } - - #[cfg(all(feature = "linkable", feature = "migratable"))] - #[test] - fn test_from_bytes_via_linkable_trait() { - use crate::Linkable; - - // v1 payload should auto-migrate - let v1_json = r#"{"schema_version":1,"temp":68.0,"timestamp":1704326400000,"unit":"F"}"#; - let temp = Temperature::from_bytes(v1_json.as_bytes()).unwrap(); - assert!((temp.celsius - 20.0).abs() < 0.01); - - // v2 payload should parse directly - let v2_json = r#"{"schema_version":2,"celsius":22.5,"timestamp":1704326400000}"#; - let temp = Temperature::from_bytes(v2_json.as_bytes()).unwrap(); - assert_eq!(temp.celsius, 22.5); - } - - #[cfg(all(feature = "linkable", feature = "migratable"))] - #[test] - fn test_from_bytes_missing_version_fails() { - use crate::Linkable; - - let json = r#"{"celsius":22.5,"timestamp":1704326400000}"#; - let result = Temperature::from_bytes(json.as_bytes()); - assert!(result.is_err()); - } - - #[cfg(feature = "linkable")] - #[test] - fn test_v1_serialization_includes_schema_version() { - use crate::Linkable; - - let v1 = TemperatureV1::new(22.5, 1704326400000, "C"); - let bytes = v1.to_bytes().unwrap(); - let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - - assert_eq!(json["schema_version"], 1); - assert_eq!(json["temp"], 22.5); - assert_eq!(json["unit"], "C"); - } - - #[cfg(feature = "simulatable")] - #[test] - fn test_simulation() { - use crate::simulatable::SimulationParams; - use rand::rngs::StdRng; - use rand::SeedableRng; - - let config = SimulationConfig { - enabled: true, - interval_ms: 1000, - params: SimulationParams { - base: 20.0, - variation: 5.0, - trend: 0.0, - step: 0.2, - }, - }; - - let mut rng = StdRng::seed_from_u64(42); - - // Generate first sample - let temp1 = Temperature::simulate(&config, None, &mut rng, 1000); - assert!(temp1.celsius >= 15.0 && temp1.celsius <= 25.0); - - // Generate second sample (should be close to first due to random walk) - let temp2 = Temperature::simulate(&config, Some(&temp1), &mut rng, 2000); - let diff = (temp2.celsius - temp1.celsius).abs(); - assert!(diff < 1.0, "Random walk step too large: {}", diff); - } - - #[cfg(feature = "simulatable")] - #[test] - fn test_simulation_with_trend() { - use crate::simulatable::SimulationParams; - use rand::rngs::StdRng; - use rand::SeedableRng; - - let config = SimulationConfig { - enabled: true, - interval_ms: 1000, - params: SimulationParams { - base: 20.0, - variation: 10.0, - trend: 0.5, // Strong upward trend - step: 0.0, // No random walk, just trend - }, - }; - - let mut rng = StdRng::seed_from_u64(42); - - let temp1 = Temperature::simulate(&config, None, &mut rng, 1000); - let temp2 = Temperature::simulate(&config, Some(&temp1), &mut rng, 2000); - let temp3 = Temperature::simulate(&config, Some(&temp2), &mut rng, 3000); - - // With positive trend and no randomness, each should be higher - assert!( - temp2.celsius > temp1.celsius, - "Trend should increase temperature" - ); - assert!( - temp3.celsius > temp2.celsius, - "Trend should increase temperature" - ); - } -} diff --git a/aimdb-data-contracts/src/lib.rs b/aimdb-data-contracts/src/lib.rs index 3b80394a..9befa636 100644 --- a/aimdb-data-contracts/src/lib.rs +++ b/aimdb-data-contracts/src/lib.rs @@ -1,11 +1,13 @@ //! # AimDB Data Contracts //! -//! Self-describing data schemas that work identically across MCU, edge, and cloud. +//! Trait definitions for self-describing data schemas that work identically +//! across MCU, edge, and cloud. //! //! This crate provides: -//! - **Schema types** - Data structures with unique identifiers -//! - **Contract profiles** - Configuration for runtime behavior -//! - **Simulation support** - Generate realistic test data +//! - **Schema types** — Data structures with unique identifiers ([`SchemaType`]) +//! - **Streamable** — Marker for types that cross serialization boundaries ([`Streamable`]) +//! - **Contract profiles** — Configuration for runtime behavior +//! - **Simulation support** — Generate realistic test data //! //! ## Design Philosophy //! @@ -15,16 +17,24 @@ //! - Type-safe data exchange between systems //! - Configurable policies without changing code //! -//! ## Example +//! ## Defining a Custom Contract //! //! ```rust -//! use aimdb_data_contracts::{SchemaType, Settable}; -//! use aimdb_data_contracts::contracts::Temperature; +//! use aimdb_data_contracts::{SchemaType, Streamable}; +//! use serde::{Serialize, Deserialize}; //! -//! // Create a reading -//! let temp = Temperature::set(22.5, 1704326400000); -//! assert_eq!(temp.celsius, 22.5); -//! assert_eq!(Temperature::NAME, "temperature"); +//! #[derive(Clone, Debug, Serialize, Deserialize)] +//! pub struct MyCustomSensor { +//! pub reading: f64, +//! pub timestamp: u64, +//! } +//! +//! impl SchemaType for MyCustomSensor { +//! const NAME: &'static str = "my_custom_sensor"; +//! } +//! +//! // Mark as streamable — can cross WebSocket / WASM boundaries +//! impl Streamable for MyCustomSensor {} //! ``` #![cfg_attr(not(feature = "std"), no_std)] @@ -34,10 +44,8 @@ extern crate std; extern crate alloc; -pub mod contracts; - mod streamable; -pub use streamable::{for_each_streamable, Streamable, StreamableVisitor}; +pub use streamable::Streamable; #[cfg(feature = "linkable")] mod linkable; @@ -206,7 +214,8 @@ pub trait Observable: SchemaType { /// # Example /// /// ```rust,ignore -/// use aimdb_data_contracts::{Linkable, contracts::Temperature}; +/// use aimdb_data_contracts::Linkable; +/// use my_app::Temperature; // user-defined type implementing Linkable /// /// // In connector configuration: /// builder.configure::(NODE_ID, |reg| { diff --git a/aimdb-data-contracts/src/linkable.rs b/aimdb-data-contracts/src/linkable.rs index 9035bf45..17a8d095 100644 --- a/aimdb-data-contracts/src/linkable.rs +++ b/aimdb-data-contracts/src/linkable.rs @@ -5,27 +5,94 @@ #[cfg(test)] mod tests { - use crate::contracts::{GpsLocation, Humidity, Temperature}; - use crate::Linkable; + use crate::{Linkable, SchemaType}; + use serde::{Deserialize, Serialize}; + + /// Test-only temperature struct for linkable tests. + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct TestTemp { + celsius: f32, + timestamp: u64, + } + + impl SchemaType for TestTemp { + const NAME: &'static str = "test_temp"; + } + + impl Linkable for TestTemp { + fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|e| e.to_string()) + } + + fn to_bytes(&self) -> Result, String> { + serde_json::to_vec(self).map_err(|e| e.to_string()) + } + } + + /// Test-only humidity struct for linkable tests. + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct TestHumidity { + percent: f32, + timestamp: u64, + } + + impl SchemaType for TestHumidity { + const NAME: &'static str = "test_humidity"; + } + + impl Linkable for TestHumidity { + fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|e| e.to_string()) + } + + fn to_bytes(&self) -> Result, String> { + serde_json::to_vec(self).map_err(|e| e.to_string()) + } + } + + /// Test-only location struct for linkable tests. + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct TestLocation { + latitude: f64, + longitude: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + altitude: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + accuracy: Option, + timestamp: u64, + } + + impl SchemaType for TestLocation { + const NAME: &'static str = "test_location"; + } + + impl Linkable for TestLocation { + fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|e| e.to_string()) + } + + fn to_bytes(&self) -> Result, String> { + serde_json::to_vec(self).map_err(|e| e.to_string()) + } + } #[test] fn test_temperature_roundtrip() { - let temp = Temperature { - schema_version: 2, + let temp = TestTemp { celsius: 22.5, timestamp: 1704326400000, }; let bytes = temp.to_bytes().expect("serialization should succeed"); - let restored = Temperature::from_bytes(&bytes).expect("deserialization should succeed"); + let restored = TestTemp::from_bytes(&bytes).expect("deserialization should succeed"); assert_eq!(temp, restored); } #[test] fn test_temperature_from_json_string() { - let json = br#"{"schema_version": 2, "celsius": 25.0, "timestamp": 1704326400000}"#; - let temp = Temperature::from_bytes(json).expect("should parse valid JSON"); + let json = br#"{"celsius": 25.0, "timestamp": 1704326400000}"#; + let temp = TestTemp::from_bytes(json).expect("should parse valid JSON"); assert_eq!(temp.celsius, 25.0); assert_eq!(temp.timestamp, 1704326400000); @@ -34,24 +101,24 @@ mod tests { #[test] fn test_temperature_from_invalid_json() { let invalid = b"not valid json"; - assert!(Temperature::from_bytes(invalid).is_err()); + assert!(TestTemp::from_bytes(invalid).is_err()); } #[test] fn test_temperature_from_wrong_schema() { let json = br#"{"wrong_field": 123}"#; - assert!(Temperature::from_bytes(json).is_err()); + assert!(TestTemp::from_bytes(json).is_err()); } #[test] fn test_humidity_roundtrip() { - let humidity = Humidity { + let humidity = TestHumidity { percent: 65.0, timestamp: 1704326400000, }; let bytes = humidity.to_bytes().expect("serialization should succeed"); - let restored = Humidity::from_bytes(&bytes).expect("deserialization should succeed"); + let restored = TestHumidity::from_bytes(&bytes).expect("deserialization should succeed"); assert_eq!(humidity, restored); } @@ -59,15 +126,15 @@ mod tests { #[test] fn test_humidity_from_json_string() { let json = br#"{"percent": 72.5, "timestamp": 1704326400000}"#; - let humidity = Humidity::from_bytes(json).expect("should parse valid JSON"); + let humidity = TestHumidity::from_bytes(json).expect("should parse valid JSON"); assert_eq!(humidity.percent, 72.5); assert_eq!(humidity.timestamp, 1704326400000); } #[test] - fn test_gps_location_roundtrip() { - let location = GpsLocation { + fn test_location_roundtrip() { + let location = TestLocation { latitude: 48.2082, longitude: 16.3738, altitude: Some(200.0), @@ -76,16 +143,16 @@ mod tests { }; let bytes = location.to_bytes().expect("serialization should succeed"); - let restored = GpsLocation::from_bytes(&bytes).expect("deserialization should succeed"); + let restored = TestLocation::from_bytes(&bytes).expect("deserialization should succeed"); assert_eq!(location, restored); } #[test] - fn test_gps_location_minimal() { - // GPS location with only required fields + fn test_location_minimal() { + // Location with only required fields let json = br#"{"latitude": 48.2082, "longitude": 16.3738, "timestamp": 1704326400000}"#; - let location = GpsLocation::from_bytes(json).expect("should parse valid JSON"); + let location = TestLocation::from_bytes(json).expect("should parse valid JSON"); assert_eq!(location.latitude, 48.2082); assert_eq!(location.longitude, 16.3738); @@ -96,7 +163,7 @@ mod tests { #[test] fn test_error_message_is_descriptive() { let invalid = b"not valid json"; - let err = Temperature::from_bytes(invalid).unwrap_err(); + let err = TestTemp::from_bytes(invalid).unwrap_err(); assert!(!err.is_empty(), "Error message should be descriptive"); } } diff --git a/aimdb-data-contracts/src/migratable.rs b/aimdb-data-contracts/src/migratable.rs index ecca8a96..13dbd240 100644 --- a/aimdb-data-contracts/src/migratable.rs +++ b/aimdb-data-contracts/src/migratable.rs @@ -140,18 +140,23 @@ impl core::fmt::Display for MigrationError { /// # Example /// /// ```rust,ignore -/// struct TempV1ToV2; -/// impl MigrationStep for TempV1ToV2 { +/// struct TemperatureV1ToV2; +/// impl MigrationStep for TemperatureV1ToV2 { /// type Older = TemperatureV1; -/// type Newer = Temperature; +/// type Newer = TemperatureV2; /// const FROM_VERSION: u32 = 1; /// const TO_VERSION: u32 = 2; /// -/// fn up(v1: TemperatureV1) -> Result { -/// Ok(v1.to_v2()) +/// fn up(v1: TemperatureV1) -> Result { +/// let celsius = match v1.unit.as_str() { +/// "F" => (v1.temp - 32.0) * 5.0 / 9.0, +/// "K" => v1.temp - 273.15, +/// _ => v1.temp, +/// }; +/// Ok(TemperatureV2 { schema_version: 2, celsius, timestamp: v1.timestamp }) /// } -/// fn down(v2: Temperature) -> Result { -/// Ok(TemperatureV1 { temp: v2.celsius, .. }) +/// fn down(v2: TemperatureV2) -> Result { +/// Ok(TemperatureV1 { schema_version: 1, temp: v2.celsius, timestamp: v2.timestamp, unit: "C".into() }) /// } /// } /// ``` diff --git a/aimdb-data-contracts/src/observable.rs b/aimdb-data-contracts/src/observable.rs index 0ce87e0d..501fc7b1 100644 --- a/aimdb-data-contracts/src/observable.rs +++ b/aimdb-data-contracts/src/observable.rs @@ -19,7 +19,7 @@ use crate::Observable; /// # Example /// /// ```ignore -/// use aimdb_data_contracts::{contracts::Temperature, log_tap}; +/// use aimdb_data_contracts::log_tap; /// /// builder.configure::(NodeKey::Alpha, |reg| { /// reg.buffer(BufferCfg::SingleLatest) diff --git a/aimdb-data-contracts/src/streamable.rs b/aimdb-data-contracts/src/streamable.rs index cb9591ed..a09655ac 100644 --- a/aimdb-data-contracts/src/streamable.rs +++ b/aimdb-data-contracts/src/streamable.rs @@ -8,19 +8,32 @@ //! //! `Streamable` is a *capability marker* — it combines [`SchemaType`] identity //! with the `serde` bounds needed for type-erased dispatch at serialization -//! boundaries. The companion [`for_each_streamable`] function is the single -//! source of truth for which types are streamable — consumers implement -//! [`StreamableVisitor`] to build whatever dispatch tables they need. +//! boundaries. Users register their `Streamable` types with connectors and +//! adapters via `.register::()` builder methods. //! -//! # Adding a new streamable contract +//! # Implementing Streamable for a Custom Type //! -//! 1. Define your struct with `Serialize + Deserialize` in `contracts/`. -//! 2. Implement `SchemaType` (unique `NAME`). -//! 3. `impl Streamable for MyType {}` below. -//! 4. Add `visitor.visit::();` in [`for_each_streamable`]. +//! 1. Define your struct with `Serialize + Deserialize`. +//! 2. Implement [`SchemaType`] (unique `NAME`). +//! 3. `impl Streamable for MyType {}`. +//! 4. Register it with your connector: `.register::()`. //! -//! That's it — every consumer that uses the visitor picks up the new type -//! automatically. +//! ```rust +//! use aimdb_data_contracts::{SchemaType, Streamable}; +//! use serde::{Serialize, Deserialize}; +//! +//! #[derive(Clone, Debug, Serialize, Deserialize)] +//! pub struct MyCustomSensor { +//! pub reading: f64, +//! pub timestamp: u64, +//! } +//! +//! impl SchemaType for MyCustomSensor { +//! const NAME: &'static str = "my_custom_sensor"; +//! } +//! +//! impl Streamable for MyCustomSensor {} +//! ``` use crate::SchemaType; use core::fmt::Debug; @@ -43,142 +56,69 @@ use serde::{de::DeserializeOwned, Serialize}; /// /// ```rust /// use aimdb_data_contracts::{SchemaType, Streamable}; -/// use aimdb_data_contracts::contracts::Temperature; +/// use serde::{Serialize, Deserialize}; /// -/// // Temperature implements Streamable — it can be used across boundaries -/// fn assert_streamable() {} -/// assert_streamable::(); -/// ``` -pub trait Streamable: - SchemaType + Serialize + DeserializeOwned + Send + Sync + Clone + Debug + 'static -{ -} - -/// Visitor trait for iterating over all registered [`Streamable`] types. -/// -/// Implement this trait to build type-erased dispatch tables, registries, -/// or any other structure that needs to know about all streamable types. -/// -/// # Example -/// -/// ```rust -/// use std::any::TypeId; -/// use aimdb_data_contracts::{SchemaType, Streamable, StreamableVisitor, for_each_streamable}; -/// -/// struct TypeIdCollector { -/// entries: Vec<(TypeId, &'static str)>, +/// #[derive(Clone, Debug, Serialize, Deserialize)] +/// pub struct Pressure { +/// pub hpa: f32, +/// pub timestamp: u64, /// } /// -/// impl StreamableVisitor for TypeIdCollector { -/// fn visit(&mut self) { -/// self.entries.push((TypeId::of::(), T::NAME)); -/// } +/// impl SchemaType for Pressure { +/// const NAME: &'static str = "pressure"; /// } /// -/// let mut collector = TypeIdCollector { entries: Vec::new() }; -/// for_each_streamable(&mut collector); -/// assert_eq!(collector.entries.len(), 3); -/// ``` -pub trait StreamableVisitor { - /// Called once for each registered [`Streamable`] type. - fn visit(&mut self); -} - -// ═══════════════════════════════════════════════════════════════════ -// Implementations for built-in contracts -// ═══════════════════════════════════════════════════════════════════ - -use crate::contracts::{GpsLocation, Humidity, Temperature}; - -impl Streamable for Temperature {} -impl Streamable for Humidity {} -impl Streamable for GpsLocation {} - -/// Iterate over every registered [`Streamable`] type via the visitor pattern. -/// -/// This is the **single source of truth** for which types are streamable. -/// All consumers (WASM adapter, WebSocket connector, CLI) use this function -/// to discover streamable types instead of maintaining their own lists. -/// -/// # Adding a new contract +/// impl Streamable for Pressure {} /// -/// 1. `impl Streamable for NewType {}` (above) -/// 2. Add `visitor.visit::();` here. -pub fn for_each_streamable(visitor: &mut impl StreamableVisitor) { - visitor.visit::(); - visitor.visit::(); - visitor.visit::(); +/// fn assert_streamable() {} +/// assert_streamable::(); +/// ``` +pub trait Streamable: + SchemaType + Serialize + DeserializeOwned + Send + Sync + Clone + Debug + 'static +{ } #[cfg(test)] mod tests { use super::*; - use core::any::TypeId; + use serde::{Deserialize, Serialize}; - struct NameCollector { - names: alloc::vec::Vec<&'static str>, + #[derive(Clone, Debug, Serialize, Deserialize)] + struct TestSensor { + value: f32, + timestamp: u64, } - impl StreamableVisitor for NameCollector { - fn visit(&mut self) { - self.names.push(T::NAME); - } + impl SchemaType for TestSensor { + const NAME: &'static str = "test_sensor"; } - struct TypeIdResolver { - target: TypeId, - result: Option<&'static str>, - } - - impl StreamableVisitor for TypeIdResolver { - fn visit(&mut self) { - if TypeId::of::() == self.target { - self.result = Some(T::NAME); - } - } - } + impl Streamable for TestSensor {} #[test] - fn visitor_discovers_all_types() { - let mut c = NameCollector { - names: alloc::vec::Vec::new(), - }; - for_each_streamable(&mut c); - assert!(c.names.contains(&"temperature")); - assert!(c.names.contains(&"humidity")); - assert!(c.names.contains(&"gps_location")); - assert_eq!(c.names.len(), 3); + fn streamable_has_schema_name() { + assert_eq!(TestSensor::NAME, "test_sensor"); } #[test] - fn visitor_resolves_type_id() { - let mut r = TypeIdResolver { - target: TypeId::of::(), - result: None, - }; - for_each_streamable(&mut r); - assert_eq!(r.result, Some("temperature")); + fn streamable_requires_schema_type() { + fn assert_streamable() {} + assert_streamable::(); } #[test] - fn visitor_returns_none_for_unknown() { - let mut r = TypeIdResolver { - target: TypeId::of::(), - result: None, + fn streamable_requires_serde() { + let sensor = TestSensor { + value: 42.5, + timestamp: 1000, }; - for_each_streamable(&mut r); - assert_eq!(r.result, None); - } - #[test] - fn known_schemas_are_discoverable() { - let mut c = NameCollector { - names: alloc::vec::Vec::new(), - }; - for_each_streamable(&mut c); - assert!(c.names.contains(&"temperature")); - assert!(c.names.contains(&"humidity")); - assert!(c.names.contains(&"gps_location")); - assert!(!c.names.contains(&"unknown")); + // Serialize + let json = serde_json::to_string(&sensor).unwrap(); + assert!(json.contains("42.5")); + + // Deserialize + let restored: TestSensor = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.value, 42.5); } } diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 1f6cec5e..9413930e 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -62,11 +62,11 @@ futures-core = { version = "0.3", default-features = false } # Embassy runtime dependencies (no_std) # Note: These use the workspace's local embassy checkout to avoid conflicts -embassy-executor = { version = "0.9.1", path = "../_external/embassy/embassy-executor", optional = true } -embassy-time = { version = "0.5.0", path = "../_external/embassy/embassy-time", optional = true } -embassy-sync = { version = "0.7.2", path = "../_external/embassy/embassy-sync", optional = true } -embassy-futures = { version = "0.1", path = "../_external/embassy/embassy-futures", optional = true } -embassy-net = { version = "0.8.0", path = "../_external/embassy/embassy-net", optional = true, features = [ +embassy-executor = { version = "0.10.0", path = "../_external/embassy/embassy-executor", optional = true } +embassy-time = { version = "0.5.1", path = "../_external/embassy/embassy-time", optional = true } +embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } +embassy-futures = { version = "0.1.2", path = "../_external/embassy/embassy-futures", optional = true } +embassy-net = { version = "0.9.0", path = "../_external/embassy/embassy-net", optional = true, features = [ "tcp", "udp", "dhcpv4", diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index ec954350..f1d48915 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -59,10 +59,10 @@ futures-core = { version = "0.3", default-features = false } # Embassy runtime dependencies (no_std) # Note: These use the workspace's local embassy checkout to avoid conflicts -embassy-executor = { version = "0.9.1", path = "../_external/embassy/embassy-executor", optional = true } -embassy-time = { version = "0.5.0", path = "../_external/embassy/embassy-time", optional = true } -embassy-sync = { version = "0.7.2", path = "../_external/embassy/embassy-sync", optional = true } -embassy-net = { version = "0.8.0", path = "../_external/embassy/embassy-net", optional = true, features = [ +embassy-executor = { version = "0.10.0", path = "../_external/embassy/embassy-executor", optional = true } +embassy-time = { version = "0.5.1", path = "../_external/embassy/embassy-time", optional = true } +embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } +embassy-net = { version = "0.9.0", path = "../_external/embassy/embassy-net", optional = true, features = [ "tcp", "dhcpv4", "medium-ethernet", diff --git a/aimdb-wasm-adapter/Cargo.toml b/aimdb-wasm-adapter/Cargo.toml index fe834b0a..6825955b 100644 --- a/aimdb-wasm-adapter/Cargo.toml +++ b/aimdb-wasm-adapter/Cargo.toml @@ -38,7 +38,7 @@ aimdb-core = { version = "1.0.0", path = "../aimdb-core", default-features = fal aimdb-ws-protocol = { version = "0.1.0", path = "../aimdb-ws-protocol" } # Data contracts (alloc only — no std) -aimdb-data-contracts = { version = "1.0.0", path = "../aimdb-data-contracts", default-features = false, features = [ +aimdb-data-contracts = { version = "0.1.0", path = "../aimdb-data-contracts", default-features = false, features = [ "alloc", ] } diff --git a/aimdb-wasm-adapter/src/bindings.rs b/aimdb-wasm-adapter/src/bindings.rs index 3796aad5..1e574acc 100644 --- a/aimdb-wasm-adapter/src/bindings.rs +++ b/aimdb-wasm-adapter/src/bindings.rs @@ -114,7 +114,7 @@ pub struct WasmDb { db: Option>, /// Maps record key → schema type name (always populated). schema_map: BTreeMap, - /// Type-erased dispatch registry built from the visitor pattern. + /// Type-erased dispatch registry built via `.register::()` calls. registry: SchemaRegistry, } @@ -133,7 +133,7 @@ impl WasmDb { configs: Some(Vec::new()), db: None, schema_map: BTreeMap::new(), - registry: SchemaRegistry::build(), + registry: SchemaRegistry::new(), } } @@ -295,12 +295,41 @@ impl WasmDb { .clone(); // cheap: two Arc pointer copies let schema_map = self.schema_map.clone(); - let registry = SchemaRegistry::build(); + let registry = self.registry.clone(); WsBridge::new_internal(db, schema_map, registry, url, options) } } +// ─── Rust-only API (not exported to JS) ─────────────────────────────────── + +use aimdb_data_contracts::Streamable; + +impl WasmDb { + /// Register a [`Streamable`] type for runtime dispatch. + /// + /// Must be called **before** [`build`](WasmDb::build) for the type to be + /// available via `configureRecord`, `get`, `set`, and `subscribe`. + /// + /// This method is Rust-only (not exported via `#[wasm_bindgen]`) because + /// generic type parameters cannot cross the WASM boundary. Typical usage + /// is in a factory function that builds a pre-configured `WasmDb`: + /// + /// ```rust,ignore + /// #[wasm_bindgen] + /// pub fn create_db() -> WasmDb { + /// let mut db = WasmDb::new(); + /// db.register::(); + /// db.register::(); + /// db + /// } + /// ``` + pub fn register(&mut self) -> &mut Self { + self.registry.register::(); + self + } +} + // ─── discover_impl ──────────────────────────────────────────────────────── /// Build a one-shot WebSocket promise that resolves with `TopicInfo[]`. diff --git a/aimdb-wasm-adapter/src/schema_registry.rs b/aimdb-wasm-adapter/src/schema_registry.rs index 7f18140e..d12f748f 100644 --- a/aimdb-wasm-adapter/src/schema_registry.rs +++ b/aimdb-wasm-adapter/src/schema_registry.rs @@ -1,14 +1,17 @@ //! Type-erased dispatch registry for [`Streamable`] types in the WASM adapter. //! -//! Built once via [`SchemaRegistry::build`] using the visitor pattern from -//! `aimdb-data-contracts`. Each entry stores boxed closures that capture the -//! concrete type `T` through monomorphization, enabling runtime dispatch by -//! schema name without a central match macro. +//! Built via [`SchemaRegistry::new`] + [`register`](SchemaRegistry::register) +//! calls. Each entry stores `Arc`-wrapped closures that capture the concrete +//! type `T` through monomorphization, enabling runtime dispatch by schema name +//! without a central match macro. +//! +//! The registry is `Clone`-able (cheap `Arc` bumps) so it can be shared +//! between `WasmDb` and `WsBridge`. extern crate alloc; -use alloc::boxed::Box; use alloc::collections::BTreeMap; +use alloc::sync::Arc; use wasm_bindgen::prelude::*; @@ -16,21 +19,22 @@ use aimdb_core::buffer::BufferCfg; use aimdb_core::builder::{AimDb, AimDbBuilder}; use aimdb_core::record_id::StringKey; -use aimdb_data_contracts::{for_each_streamable, Streamable, StreamableVisitor}; +use aimdb_data_contracts::Streamable; use crate::WasmAdapter; // ─── Type-erased operations ─────────────────────────────────────────────── -type ConfigureFn = Box, StringKey, BufferCfg) + Send + Sync>; -type GetFn = Box, &str) -> Result + Send + Sync>; -type SetFn = Box, &str, JsValue) -> Result<(), JsError> + Send + Sync>; -type SubscribeFn = Box< +type ConfigureFn = Arc, StringKey, BufferCfg) + Send + Sync>; +type GetFn = Arc, &str) -> Result + Send + Sync>; +type SetFn = Arc, &str, JsValue) -> Result<(), JsError> + Send + Sync>; +type SubscribeFn = Arc< dyn Fn(&AimDb, &str, &js_sys::Function) -> Result + Send + Sync, >; -type ProduceFromJsonFn = Box, &str, serde_json::Value) + Send + Sync>; +type ProduceFromJsonFn = Arc, &str, serde_json::Value) + Send + Sync>; /// Type-erased operations for a single [`Streamable`] type. +#[derive(Clone)] pub(crate) struct SchemaOps { pub configure: ConfigureFn, pub get: GetFn, @@ -43,24 +47,47 @@ pub(crate) struct SchemaOps { /// Maps schema names to type-erased operations. /// -/// Built once at startup via [`SchemaRegistry::build`], then shared across -/// the `WasmDb` and `WsBridge`. +/// Built via [`SchemaRegistry::new`] + repeated [`register`](Self::register) +/// calls at startup, then shared between `WasmDb` and `WsBridge`. +/// +/// Cloning is cheap — all closures are `Arc`-wrapped. +#[derive(Clone)] pub(crate) struct SchemaRegistry { entries: BTreeMap<&'static str, SchemaOps>, } impl SchemaRegistry { - /// Build the registry by visiting all [`Streamable`] types. - pub fn build() -> Self { - let mut builder = RegistryBuilder { - entries: BTreeMap::new(), - }; - for_each_streamable(&mut builder); + /// Create an empty registry. Call [`register`](Self::register) to add types. + pub fn new() -> Self { SchemaRegistry { - entries: builder.entries, + entries: BTreeMap::new(), } } + /// Register a [`Streamable`] type for runtime dispatch. + /// + /// Duplicate registrations (same `T::NAME`) silently overwrite the + /// previous entry. + pub fn register(&mut self) -> &mut Self { + use crate::bindings::{get_typed, set_typed, subscribe_typed}; + use crate::ws_bridge::produce_from_json; + + let ops = SchemaOps { + configure: Arc::new(|builder, key, cfg| { + use crate::WasmRecordRegistrarExt; + builder.configure::(key, |reg| { + reg.buffer(cfg); + }); + }), + get: Arc::new(get_typed::), + set: Arc::new(set_typed::), + subscribe: Arc::new(|db, key, cb| subscribe_typed::(db, key, cb)), + produce_from_json: Arc::new(produce_from_json::), + }; + self.entries.insert(T::NAME, ops); + self + } + /// Look up operations for a schema name. pub fn get(&self, schema_name: &str) -> Option<&SchemaOps> { self.entries.get(schema_name) @@ -76,30 +103,3 @@ impl SchemaRegistry { self.entries.keys().copied().collect() } } - -// ─── Visitor that builds the registry ───────────────────────────────────── - -struct RegistryBuilder { - entries: BTreeMap<&'static str, SchemaOps>, -} - -impl StreamableVisitor for RegistryBuilder { - fn visit(&mut self) { - use crate::bindings::{get_typed, set_typed, subscribe_typed}; - use crate::ws_bridge::produce_from_json; - - let ops = SchemaOps { - configure: Box::new(|builder, key, cfg| { - use crate::WasmRecordRegistrarExt; - builder.configure::(key, |reg| { - reg.buffer(cfg); - }); - }), - get: Box::new(get_typed::), - set: Box::new(set_typed::), - subscribe: Box::new(|db, key, cb| subscribe_typed::(db, key, cb)), - produce_from_json: Box::new(produce_from_json::), - }; - self.entries.insert(T::NAME, ops); - } -} diff --git a/aimdb-websocket-connector/Cargo.toml b/aimdb-websocket-connector/Cargo.toml index 1c06f279..94ba9cf6 100644 --- a/aimdb-websocket-connector/Cargo.toml +++ b/aimdb-websocket-connector/Cargo.toml @@ -34,10 +34,10 @@ tokio-runtime = ["server"] tracing = ["dep:tracing"] [dependencies] -aimdb-core = { path = "../aimdb-core", default-features = false } -aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = false } -aimdb-executor = { path = "../aimdb-executor", default-features = false } -aimdb-ws-protocol = { path = "../aimdb-ws-protocol" } +aimdb-core = { version = "1.0.0", path = "../aimdb-core", default-features = false } +aimdb-data-contracts = { version = "0.1.0", path = "../aimdb-data-contracts", default-features = false } +aimdb-executor = { version = "0.1.0", path = "../aimdb-executor", default-features = false } +aimdb-ws-protocol = { version = "0.1.0", path = "../aimdb-ws-protocol" } # Async runtime tokio = { version = "1", features = [ diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/builder.rs index dd22d56d..a1c7aa0e 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/builder.rs @@ -15,14 +15,13 @@ //! ``` use std::{ - any::TypeId, collections::HashMap, net::{SocketAddr, ToSocketAddrs}, pin::Pin, sync::{Arc, Mutex}, }; -use aimdb_data_contracts::for_each_streamable; +use aimdb_data_contracts::Streamable; use aimdb_core::{router::RouterBuilder, ConnectorBuilder}; use axum::Router as AxumRouter; @@ -31,6 +30,7 @@ use crate::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, connector::WebSocketConnectorImpl, + registry::StreamableRegistry, server::start_server, session::{NoQuery, NoSnapshot, QueryHandler, SessionContext, SnapshotProvider}, }; @@ -47,7 +47,12 @@ use aimdb_ws_protocol::TopicInfo; /// ```rust,ignore /// use aimdb_websocket_connector::WebSocketConnector; /// -/// let connector = WebSocketConnector::new() +/// let mut connector = WebSocketConnector::new(); +/// connector.register::(); +/// connector.register::(); +/// connector.register::(); +/// +/// let connector = connector /// .bind("0.0.0.0:8080") /// .path("/ws") /// .with_late_join(true) @@ -75,6 +80,8 @@ pub struct WebSocketConnectorBuilder { raw_payload: bool, /// Handler for client `query` messages (history retrieval). query_handler: Arc, + /// Registered streamable types for schema resolution. + streamable_registry: StreamableRegistry, } impl Default for WebSocketConnectorBuilder { @@ -90,6 +97,7 @@ impl Default for WebSocketConnectorBuilder { auto_subscribe_topics: Vec::new(), raw_payload: false, query_handler: Arc::new(NoQuery), + streamable_registry: StreamableRegistry::new(), } } } @@ -228,6 +236,35 @@ impl WebSocketConnectorBuilder { self.query_handler = Arc::new(handler); self } + + /// Register a [`Streamable`] type for WebSocket schema resolution. + /// + /// Each call monomorphizes closures that capture `T` for serialization, + /// deserialization, and routing. The serializer performs a `downcast_ref` + /// on `&dyn Any` to recover the concrete type at dispatch. + /// + /// # Example + /// + /// ```rust,ignore + /// use aimdb_websocket_connector::WebSocketConnector; + /// + /// let mut connector = WebSocketConnector::new(); + /// connector.register::(); + /// connector.register::(); + /// connector.register::(); // user's own type + /// + /// let connector = connector.bind("0.0.0.0:8080"); + /// ``` + /// # Panics + /// + /// Panics if a *different* type has already been registered under the + /// same schema name (`T::NAME`). + pub fn register(&mut self) -> &mut Self { + self.streamable_registry + .register::() + .expect("schema name collision in StreamableRegistry"); + self + } } // ════════════════════════════════════════════════════════════════════ @@ -290,24 +327,14 @@ where }; // ── Known topics (for list_topics responses) ────────── - // Build a TypeId → schema name map from all registered Streamable types. - struct TypeIdMap(HashMap); - impl aimdb_data_contracts::StreamableVisitor for TypeIdMap { - fn visit(&mut self) { - self.0.insert( - TypeId::of::(), - ::NAME, - ); - } - } - let mut type_id_map = TypeIdMap(HashMap::new()); - for_each_streamable(&mut type_id_map); + // Use the registered streamable types to resolve TypeId → schema name. + let type_id_map = &self.streamable_registry.type_id_to_name; let topic_type_ids = db.collect_outbound_topic_type_ids("ws"); let known_topics: Vec = topic_type_ids .into_iter() .map(|(topic, type_id)| { - let schema_type = type_id_map.0.get(&type_id).map(|s| s.to_string()); + let schema_type = type_id_map.get(&type_id).map(|s| s.to_string()); // Extract entity from topic name: "temp.vienna" → "vienna". // The server owns the naming convention — clients receive // the entity as a first-class field and never parse topics. diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 5d58e2b6..f053f846 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -82,6 +82,8 @@ pub mod client_manager; #[cfg(feature = "server")] pub mod connector; #[cfg(feature = "server")] +pub(crate) mod registry; +#[cfg(feature = "server")] pub(crate) mod server; #[cfg(feature = "server")] pub(crate) mod session; diff --git a/aimdb-websocket-connector/src/registry.rs b/aimdb-websocket-connector/src/registry.rs new file mode 100644 index 00000000..607b8ad8 --- /dev/null +++ b/aimdb-websocket-connector/src/registry.rs @@ -0,0 +1,256 @@ +//! Type-erased dispatch registry for [`Streamable`] types. +//! +//! Built incrementally via [`StreamableRegistry::register::()`] at +//! connector construction time. Each entry stores monomorphized closures +//! that capture the concrete type `T` — runtime downcasts are limited to +//! a `TypeId`-guarded path inside the serializer closure. + +use std::any::TypeId; +use std::collections::HashMap; + +use aimdb_data_contracts::Streamable; + +// ─── Type-erased operations ─────────────────────────────────────── + +/// Type-erased serialization closure: takes `&dyn Any`, downcasts to `T`, +/// and serializes to JSON bytes. +type SerializeFn = Box Result, String> + Send + Sync>; + +/// Type-erased deserialization closure: takes JSON bytes and produces a +/// boxed `Any` value of the concrete type `T`. +type DeserializeFn = + Box Result, String> + Send + Sync>; + +/// Type-erased operations for a single [`Streamable`] type. +/// +/// Each field is a monomorphized closure that captures `T` at compile time +/// through generic instantiation. The serializer performs a `downcast_ref` +/// on `&dyn Any` to recover the concrete type. +#[allow(dead_code)] +pub(crate) struct StreamableOps { + /// The `TypeId` of the concrete type. + pub type_id: TypeId, + /// The schema name (`T::NAME`). + pub name: &'static str, + /// Serialize a `&dyn Any` (known to be `&T`) to JSON bytes. + pub serialize: SerializeFn, + /// Deserialize JSON bytes into a `Box` (actually `Box`). + pub deserialize: DeserializeFn, +} + +// ─── Registry ───────────────────────────────────────────────────── + +/// Maps schema names and type IDs to type-erased operations. +/// +/// Built incrementally via [`register::()`](StreamableRegistry::register) +/// before the connector is started. +pub(crate) struct StreamableRegistry { + /// Schema name → operations. + pub name_to_ops: HashMap<&'static str, StreamableOps>, + /// TypeId → schema name (for outbound topic resolution). + pub type_id_to_name: HashMap, +} + +impl StreamableRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + name_to_ops: HashMap::new(), + type_id_to_name: HashMap::new(), + } + } + + /// Register a [`Streamable`] type. + /// + /// Each call monomorphizes closures for `T`'s serialization and + /// deserialization. Re-registering the same type is idempotent. + /// + /// # Errors + /// + /// Returns an error if a *different* type has already been registered + /// under the same schema name (`T::NAME`). + pub fn register(&mut self) -> Result<(), String> { + let type_id = TypeId::of::(); + let name = T::NAME; + + // Same type re-registered — idempotent, nothing to do. + if let Some(existing) = self.name_to_ops.get(name) { + if existing.type_id == type_id { + return Ok(()); + } + return Err(format!( + "schema name collision: \"{name}\" is already registered by a different type" + )); + } + + let ops = StreamableOps { + type_id, + name, + serialize: Box::new(|any_ref| { + let value = any_ref + .downcast_ref::() + .expect("type mismatch: registry is internally consistent"); + serde_json::to_vec(value).map_err(|e| e.to_string()) + }), + deserialize: Box::new(|bytes| { + let value: T = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + Ok(Box::new(value)) + }), + }; + + self.name_to_ops.insert(name, ops); + self.type_id_to_name.insert(type_id, name); + Ok(()) + } + + /// Look up operations by schema name. + #[allow(dead_code)] + pub fn get_by_name(&self, name: &str) -> Option<&StreamableOps> { + self.name_to_ops.get(name) + } + + /// Resolve a `TypeId` to its schema name. + #[allow(dead_code)] + pub fn resolve_name(&self, type_id: &TypeId) -> Option<&'static str> { + self.type_id_to_name.get(type_id).copied() + } + + /// Returns all registered schema names. + #[allow(dead_code)] + pub fn known_names(&self) -> Vec<&'static str> { + self.name_to_ops.keys().copied().collect() + } + + /// Returns `true` if no types have been registered. + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.name_to_ops.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_data_contracts::SchemaType; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Serialize, Deserialize)] + struct TestSensor { + value: f32, + timestamp: u64, + } + + impl SchemaType for TestSensor { + const NAME: &'static str = "test_sensor"; + } + + impl Streamable for TestSensor {} + + #[derive(Clone, Debug, Serialize, Deserialize)] + struct TestActuator { + command: String, + } + + impl SchemaType for TestActuator { + const NAME: &'static str = "test_actuator"; + } + + impl Streamable for TestActuator {} + + #[test] + fn register_and_lookup_by_name() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + let ops = reg.get_by_name("test_sensor").unwrap(); + assert_eq!(ops.name, "test_sensor"); + assert_eq!(ops.type_id, TypeId::of::()); + } + + #[test] + fn register_and_resolve_type_id() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); + assert_eq!(reg.resolve_name(&TypeId::of::()), None); + } + + #[test] + fn serialize_roundtrip() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + let sensor = TestSensor { + value: 42.5, + timestamp: 1000, + }; + + let ops = reg.get_by_name("test_sensor").unwrap(); + let bytes = (ops.serialize)(&sensor).unwrap(); + let restored = (ops.deserialize)(&bytes).unwrap(); + let restored_sensor = restored.downcast_ref::().unwrap(); + + assert_eq!(restored_sensor.value, 42.5); + assert_eq!(restored_sensor.timestamp, 1000); + } + + #[test] + fn duplicate_registration_is_idempotent() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + reg.register::().unwrap(); + + assert_eq!(reg.known_names().len(), 1); + } + + #[test] + fn name_collision_from_different_type_is_rejected() { + #[derive(Clone, Debug, Serialize, Deserialize)] + struct FakeSensor { + fake: bool, + } + + impl SchemaType for FakeSensor { + const NAME: &'static str = "test_sensor"; // same name as TestSensor + } + + impl Streamable for FakeSensor {} + + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + let err = reg.register::().unwrap_err(); + assert!(err.contains("test_sensor")); + assert!(err.contains("collision")); + } + + #[test] + fn multiple_types_registered() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + reg.register::().unwrap(); + + assert_eq!(reg.known_names().len(), 2); + assert!(reg.get_by_name("test_sensor").is_some()); + assert!(reg.get_by_name("test_actuator").is_some()); + } + + #[test] + fn empty_registry() { + let reg = StreamableRegistry::new(); + assert!(reg.is_empty()); + assert!(reg.get_by_name("anything").is_none()); + } + + #[test] + fn unknown_schema_returns_none() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + assert!(reg.get_by_name("unknown_schema").is_none()); + } +} diff --git a/examples/embassy-knx-connector-demo/Cargo.toml b/examples/embassy-knx-connector-demo/Cargo.toml index 766b2eca..dd6b4cde 100644 --- a/examples/embassy-knx-connector-demo/Cargo.toml +++ b/examples/embassy-knx-connector-demo/Cargo.toml @@ -43,7 +43,7 @@ embassy-stm32 = { workspace = true, features = [ ] } embassy-sync = { workspace = true, features = ["defmt"] } embassy-executor = { workspace = true, features = [ - "arch-cortex-m", + "platform-cortex-m", "executor-thread", "defmt", ] } diff --git a/examples/embassy-mqtt-connector-demo/Cargo.toml b/examples/embassy-mqtt-connector-demo/Cargo.toml index 40a4ce27..9662791a 100644 --- a/examples/embassy-mqtt-connector-demo/Cargo.toml +++ b/examples/embassy-mqtt-connector-demo/Cargo.toml @@ -44,7 +44,7 @@ embassy-stm32 = { workspace = true, features = [ ] } embassy-sync = { workspace = true, features = ["defmt"] } embassy-executor = { workspace = true, features = [ - "arch-cortex-m", + "platform-cortex-m", "executor-thread", "defmt", ] } diff --git a/examples/weather-mesh-demo/weather-hub/Cargo.toml b/examples/weather-mesh-demo/weather-hub/Cargo.toml index aafcf311..bec2f6d3 100644 --- a/examples/weather-mesh-demo/weather-hub/Cargo.toml +++ b/examples/weather-mesh-demo/weather-hub/Cargo.toml @@ -11,7 +11,10 @@ name = "weather-hub" path = "src/main.rs" [dependencies] -weather-mesh-common = { path = "../weather-mesh-common" } +weather-mesh-common = { path = "../weather-mesh-common", features = [ + "linkable", + "migratable", +] } aimdb-core = { path = "../../../aimdb-core", features = ["derive"] } aimdb-tokio-adapter = { path = "../../../aimdb-tokio-adapter" } aimdb-data-contracts = { path = "../../../aimdb-data-contracts", features = [ diff --git a/examples/weather-mesh-demo/weather-mesh-common/Cargo.toml b/examples/weather-mesh-demo/weather-mesh-common/Cargo.toml index e67c78ef..61da1c4b 100644 --- a/examples/weather-mesh-demo/weather-mesh-common/Cargo.toml +++ b/examples/weather-mesh-demo/weather-mesh-common/Cargo.toml @@ -9,6 +9,9 @@ publish = false [features] default = ["std"] std = ["aimdb-data-contracts/std"] +linkable = ["aimdb-data-contracts/linkable", "serde_json"] +simulatable = ["aimdb-data-contracts/simulatable", "rand"] +migratable = ["aimdb-data-contracts/migratable", "serde_json"] [dependencies] aimdb-core = { path = "../../../aimdb-core", default-features = false, features = [ @@ -17,3 +20,7 @@ aimdb-core = { path = "../../../aimdb-core", default-features = false, features ] } aimdb-data-contracts = { path = "../../../aimdb-data-contracts", default-features = false } serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", optional = true } +rand = { version = "0.8", optional = true, default-features = false, features = [ + "std_rng", +] } diff --git a/aimdb-data-contracts/src/contracts/humidity.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/humidity.rs similarity index 62% rename from aimdb-data-contracts/src/contracts/humidity.rs rename to examples/weather-mesh-demo/weather-mesh-common/src/contracts/humidity.rs index dcf106fa..d7b5784e 100644 --- a/aimdb-data-contracts/src/contracts/humidity.rs +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/humidity.rs @@ -2,22 +2,17 @@ extern crate alloc; -use crate::{Observable, SchemaType, Settable}; +use aimdb_data_contracts::{Observable, SchemaType, Settable, Streamable}; use serde::{Deserialize, Serialize}; #[cfg(feature = "linkable")] -use crate::Linkable; +use aimdb_data_contracts::Linkable; #[cfg(feature = "simulatable")] -use crate::{Simulatable, SimulationConfig}; - -#[cfg(feature = "ts")] -use ts_rs::TS; +use aimdb_data_contracts::{Simulatable, SimulationConfig}; /// Humidity sensor reading #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(TS))] -#[cfg_attr(feature = "ts", ts(export))] pub struct Humidity { /// Relative humidity as a percentage (0-100) pub percent: f32, @@ -29,6 +24,8 @@ impl SchemaType for Humidity { const NAME: &'static str = "humidity"; } +impl Streamable for Humidity {} + impl Observable for Humidity { type Signal = f32; const ICON: &'static str = "💧"; @@ -109,56 +106,3 @@ impl Linkable for Humidity { serde_json::to_vec(self).map_err(|e| e.to_string()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_settable() { - let humidity = Humidity::set(65.0, 1704326400000); - assert_eq!(humidity.percent, 65.0); - assert_eq!(humidity.timestamp, 1704326400000); - } - - #[test] - fn test_schema_name() { - assert_eq!(Humidity::NAME, "humidity"); - } - - #[test] - fn test_observable() { - let humidity = Humidity::set(65.0, 1704326400000); - assert_eq!(humidity.signal(), 65.0); - } - - #[cfg(feature = "simulatable")] - #[test] - fn test_simulation() { - use crate::simulatable::SimulationParams; - use rand::rngs::StdRng; - use rand::SeedableRng; - - let config = SimulationConfig { - enabled: true, - interval_ms: 1000, - params: SimulationParams { - base: 50.0, - variation: 10.0, - trend: 0.0, - step: 0.2, - }, - }; - - let mut rng = StdRng::seed_from_u64(42); - - // Generate first sample - let h1 = Humidity::simulate(&config, None, &mut rng, 1000); - assert!(h1.percent >= 40.0 && h1.percent <= 60.0); - - // Generate second sample (should be close to first due to random walk) - let h2 = Humidity::simulate(&config, Some(&h1), &mut rng, 2000); - let diff = (h2.percent - h1.percent).abs(); - assert!(diff < 2.0, "Random walk step too large: {}", diff); - } -} diff --git a/aimdb-data-contracts/src/contracts/location.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/location.rs similarity index 55% rename from aimdb-data-contracts/src/contracts/location.rs rename to examples/weather-mesh-demo/weather-mesh-common/src/contracts/location.rs index 44eaa9f9..ae54e364 100644 --- a/aimdb-data-contracts/src/contracts/location.rs +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/location.rs @@ -1,21 +1,18 @@ //! GPS location schema -use crate::{Observable, SchemaType, Settable}; +extern crate alloc; + +use aimdb_data_contracts::{Observable, SchemaType, Settable, Streamable}; use serde::{Deserialize, Serialize}; #[cfg(feature = "linkable")] -use crate::Linkable; +use aimdb_data_contracts::Linkable; #[cfg(feature = "simulatable")] -use crate::{Simulatable, SimulationConfig}; - -#[cfg(feature = "ts")] -use ts_rs::TS; +use aimdb_data_contracts::{Simulatable, SimulationConfig}; /// GPS location reading #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(TS))] -#[cfg_attr(feature = "ts", ts(export))] pub struct GpsLocation { /// Latitude in decimal degrees (-90 to 90) pub latitude: f64, @@ -35,6 +32,8 @@ impl SchemaType for GpsLocation { const NAME: &'static str = "gps_location"; } +impl Streamable for GpsLocation {} + impl Observable for GpsLocation { /// Signal is (latitude, longitude) tuple. /// Ordering is lexicographic (lat first, then lon). @@ -144,100 +143,3 @@ impl Linkable for GpsLocation { serde_json::to_vec(self).map_err(|e| e.to_string()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_settable() { - let loc = GpsLocation::set((48.2082, 16.3738, Some(200.0), Some(5.0)), 1704326400000); - assert_eq!(loc.latitude, 48.2082); - assert_eq!(loc.longitude, 16.3738); - assert_eq!(loc.altitude, Some(200.0)); - assert_eq!(loc.accuracy, Some(5.0)); - assert_eq!(loc.timestamp, 1704326400000); - } - - #[test] - fn test_settable_without_optional() { - let loc = GpsLocation::set((48.2082, 16.3738, None, None), 1704326400000); - assert_eq!(loc.latitude, 48.2082); - assert_eq!(loc.longitude, 16.3738); - assert_eq!(loc.altitude, None); - assert_eq!(loc.accuracy, None); - } - - #[test] - fn test_schema_name() { - assert_eq!(GpsLocation::NAME, "gps_location"); - } - - #[cfg(feature = "simulatable")] - #[test] - fn test_simulation() { - use crate::simulatable::SimulationParams; - use rand::rngs::StdRng; - use rand::SeedableRng; - - let config = SimulationConfig { - enabled: true, - interval_ms: 1000, - params: SimulationParams { - base: 48.2082, // Vienna latitude - variation: 0.001, // ~111 meters - trend: 0.0, - step: 0.2, - }, - }; - - let mut rng = StdRng::seed_from_u64(42); - - // Generate first sample - let loc1 = GpsLocation::simulate(&config, None, &mut rng, 1000); - assert!(loc1.latitude >= 48.207 && loc1.latitude <= 48.210); - assert!(loc1.altitude.is_some()); - - // Generate second sample (should be close to first due to random walk) - let loc2 = GpsLocation::simulate(&config, Some(&loc1), &mut rng, 2000); - let lat_diff = (loc2.latitude - loc1.latitude).abs(); - assert!( - lat_diff < 0.0005, - "Random walk step too large: {}", - lat_diff - ); - } - - #[test] - fn test_observable() { - let loc = GpsLocation::set((48.2082, 16.3738, Some(350.0), None), 1000); - assert_eq!(loc.signal(), (48.2082, 16.3738)); - - // Lexicographic comparison: lat first, then lon - let loc_north = GpsLocation::set((49.0, 16.0, None, None), 1000); - let loc_south = GpsLocation::set((47.0, 17.0, None, None), 1000); - assert!(loc_north.signal() > loc_south.signal()); // 49 > 47 - - // Test icon and unit - assert_eq!(GpsLocation::ICON, "📍"); - assert_eq!(GpsLocation::UNIT, "°"); - - // Test format_log with all fields - let loc_full = GpsLocation::set((48.2082, 16.3738, Some(350.0), Some(5.5)), 1000); - let log = loc_full.format_log("alpha"); - assert!(log.contains("📍")); - assert!(log.contains("[alpha]")); - assert!(log.contains("48.208200°")); - assert!(log.contains("16.373800°")); - assert!(log.contains("alt=350.0m")); - assert!(log.contains("acc=±5.5m")); - - // Test format_log without optional fields - let loc_minimal = GpsLocation::set((48.2082, 16.3738, None, None), 2000); - let log_min = loc_minimal.format_log("beta"); - assert!(log_min.contains("📍")); - assert!(log_min.contains("[beta]")); - assert!(!log_min.contains("alt=")); - assert!(!log_min.contains("acc=")); - } -} diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs new file mode 100644 index 00000000..602ee52f --- /dev/null +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs @@ -0,0 +1,14 @@ +//! Weather demo data contracts. +//! +//! These are example implementations of AimDB data contracts for weather +//! monitoring. They demonstrate how to implement `SchemaType`, `Streamable`, +//! `Observable`, `Settable`, `Linkable`, `Simulatable`, and `Migratable` +//! traits from `aimdb-data-contracts`. + +pub mod humidity; +pub mod location; +pub mod temperature; + +pub use humidity::Humidity; +pub use location::GpsLocation; +pub use temperature::{Temperature, TemperatureV1, TemperatureV2}; diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/contracts/temperature.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/temperature.rs new file mode 100644 index 00000000..c15a66b2 --- /dev/null +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/temperature.rs @@ -0,0 +1,251 @@ +//! Temperature sensor schema +//! +//! # Schema Evolution +//! +//! This module demonstrates backward-compatible schema migration with +//! **version-aware payloads** for decoupled deployment: +//! +//! - **v1** (legacy): `{ "schema_version": 1, "temp": f32, "timestamp": u64, "unit": "C"|"F"|"K" }` +//! - **v2** (current): `{ "schema_version": 2, "celsius": f32, "timestamp": u64 }` +//! +//! The `MigrationChain` impl (via `migration_chain!`) reads the `schema_version` +//! from the payload and migrates automatically, allowing nodes and hubs to be updated independently. + +extern crate alloc; + +use aimdb_data_contracts::{Observable, SchemaType, Settable, Streamable}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "linkable")] +use aimdb_data_contracts::Linkable; + +#[cfg(feature = "simulatable")] +use aimdb_data_contracts::{Simulatable, SimulationConfig}; + +#[cfg(feature = "migratable")] +use aimdb_data_contracts::{MigrationError, MigrationStep}; + +/// Temperature sensor reading in Celsius (schema v2) +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TemperatureV2 { + /// Schema version (always 2 for current format) + #[serde(default = "default_v2_version")] + pub schema_version: u32, + /// Temperature in degrees Celsius + pub celsius: f32, + /// Unix timestamp (milliseconds) when reading was taken + pub timestamp: u64, +} + +/// Type alias — always points to the latest schema version. +pub type Temperature = TemperatureV2; + +fn default_v2_version() -> u32 { + 2 +} + +impl TemperatureV2 { + /// Create a new temperature reading (current schema version) + pub fn new(celsius: f32, timestamp: u64) -> Self { + Self { + schema_version: 2, + celsius, + timestamp, + } + } +} + +impl SchemaType for TemperatureV2 { + const NAME: &'static str = "temperature"; + const VERSION: u32 = 2; +} + +impl Streamable for TemperatureV2 {} + +// ═══════════════════════════════════════════════════════════════════ +// LEGACY v1 SCHEMA (for migration purposes) +// ═══════════════════════════════════════════════════════════════════ + +/// Legacy Temperature schema (v1) - for migration from old nodes. +/// +/// v1 format: `{ "schema_version": 1, "temp": f32, "timestamp": u64, "unit": "C"|"F"|"K" }` +/// +/// This is kept so migration logic can be tested in CI/CD, ensuring +/// backward compatibility is maintained. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TemperatureV1 { + /// Schema version marker (always 1 for v1) + #[serde(default = "default_v1_version")] + pub schema_version: u32, + /// Temperature value in the unit specified by `unit` field + pub temp: f32, + /// Unix timestamp (milliseconds) + pub timestamp: u64, + /// Unit: "C" (Celsius), "F" (Fahrenheit), or "K" (Kelvin) + pub unit: alloc::string::String, +} + +fn default_v1_version() -> u32 { + 1 +} + +impl TemperatureV1 { + /// Create a new v1 temperature reading + pub fn new(temp: f32, timestamp: u64, unit: &str) -> Self { + Self { + schema_version: 1, + temp, + timestamp, + unit: alloc::string::String::from(unit), + } + } +} + +impl SchemaType for TemperatureV1 { + const NAME: &'static str = "temperature"; + const VERSION: u32 = 1; +} + +#[cfg(feature = "linkable")] +impl Linkable for TemperatureV1 { + fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|e| alloc::string::ToString::to_string(&e)) + } + + fn to_bytes(&self) -> Result, alloc::string::String> { + serde_json::to_vec(self).map_err(|e| alloc::string::ToString::to_string(&e)) + } +} + +// ═══════════════════════════════════════════════════════════════════ +// TYPE-SAFE MIGRATION (v1 → v2) +// ═══════════════════════════════════════════════════════════════════ + +/// Migration step: Temperature v1 (temp + unit) → v2 (celsius only) +#[cfg(feature = "migratable")] +pub struct TemperatureV1ToV2; + +#[cfg(feature = "migratable")] +impl MigrationStep for TemperatureV1ToV2 { + type Older = TemperatureV1; + type Newer = TemperatureV2; + const FROM_VERSION: u32 = 1; + const TO_VERSION: u32 = 2; + + fn up(v1: TemperatureV1) -> Result { + let celsius = match v1.unit.as_str() { + "F" => (v1.temp - 32.0) * 5.0 / 9.0, + "K" => v1.temp - 273.15, + _ => v1.temp, // "C" or unknown defaults to Celsius + }; + Ok(TemperatureV2 { + schema_version: 2, + celsius, + timestamp: v1.timestamp, + }) + } + + fn down(v2: TemperatureV2) -> Result { + Ok(TemperatureV1 { + schema_version: 1, + temp: v2.celsius, + timestamp: v2.timestamp, + unit: alloc::string::String::from("C"), + }) + } +} + +#[cfg(feature = "migratable")] +aimdb_data_contracts::migration_chain! { + type Current = TemperatureV2; + version_field = "schema_version"; + steps { + TemperatureV1ToV2: TemperatureV1 => TemperatureV2, + } +} + +impl Observable for TemperatureV2 { + type Signal = f32; + const ICON: &'static str = "🌡️"; + const UNIT: &'static str = "°C"; + + fn signal(&self) -> f32 { + self.celsius + } + + fn format_log(&self, node_id: &str) -> alloc::string::String { + alloc::format!( + "{} [{}] Temperature: {:.1}{} at {}", + Self::ICON, + node_id, + self.celsius, + Self::UNIT, + self.timestamp + ) + } +} + +#[cfg(feature = "simulatable")] +impl Simulatable for TemperatureV2 { + /// Simulate temperature readings with random walk behavior. + /// + /// # Config params interpretation + /// - `base`: Center temperature value (default: 22.0°C) + /// - `variation`: Maximum deviation from base (default: 3.0°C) + /// - `step`: Random walk step multiplier (default: 0.2) + /// - `trend`: Linear trend per sample (default: 0.0) + fn simulate( + config: &SimulationConfig, + previous: Option<&Self>, + rng: &mut R, + timestamp: u64, + ) -> Self { + let base = config.params.base as f32; + let variation = config.params.variation as f32; + let step = config.params.step as f32; + let trend = config.params.trend as f32; + + // Random walk: small delta from previous value, clamped to range + let current = match previous { + Some(prev) => { + let delta = (rng.gen::() - 0.5) * variation * step; + (prev.celsius + delta + trend).clamp(base - variation, base + variation) + } + None => base + (rng.gen::() - 0.5) * variation, + }; + + TemperatureV2 { + schema_version: 2, + celsius: current, + timestamp, + } + } +} + +impl Settable for TemperatureV2 { + type Value = f32; + + fn set(value: Self::Value, timestamp: u64) -> Self { + TemperatureV2 { + schema_version: 2, + celsius: value, + timestamp, + } + } +} + +// ═══════════════════════════════════════════════════════════════════ +// LINKABLE WITH MIGRATION SUPPORT +// ═══════════════════════════════════════════════════════════════════ + +#[cfg(all(feature = "linkable", feature = "migratable"))] +impl Linkable for TemperatureV2 { + fn from_bytes(data: &[u8]) -> Result { + use aimdb_data_contracts::MigrationChain; + Self::migrate_from_bytes(data).map_err(|e| alloc::format!("Migration error: {}", e)) + } + + fn to_bytes(&self) -> Result, alloc::string::String> { + serde_json::to_vec(self).map_err(|e| alloc::string::ToString::to_string(&e)) + } +} diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs b/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs index 4581fdb8..7dd79085 100644 --- a/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs +++ b/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs @@ -6,9 +6,12 @@ #![cfg_attr(not(feature = "std"), no_std)] -// Re-export contracts used in the mesh -pub use aimdb_data_contracts::contracts::{GpsLocation, Humidity, Temperature}; -pub use aimdb_data_contracts::{SchemaType, Settable}; +// Local contract definitions (Temperature, Humidity, GpsLocation) +pub mod contracts; +pub use contracts::{GpsLocation, Humidity, Temperature}; + +// Re-export traits from aimdb-data-contracts +pub use aimdb_data_contracts::{SchemaType, Settable, Streamable}; // Re-export RecordKey for convenience pub use aimdb_core::RecordKey; diff --git a/examples/weather-mesh-demo/weather-station-alpha/Cargo.toml b/examples/weather-mesh-demo/weather-station-alpha/Cargo.toml index 04369588..b464540a 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-alpha/Cargo.toml @@ -11,7 +11,10 @@ name = "weather-station-alpha" path = "src/main.rs" [dependencies] -weather-mesh-common = { path = "../weather-mesh-common" } +weather-mesh-common = { path = "../weather-mesh-common", features = [ + "linkable", + "migratable", +] } aimdb-core = { path = "../../../aimdb-core" } aimdb-tokio-adapter = { path = "../../../aimdb-tokio-adapter" } aimdb-data-contracts = { path = "../../../aimdb-data-contracts", features = [ diff --git a/examples/weather-mesh-demo/weather-station-beta/Cargo.toml b/examples/weather-mesh-demo/weather-station-beta/Cargo.toml index 4c0beacd..ec8be7ad 100644 --- a/examples/weather-mesh-demo/weather-station-beta/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-beta/Cargo.toml @@ -11,7 +11,11 @@ name = "weather-station-beta" path = "src/main.rs" [dependencies] -weather-mesh-common = { path = "../weather-mesh-common" } +weather-mesh-common = { path = "../weather-mesh-common", features = [ + "simulatable", + "linkable", + "migratable", +] } aimdb-core = { path = "../../../aimdb-core" } aimdb-tokio-adapter = { path = "../../../aimdb-tokio-adapter" } aimdb-data-contracts = { path = "../../../aimdb-data-contracts", features = [ diff --git a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml index e030e15e..4fb713e8 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml @@ -15,7 +15,9 @@ std = [] [dependencies] # AimDB dependencies -weather-mesh-common = { path = "../weather-mesh-common", default-features = false } +weather-mesh-common = { path = "../weather-mesh-common", default-features = false, features = [ + "simulatable", +] } aimdb-core = { path = "../../../aimdb-core", default-features = false, features = [ "alloc", ] } @@ -44,7 +46,7 @@ embassy-stm32 = { workspace = true, features = [ ] } embassy-sync = { workspace = true, features = ["defmt"] } embassy-executor = { workspace = true, features = [ - "arch-cortex-m", + "platform-cortex-m", "executor-thread", "defmt", ] }