From ed798e3ed93e71281a7e3ad4bbf23d890e370c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 8 Apr 2026 20:05:38 +0000 Subject: [PATCH 01/11] feat: introduce context-aware deserializers for inbound connectors - Added support for context-aware deserialization by introducing `ContextDeserializerFn`. - Updated `InboundConnectorBuilder` to include a context-aware deserializer method (`with_deserializer`). - Renamed existing deserializer method to `with_deserializer_raw` for raw byte deserialization. - Modified `Router::route` to accept an optional context parameter, allowing deserializers to access runtime context. - Updated all relevant connectors (MQTT, KNX, WebSocket) to pass runtime context during message routing. - Enhanced documentation to reflect the new deserialization capabilities and usage examples. - Added a design document outlining the rationale and implementation details for context-aware deserializers. --- aimdb-core/src/builder.rs | 2 +- aimdb-core/src/connector.rs | 50 +- aimdb-core/src/router.rs | 127 +++- aimdb-core/src/typed_api.rs | 71 +- aimdb-knx-connector/src/embassy_client.rs | 2 +- aimdb-knx-connector/src/tokio_client.rs | 2 +- .../tests/topic_provider_tests.rs | 2 +- aimdb-mqtt-connector/src/embassy_client.rs | 2 +- aimdb-mqtt-connector/src/tokio_client.rs | 2 +- .../tests/topic_provider_tests.rs | 2 +- .../src/client/connector.rs | 2 +- aimdb-websocket-connector/src/session.rs | 2 +- .../design/026-context-aware-deserializers.md | 652 ++++++++++++++++++ .../tokio-mqtt-connector-demo/src/main.rs | 4 +- 14 files changed, 863 insertions(+), 59 deletions(-) create mode 100644 docs/design/026-context-aware-deserializers.md diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 26da79d6..737f4448 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1450,7 +1450,7 @@ impl AimDb { ) -> Vec<( String, Box, - crate::connector::DeserializerFn, + crate::connector::DeserializerKind, )> { let mut routes = Vec::new(); diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index fee7f5fa..b0d23e8a 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -531,6 +531,34 @@ impl ConnectorLink { pub type DeserializerFn = Arc Result, String> + Send + Sync>; +/// Type alias for context-aware type-erased deserializer callbacks +/// +/// Like `DeserializerFn`, but receives a type-erased runtime context +/// for platform-independent timestamps and logging during deserialization. +/// +/// The first argument is the type-erased runtime (as `Arc`), +/// which is downcast to the concrete runtime type via `RuntimeContext::extract_from_any`. +pub type ContextDeserializerFn = Arc< + dyn Fn( + Arc, + &[u8], + ) -> Result, String> + + Send + + Sync, +>; + +/// Which deserializer variant is registered for an inbound link +/// +/// Enforces mutual exclusivity between raw bytes-only deserializers +/// and context-aware deserializers. +#[derive(Clone)] +pub enum DeserializerKind { + /// Plain bytes-only deserializer (from `.with_deserializer_raw()`) + Raw(DeserializerFn), + /// Context-aware deserializer (from `.with_deserializer()`) + Context(ContextDeserializerFn), +} + /// Type alias for producer factory callback (alloc feature) /// /// Takes Arc (which contains AimDb) and returns a boxed ProducerTrait. @@ -646,12 +674,12 @@ pub struct InboundConnectorLink { /// Deserialization callback that converts bytes to typed values /// - /// This is a type-erased function that takes `&[u8]` and returns - /// `Result, String>`. The spawned task will - /// downcast to the concrete type before producing. + /// Either a plain bytes-only deserializer (`Raw`) or a context-aware + /// deserializer (`Context`) that receives `RuntimeContext` for timestamps + /// and logging. /// /// Available in both `std` and `no_std` (with `alloc` feature) environments. - pub deserializer: DeserializerFn, + pub deserializer: DeserializerKind, /// Producer creation callback (alloc feature) /// @@ -700,7 +728,7 @@ impl Debug for InboundConnectorLink { impl InboundConnectorLink { /// Creates a new inbound connector link from a URL and deserializer - pub fn new(url: ConnectorUrl, deserializer: DeserializerFn) -> Self { + pub fn new(url: ConnectorUrl, deserializer: DeserializerKind) -> Self { Self { url, config: Vec::new(), @@ -1153,12 +1181,12 @@ mod tests { #[test] fn test_inbound_connector_link_resolve_topic_default() { - use super::{ConnectorUrl, DeserializerFn, InboundConnectorLink}; + use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/temperature").unwrap(); let deserializer: DeserializerFn = Arc::new(|_| Ok(Box::new(()) as Box)); - let link = InboundConnectorLink::new(url, deserializer); + let link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); // No resolver configured, should return static topic from URL assert_eq!(link.resolve_topic(), "sensors/temperature"); @@ -1166,12 +1194,12 @@ mod tests { #[test] fn test_inbound_connector_link_resolve_topic_dynamic() { - use super::{ConnectorUrl, DeserializerFn, InboundConnectorLink}; + use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/default").unwrap(); let deserializer: DeserializerFn = Arc::new(|_| Ok(Box::new(()) as Box)); - let mut link = InboundConnectorLink::new(url, deserializer); + let mut link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); // Configure dynamic resolver link.topic_resolver = Some(Arc::new(|| Some("sensors/dynamic/kitchen".into()))); @@ -1182,12 +1210,12 @@ mod tests { #[test] fn test_inbound_connector_link_resolve_topic_fallback() { - use super::{ConnectorUrl, DeserializerFn, InboundConnectorLink}; + use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/fallback").unwrap(); let deserializer: DeserializerFn = Arc::new(|_| Ok(Box::new(()) as Box)); - let mut link = InboundConnectorLink::new(url, deserializer); + let mut link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); // Configure resolver that returns None link.topic_resolver = Some(Arc::new(|| None)); diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index dcd61477..7257343e 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -19,7 +19,7 @@ use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; #[cfg(feature = "std")] use std::sync::Arc; -use crate::connector::{DeserializerFn, ProducerTrait}; +use crate::connector::{DeserializerKind, ProducerTrait}; /// A single routing entry /// @@ -45,8 +45,8 @@ pub struct Route { /// Type-erased producer for this route pub producer: Box, - /// Deserializer for converting bytes → typed value - pub deserializer: DeserializerFn, + /// Deserializer for converting bytes → typed value (raw or context-aware) + pub deserializer: DeserializerKind, } /// Generic message router for connector dispatch @@ -84,6 +84,7 @@ impl Router { /// # Arguments /// * `resource_id` - Resource identifier (topic, path, segment name, etc.) /// * `payload` - Raw message payload bytes + /// * `ctx` - Optional type-erased runtime context for context-aware deserializers /// /// # Returns /// * `Ok(())` - At least one route successfully processed the message @@ -91,17 +92,46 @@ impl Router { /// /// # Behavior /// - Checks all routes that match the resource_id (may be multiple) + /// - For `DeserializerKind::Raw`, calls the deserializer with payload only + /// - For `DeserializerKind::Context`, calls with context + payload (skips if no context) /// - Logs warnings on deserialization failures but continues /// - Logs debug message if no routes found for resource_id - pub async fn route(&self, resource_id: &str, payload: &[u8]) -> Result<(), String> { + pub async fn route( + &self, + resource_id: &str, + payload: &[u8], + ctx: Option<&Arc>, + ) -> Result<(), String> { let mut routed = false; // Linear search through all routes // Note: Multiple routes may match the same resource_id (different types) for route in &self.routes { if route.resource_id.as_ref() == resource_id { - // Deserialize the payload - match (route.deserializer)(payload) { + // Deserialize the payload based on deserializer kind + let result = match &route.deserializer { + DeserializerKind::Raw(deser) => (deser)(payload), + DeserializerKind::Context(deser) => match ctx { + Some(ctx) => (deser)(ctx.clone(), payload), + None => { + #[cfg(feature = "tracing")] + tracing::warn!( + "Context deserializer on '{}' but no context provided, skipping", + resource_id + ); + + #[cfg(feature = "defmt")] + defmt::warn!( + "Context deserializer on '{}' but no context provided", + resource_id + ); + + continue; + } + }, + }; + + match result { Ok(value_any) => { // Produce into the buffer match route.producer.produce_any(value_any).await { @@ -233,7 +263,7 @@ impl RouterBuilder { /// let router = RouterBuilder::from_routes(routes).build(); /// connector.set_router(router).await?; /// ``` - pub fn from_routes(routes: Vec<(String, Box, DeserializerFn)>) -> Self { + pub fn from_routes(routes: Vec<(String, Box, DeserializerKind)>) -> Self { let mut builder = Self::new(); for (resource_id, producer, deserializer) in routes { // Convert String to Arc - no leaking needed! @@ -248,7 +278,7 @@ impl RouterBuilder { /// # Arguments /// * `resource_id` - Resource identifier to match (as Arc) /// * `producer` - Producer that implements ProducerTrait - /// * `deserializer` - Function to deserialize bytes to the target type + /// * `deserializer` - Deserializer variant (raw or context-aware) /// /// # Resource ID Memory Management /// The resource_id is stored as Arc for proper reference counting and cleanup. @@ -259,7 +289,7 @@ impl RouterBuilder { mut self, resource_id: Arc, producer: Box, - deserializer: DeserializerFn, + deserializer: DeserializerKind, ) -> Self { self.routes.push(Route { resource_id, @@ -325,12 +355,12 @@ mod tests { producer: Box::new(MockProducer { call_count: call_count.clone(), }), - deserializer: Arc::new(|_bytes| Ok(Box::new(42i32))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), }]; let router = Router::new(routes); - router.route("test/resource", b"dummy").await.unwrap(); + router.route("test/resource", b"dummy", None).await.unwrap(); assert_eq!(call_count.load(Ordering::SeqCst), 1); } @@ -346,20 +376,25 @@ mod tests { producer: Box::new(MockProducer { call_count: call_count1.clone(), }), - deserializer: Arc::new(|_bytes| Ok(Box::new(42i32))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), }, Route { resource_id: Arc::from("shared/resource"), producer: Box::new(MockProducer { call_count: call_count2.clone(), }), - deserializer: Arc::new(|_bytes| Ok(Box::new("test".to_string()))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| { + Ok(Box::new("test".to_string())) + })), }, ]; let router = Router::new(routes); - router.route("shared/resource", b"dummy").await.unwrap(); + router + .route("shared/resource", b"dummy", None) + .await + .unwrap(); // Both producers should be called assert_eq!(call_count1.load(Ordering::SeqCst), 1); @@ -373,13 +408,16 @@ mod tests { producer: Box::new(MockProducer { call_count: Arc::new(AtomicUsize::new(0)), }), - deserializer: Arc::new(|_bytes| Ok(Box::new(42i32))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), }]; let router = Router::new(routes); // Should not panic on unknown resource - router.route("unknown/resource", b"dummy").await.unwrap(); + router + .route("unknown/resource", b"dummy", None) + .await + .unwrap(); } #[tokio::test] @@ -390,21 +428,23 @@ mod tests { producer: Box::new(MockProducer { call_count: Arc::new(AtomicUsize::new(0)), }), - deserializer: Arc::new(|_bytes| Ok(Box::new(42i32))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), }, Route { resource_id: Arc::from("resource1"), // Duplicate producer: Box::new(MockProducer { call_count: Arc::new(AtomicUsize::new(0)), }), - deserializer: Arc::new(|_bytes| Ok(Box::new("test".to_string()))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| { + Ok(Box::new("test".to_string())) + })), }, Route { resource_id: Arc::from("resource2"), producer: Box::new(MockProducer { call_count: Arc::new(AtomicUsize::new(0)), }), - deserializer: Arc::new(|_bytes| Ok(Box::new(99i32))), + deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(99i32)))), }, ]; @@ -415,4 +455,53 @@ mod tests { assert!(ids.iter().any(|id| id.as_ref() == "resource1")); assert!(ids.iter().any(|id| id.as_ref() == "resource2")); } + + #[tokio::test] + async fn test_context_deserializer_with_context() { + let call_count = Arc::new(AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + let routes = vec![Route { + resource_id: Arc::from("ctx/resource"), + producer: Box::new(MockProducer { + call_count: call_count.clone(), + }), + deserializer: DeserializerKind::Context(Arc::new(move |_ctx, _bytes| { + Ok(Box::new(42i32) as Box) + })), + }]; + + let router = Router::new(routes); + + // Provide a dummy context (just an i32 wrapped in Arc) + let ctx: Arc = Arc::new(0i32); + router + .route("ctx/resource", b"dummy", Some(&ctx)) + .await + .unwrap(); + + assert_eq!(call_count_clone.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_context_deserializer_without_context_skips() { + let call_count = Arc::new(AtomicUsize::new(0)); + + let routes = vec![Route { + resource_id: Arc::from("ctx/resource"), + producer: Box::new(MockProducer { + call_count: call_count.clone(), + }), + deserializer: DeserializerKind::Context(Arc::new(|_ctx, _bytes| { + Ok(Box::new(42i32) as Box) + })), + }]; + + let router = Router::new(routes); + + // No context provided — context deserializer should be skipped + router.route("ctx/resource", b"dummy", None).await.unwrap(); + + assert_eq!(call_count.load(Ordering::SeqCst), 0); + } } diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 8f6412cb..b64335de 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -523,7 +523,7 @@ where /// builder.configure::(|reg| { /// reg.buffer(BufferCfg::SingleLatest) /// .link_from("mqtt://broker/lights/+/state") - /// .with_deserializer(|bytes| parse_light_state(bytes)) + /// .with_deserializer(|_ctx, bytes: &[u8]| parse_light_state(bytes)) /// .finish() /// }); /// ``` @@ -533,6 +533,7 @@ where url: url.to_string(), config: Vec::new(), deserializer: None, + context_deserializer: None, topic_resolver: None, } } @@ -727,6 +728,7 @@ pub struct InboundConnectorBuilder< url: String, config: Vec<(String, String)>, deserializer: Option>, + context_deserializer: Option, topic_resolver: Option, } @@ -741,25 +743,56 @@ where self } - /// Sets a deserialization callback + /// Sets a raw deserialization callback (bytes only, no context) /// - /// The deserializer takes raw bytes from the external system and converts - /// them to the typed value `T`. Returns `Err(String)` if deserialization fails. + /// Prefer `.with_deserializer(|ctx, data| ...)` for access to + /// `RuntimeContext` (timestamps, logging). Use this raw variant + /// only when context is unnecessary. /// /// # Example /// /// ```rust,ignore /// .link_from("mqtt://broker/sensors/temp") - /// .with_deserializer(|bytes| { + /// .with_deserializer_raw(|bytes| { /// serde_json::from_slice::(bytes) /// .map_err(|e| e.to_string()) /// }) /// ``` - pub fn with_deserializer(mut self, f: F) -> Self + pub fn with_deserializer_raw(mut self, f: F) -> Self where F: Fn(&[u8]) -> Result + Send + Sync + 'static, { self.deserializer = Some(Arc::new(f)); + self.context_deserializer = None; // mutually exclusive + self + } + + /// Sets a context-aware deserialization callback + /// + /// The closure receives a `RuntimeContext` for platform-independent + /// timestamps and logging, plus the raw bytes from the external system. + /// + /// # Example + /// + /// ```rust,ignore + /// .link_from("knx://gateway/9/1/0") + /// .with_deserializer(|ctx, data: &[u8]| { + /// let mut temp = from_knx(data, "9/1/0")?; + /// temp.timestamp = ctx.time().now(); + /// Ok(temp) + /// }) + /// ``` + pub fn with_deserializer(mut self, f: F) -> Self + where + F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, + R: aimdb_executor::Runtime + Send + Sync, + { + let f = Arc::new(f); + self.context_deserializer = Some(Arc::new(move |ctx_any, bytes| { + let ctx = crate::RuntimeContext::::extract_from_any(ctx_any); + (f)(ctx, bytes).map(|val| Box::new(val) as Box) + })); + self.deserializer = None; // mutually exclusive self } @@ -797,7 +830,7 @@ where /// let node_id = smart_contract.get_producer_node_id()?; /// Some(format!("mesh/{}/data", node_id)) /// }) - /// .with_deserializer(|bytes| parse_sensor_data(bytes)) + /// .with_deserializer(|_ctx, bytes: &[u8]| parse_sensor_data(bytes)) /// .finish(); /// ``` pub fn with_topic_resolver(mut self, resolver: F) -> Self @@ -817,7 +850,7 @@ where /// - If no connector is registered for the URL scheme /// - If the URL is invalid pub fn finish(self) -> &'a mut RecordRegistrar<'a, T, R> { - use crate::connector::{ConnectorUrl, InboundConnectorLink}; + use crate::connector::{ConnectorUrl, DeserializerKind, InboundConnectorLink}; let url = ConnectorUrl::parse(&self.url) .unwrap_or_else(|_| panic!("Invalid connector URL: {}", self.url)); @@ -832,10 +865,18 @@ where ); } - // Validation: Deserializer must be provided - let Some(deserializer) = self.deserializer else { + // Resolve deserializer variant (mutually exclusive) + let deser_kind = if let Some(ctx_deser) = self.context_deserializer { + DeserializerKind::Context(ctx_deser) + } else if let Some(raw_deser) = self.deserializer { + // Type-erase the raw deserializer + let erased: crate::connector::DeserializerFn = Arc::new(move |bytes: &[u8]| { + raw_deser(bytes).map(|val| Box::new(val) as Box) + }); + DeserializerKind::Raw(erased) + } else { panic!( - "Inbound connector requires a deserializer. Call .with_deserializer() for {}", + "Inbound connector requires a deserializer. Call .with_deserializer() or .with_deserializer_raw() for {}", self.url ); }; @@ -854,14 +895,8 @@ where ); } - // Create type-erased deserializer - let erased_deserializer: crate::connector::DeserializerFn = - Arc::new(move |bytes: &[u8]| { - deserializer(bytes).map(|val| Box::new(val) as Box) - }); - // Create inbound connector link - let mut link = InboundConnectorLink::new(url, erased_deserializer); + let mut link = InboundConnectorLink::new(url, deser_kind); link.config = self.config; // Wire through the topic resolver diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 06d3fc3a..11c5dd67 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -555,7 +555,7 @@ impl KnxConnectorImpl { data.len() ); - if let Err(_e) = router.route(&resource_id, &data).await { + if let Err(_e) = router.route(&resource_id, &data, None).await { #[cfg(feature = "defmt")] defmt::warn!( "Failed to route telegram to {}", diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index ac97518b..4aee9aa2 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -688,7 +688,7 @@ async fn connect_and_listen( tracing::debug!("KNX telegram: {} ({} bytes)", resource_id, data.len()); // Dispatch via router - if let Err(_e) = router.route(&resource_id, &data).await { + if let Err(_e) = router.route(&resource_id, &data, None).await { #[cfg(feature = "tracing")] tracing::warn!("Router dispatch failed for {}: {:?}", resource_id, _e); } diff --git a/aimdb-knx-connector/tests/topic_provider_tests.rs b/aimdb-knx-connector/tests/topic_provider_tests.rs index f30e9608..f32e1940 100644 --- a/aimdb-knx-connector/tests/topic_provider_tests.rs +++ b/aimdb-knx-connector/tests/topic_provider_tests.rs @@ -360,7 +360,7 @@ async fn test_knx_topic_resolver_with_connector_registration() { .ok() .map(|addr| format!("knx://{}", addr)) }) - .with_deserializer(|data: &[u8]| SwitchState::from_knx_bytes(data)) + .with_deserializer(|_ctx, data: &[u8]| SwitchState::from_knx_bytes(data)) .finish(); }); diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 54179402..305d83da 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -493,7 +493,7 @@ impl MqttConnectorImpl { ); // Route the message through the router to the appropriate producer - if let Err(_e) = router_for_task.route(&topic, &payload).await { + if let Err(_e) = router_for_task.route(&topic, &payload, None).await { #[cfg(feature = "defmt")] defmt::warn!("Failed to route MQTT message from '{}'", topic.as_str()); } diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 2d4610e6..a1f5c163 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -489,7 +489,7 @@ fn spawn_event_loop(mut event_loop: EventLoop, _broker_key: String, router: Arc< ); // Route to appropriate producer(s) - if let Err(_e) = router.route(&topic, &payload).await { + if let Err(_e) = router.route(&topic, &payload, None).await { #[cfg(feature = "tracing")] tracing::error!("Failed to route message on topic '{}': {}", topic, _e); } diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index 67f95765..0f6c3868 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -305,7 +305,7 @@ async fn test_topic_resolver_with_connector_registration() { // Late-binding: resolve from environment at startup std::env::var("TEST_COMMAND_TOPIC").ok() }) - .with_deserializer(|data: &[u8]| Command::from_json(data)) + .with_deserializer(|_ctx, data: &[u8]| Command::from_json(data)) .finish(); }); diff --git a/aimdb-websocket-connector/src/client/connector.rs b/aimdb-websocket-connector/src/client/connector.rs index 235195f4..76f12e20 100644 --- a/aimdb-websocket-connector/src/client/connector.rs +++ b/aimdb-websocket-connector/src/client/connector.rs @@ -358,7 +358,7 @@ impl WsClientConnectorImpl { continue; } }; - if let Err(_e) = router.route(&topic, &bytes).await { + if let Err(_e) = router.route(&topic, &bytes, None).await { #[cfg(feature = "tracing")] tracing::warn!( "WS client: route failed for topic '{}': {:?}", diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/session.rs index 24e4b140..b8e5fd72 100644 --- a/aimdb-websocket-connector/src/session.rs +++ b/aimdb-websocket-connector/src/session.rs @@ -379,7 +379,7 @@ async fn handle_write( }; // Dispatch through the inbound router - if let Err(_e) = ctx.router.route(&topic, &bytes).await { + if let Err(_e) = ctx.router.route(&topic, &bytes, None).await { #[cfg(feature = "tracing")] tracing::warn!("{}: write routing failed for '{}': {}", id, topic, _e); diff --git a/docs/design/026-context-aware-deserializers.md b/docs/design/026-context-aware-deserializers.md new file mode 100644 index 00000000..9c2b682b --- /dev/null +++ b/docs/design/026-context-aware-deserializers.md @@ -0,0 +1,652 @@ +# Design: Context-Aware Deserializers + +**Status:** 📋 Planned +**Author:** GitHub Copilot +**Date:** 2026-04-08 +**Issue:** [#56 — Context-aware deserializers](https://github.com/aimdb-dev/aimdb/issues/56) +**Target:** Both `std` and `no_std` (with `alloc`) environments + +--- + +## Table of Contents + +- [Problem Statement](#problem-statement) +- [Goals](#goals) +- [Non-Goals](#non-goals) +- [Current Architecture](#current-architecture) +- [Design Constraints](#design-constraints) +- [Proposed Solution](#proposed-solution) +- [API Design](#api-design) +- [Implementation](#implementation) +- [Alternatives Considered](#alternatives-considered) +- [Migration & Backward Compatibility](#migration--backward-compatibility) +- [Testing Strategy](#testing-strategy) +- [Implementation Plan](#implementation-plan) + +--- + +## Problem Statement + +Connector deserializers currently receive only raw bytes (`&[u8]`), with no access to `RuntimeContext`. This prevents platform-independent timestamping, diagnostic logging, and consistent cross-runtime behavior during deserialization. + +Today: + +```rust +.link_from("knx://gateway/9/1/0") + .with_deserializer(|data: &[u8]| { + records::temperature::knx::from_knx(data, "9/1/0") + // ❌ Cannot timestamp with ctx.time().now() + // ❌ Cannot log deserialization diagnostics + }) + .finish(); +``` + +Monitors (`.tap()`) already receive `RuntimeContext` via the `extract_from_any` pattern in `ext_macros.rs`, but deserializers are excluded from this capability. Users must work around this by: + +1. **Setting timestamps in monitors** — complicates data flow; timestamp reflects processing time, not receive time +2. **Using `Debug` on `Instant`** — hacky, platform-dependent +3. **Restructuring architectures** — adding unnecessary monitors just to inject context + +These workarounds violate AimDB's principle of clean, declarative data pipelines. + +## Goals + +1. **Context access in deserializers** — Provide `RuntimeContext` to deserialization closures for timestamps, logging, and runtime services +2. **Consistent `tap` / `tap_raw` convention** — `.with_deserializer()` injects context by default; `.with_deserializer_raw()` is the bytes-only escape hatch +3. **no_std compatible** — Works with both `std` and `no_std + alloc` environments +4. **All runtimes** — Consistent API across Tokio, Embassy, and WASM adapters +5. **Minimal core changes** — Follow the established type-erasure patterns already used by `DeserializerFn` and `TopicProvider` + +## Non-Goals + +- Backward compatibility for `.with_deserializer()` call sites (this is an intentional breaking change; existing callers migrate to `|ctx, data|` or rename to `.with_deserializer_raw()`) +- Providing mutable database access from within deserializers +- Async deserializers (deserialization should remain synchronous) +- Providing context to serializers (outbound direction — separate concern) + +## Current Architecture + +### Deserializer Type Chain + +``` +User closure Typed alias Type-erased storage +───────────── ─────────── ─────────────────── +|data: &[u8]| -> Result TypedDeserializerFn DeserializerFn + Arc Result> -> Result, String>> +``` + +**Defined in:** +- `TypedDeserializerFn` — `aimdb-core/src/typed_api.rs` (typed, generic) +- `DeserializerFn` — `aimdb-core/src/connector.rs` (type-erased, stored in `InboundConnectorLink`) + +### Data Flow (Inbound) + +``` +External System (MQTT/KNX/...) + │ + ▼ + Connector Event Loop ← receives raw bytes + │ + ▼ + Router::route(topic, &[u8]) ← dispatches to matching routes + │ + ▼ + (route.deserializer)(payload) ← ⭐ INJECTION POINT — no context available + │ + ▼ + route.producer.produce_any() ← pushes typed value into buffer + │ + ▼ + Buffer → Consumers / Monitors +``` + +### How Monitors Get Context (Precedent) + +The `.tap()` API already injects `RuntimeContext` via a type-erasure boundary: + +```rust +// ext_macros.rs — generated per runtime adapter +fn tap(&'a mut self, f: F) -> ... +where + F: FnOnce(RuntimeContext, Consumer) -> Fut + Send + 'static, +{ + self.tap_raw(|consumer, ctx_any| { + let ctx = RuntimeContext::extract_from_any(ctx_any); // downcast Arc → R + f(ctx, consumer) + }) +} +``` + +The new deserializer API should follow the same `extract_from_any` pattern. + +## Design Constraints + +1. **`DeserializerFn` is `Fn`, not `async`** — deserialization must remain synchronous. `RuntimeContext::time().now()` and `RuntimeContext::log()` are both synchronous, so this is fine. + +2. **`DeserializerFn` is stored in `InboundConnectorLink`** — created during configuration, before the runtime `Arc` context is available. Context must be injected later, at route construction or dispatch time. + +3. **`Router::route()` is runtime-agnostic** — the router has no generic `R` parameter. Context must flow in as `Arc`, matching the existing erasure pattern. + +4. **no_std: `Arc` comes from `alloc`** — the same approach as `DeserializerFn` and `TopicProviderAny`. + +5. **WASM is `no_std + alloc` with `Arc`** — the WASM adapter uses `Arc` (like Tokio), not `&'static` (like Embassy). Since WASM is single-threaded, `Send + Sync` are trivially satisfied via unsafe impl. + +## Proposed Solution + +### Overview + +Follow the established `tap` / `tap_raw` convention: make `.with_deserializer()` context-aware by default, and add `.with_deserializer_raw()` as the low-level escape hatch that receives only bytes. + +At the core level, introduce `ContextDeserializerFn` — a type-erased callback that accepts `(Arc, &[u8])`. This becomes the **primary** deserializer stored in `InboundConnectorLink`. The existing `DeserializerFn` (bytes-only) is retained as `with_deserializer_raw()` for cases where context is unnecessary. + +Unlike `.tap()` (which requires macro generation because `RecordRegistrar` has no generic `R`), `InboundConnectorBuilder<'a, T, R>` already carries the runtime generic `R`. This means `.with_deserializer()` can be implemented **directly in `typed_api.rs`** with an `R: Runtime` bound — no `ext_macros.rs` changes needed. The method wraps the user's `|ctx, data|` closure with `RuntimeContext::extract_from_any`, reusing the existing downcast mechanism. + +### Why This Approach + +- **Mirrors `tap` / `tap_raw` exactly** — context injection is the default; raw is the escape hatch +- **Consistent API surface** — users learn one pattern for all context-injected APIs +- **No generic `R` on Router** — context stays type-erased at the router level +- **No macro generation needed** — `InboundConnectorBuilder` already has `R`, so `.with_deserializer()` lives in `typed_api.rs` directly +- **Synchronous** — `now()` and `log()` don't require async +- **Reuses `extract_from_any`** — no new downcast method needed; Arc clone cost (one atomic increment) is negligible vs. deserialization work + +## API Design + +### User-Facing API + +```rust +// DEFAULT: Context-aware deserializer (mirrors .tap()) +builder.configure::(SensorKey::Outdoor, |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("knx://gateway/9/1/0") + .with_deserializer(|ctx, data: &[u8]| { + let mut temp = records::temperature::knx::from_knx(data, "9/1/0")?; + temp.timestamp = ctx.time().now(); // Platform-independent timing + ctx.log().debug("Deserialized KNX temperature"); + Ok(temp) + }) + .finish(); +}); + +// RAW: Plain deserializer without context (mirrors .tap_raw()) +builder.configure::(SensorKey::Outdoor, |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("knx://gateway/9/1/0") + .with_deserializer_raw(|data: &[u8]| { + records::temperature::knx::from_knx(data, "9/1/0") + }) + .finish(); +}); +``` + +### Pattern Consistency with `tap` / `tap_raw` + +| API | Context injected | Defined in | +|-----|-----------------|------------| +| `.tap(\|ctx, consumer\| ...)` | Yes | `ext_macros.rs` (generated) | +| `.tap_raw(\|consumer, ctx_any\| ...)` | No (raw `Arc`) | `aimdb-core` | +| `.with_deserializer(\|ctx, data\| ...)` | **Yes** | `aimdb-core/src/typed_api.rs` (direct) | +| `.with_deserializer_raw(\|data\| ...)` | **No** | `aimdb-core/src/typed_api.rs` (direct) | + +### Mutually Exclusive + +`.with_deserializer()` and `.with_deserializer_raw()` are mutually exclusive on the same link — calling one replaces the other. This avoids ambiguity about which deserializer runs. + +## Implementation + +### 1. New Type Alias — `ContextDeserializerFn` + +**File:** `aimdb-core/src/connector.rs` + +```rust +/// Type alias for context-aware type-erased deserializer callbacks +/// +/// Like `DeserializerFn`, but receives a type-erased runtime context +/// for platform-independent timestamps and logging during deserialization. +/// +/// The first argument is the type-erased runtime (as `Arc`), +/// which is downcast to the concrete runtime type via `RuntimeContext::extract_from_any`. +pub type ContextDeserializerFn = Arc< + dyn Fn(Arc, &[u8]) -> Result, String> + + Send + + Sync, +>; +``` + +This uses `Arc` from `alloc::sync` (re-exported in `std`), matching the existing `DeserializerFn` definition which also uses bare `Arc` without cfg gates. + +Note: the context is passed as `Arc` (cloned from the connector's stored runtime Arc). This is a single atomic reference count increment per message — negligible compared to actual deserialization work. Using `Arc` (rather than `&dyn Any`) is **required** because `RuntimeContext::extract_from_any` needs to `downcast::()` on an owned Arc, and on `no_std` it leaks the Arc to obtain a `&'static R` for the Embassy adapter's static-reference model. + +### 2. Extend `InboundConnectorLink` + +**File:** `aimdb-core/src/connector.rs` + +Store the deserializer as an enum to enforce mutual exclusivity: + +```rust +/// Which deserializer variant is registered for an inbound link +pub enum DeserializerKind { + /// Plain bytes-only deserializer (from `.with_deserializer_raw()`) + Raw(DeserializerFn), + /// Context-aware deserializer (from `.with_deserializer()` in typed_api.rs) + Context(ContextDeserializerFn), +} + +pub struct InboundConnectorLink { + pub url: ConnectorUrl, + pub config: Vec<(String, String)>, + pub deserializer: DeserializerKind, + + #[cfg(feature = "alloc")] + pub producer_factory: Option, + pub topic_resolver: Option, +} +``` + +### 3. Extend `Route` and `Router::route()` + +**File:** `aimdb-core/src/router.rs` + +```rust +pub struct Route { + pub resource_id: Arc, + pub producer: Box, + pub deserializer: DeserializerKind, +} +``` + +Update `Router::route()` to accept an optional context and dispatch accordingly: + +```rust +impl Router { + /// Route a message, with optional runtime context for deserializers + /// + /// Dispatches based on `DeserializerKind`: + /// - `Raw` — calls deserializer with payload only + /// - `Context` — calls deserializer with context + payload + /// + /// If a `Context` deserializer is registered but no context is provided, + /// the route is skipped with a warning (connector hasn't been migrated yet). + pub async fn route( + &self, + resource_id: &str, + payload: &[u8], + ctx: Option<&Arc>, + ) -> Result<(), String> { + for route in &self.routes { + if route.resource_id.as_ref() == resource_id { + let result = match &route.deserializer { + DeserializerKind::Raw(deser) => (deser)(payload), + DeserializerKind::Context(deser) => match ctx { + Some(ctx) => (deser)(ctx.clone(), payload), + None => { + #[cfg(feature = "tracing")] + tracing::warn!( + "Context deserializer on '{}' but no context provided", + resource_id + ); + continue; + } + }, + }; + + match result { + Ok(value_any) => { + route.producer.produce_any(value_any).await?; + } + Err(e) => { /* existing error logging */ } + } + } + } + Ok(()) + } +} +``` + +**Note on `Router::route()` signature change:** The existing `route(&self, resource_id, payload)` gains an `Option` context parameter (a reference to `Arc`). This is a **one-time breaking change** at the core level, but all call sites are internal (inside connectors), not user-facing. Existing connectors pass `None` until migrated to pass the runtime `Arc`. + +### 4. Thread Context Through `collect_inbound_routes` + +**File:** `aimdb-core/src/builder.rs` + +Replace the separate `DeserializerFn` with `DeserializerKind`: + +```rust +pub fn collect_inbound_routes( + &self, + scheme: &str, +) -> Vec<( + String, + Box, + crate::connector::DeserializerKind, // CHANGED from DeserializerFn +)> { + // ... existing iteration logic ... + routes.push((topic, producer, link.deserializer.clone())); +} +``` + +`RouterBuilder::from_routes()` is updated to accept `DeserializerKind` directly. + +**Note:** This is a breaking change to an internal API. There are 2 call sites for `collect_inbound_routes` (both in `aimdb-websocket-connector`) and 5 call sites for `router.route()` across the MQTT, KNX, and WebSocket connectors, plus 3 unit tests in `router.rs`. All must be updated. + +### 5. Core Builder Method — `with_deserializer_raw()` + +**File:** `aimdb-core/src/typed_api.rs` + +Rename the existing `with_deserializer()` to `with_deserializer_raw()` — this is the bytes-only variant that lives in core and requires no runtime generic: + +```rust +impl<'a, T, R> InboundConnectorBuilder<'a, T, R> +where + T: Send + Sync + 'static + Debug + Clone, + R: Spawn + 'static, +{ + /// Sets a raw deserialization callback (bytes only, no context) + /// + /// Prefer `.with_deserializer(|ctx, data| ...)` for access to + /// `RuntimeContext` (timestamps, logging). Use this raw variant + /// only when context is unnecessary. + pub fn with_deserializer_raw(mut self, f: F) -> Self + where + F: Fn(&[u8]) -> Result + Send + Sync + 'static, + { + self.deserializer = Some(Arc::new(f)); + self.context_deserializer = None; // mutually exclusive + self + } +} +``` + +Note: The builder stores typed closures (`TypedDeserializerFn` / `ContextDeserializerFn`). Type erasure and wrapping into `DeserializerKind` happens in `finish()`. + +### 6. Context-Aware Builder Method — `with_deserializer()` (in typed_api.rs) + +**File:** `aimdb-core/src/typed_api.rs` + +Unlike `.tap()` — which needs `ext_macros.rs` generation because `RecordRegistrar` has no generic `R` — the `InboundConnectorBuilder<'a, T, R>` already carries `R` as a generic parameter. This means `.with_deserializer()` can be added **directly** in `typed_api.rs` with an additional `R: Runtime` bound: + +```rust +impl<'a, T, R> InboundConnectorBuilder<'a, T, R> +where + T: Send + Sync + 'static + Debug + Clone, + R: Runtime + Send + Sync + 'static, +{ + /// Sets a context-aware deserialization callback + /// + /// The closure receives a `RuntimeContext` for platform-independent + /// timestamps and logging, plus the raw bytes from the external system. + /// + /// # Example + /// + /// ```rust,ignore + /// .link_from("knx://gateway/9/1/0") + /// .with_deserializer(|ctx, data: &[u8]| { + /// let mut temp = from_knx(data, "9/1/0")?; + /// temp.timestamp = ctx.time().now(); + /// Ok(temp) + /// }) + /// ``` + pub fn with_deserializer(mut self, f: F) -> Self + where + F: Fn(RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, + { + let f = Arc::new(f); + self.context_deserializer = Some(Arc::new(move |ctx_any, bytes| { + let ctx = RuntimeContext::::extract_from_any(ctx_any); + (f)(ctx, bytes).map(|val| Box::new(val) as Box) + })); + self.deserializer = None; // mutually exclusive + self + } +} +``` + +**Why no `extract_from_any_ref`?** The existing `extract_from_any` (which takes `Arc`) is reused as-is. A borrowed `&dyn Any` variant was considered but is unsound: + +- When a connector does `runtime_any.as_ref()` on `Arc` where the inner type is `R`, the resulting `&dyn Any` has concrete type `R`, **not** `Arc`. So `downcast_ref::>()` would always return `None`. +- Even if corrected to `downcast_ref::()`, it yields `&R` — but `RuntimeContext` requires `Arc` (std) or `&'static R` (no_std). Neither can be obtained from a plain `&R`. +- On `no_std`, the existing `extract_from_any` leaks `Arc` to `&'static R` via `Box::leak()`. This is acceptable for one-time `tap()` setup but would be a **memory leak per message** if called in a deserializer hot path via a ref variant. + +The `Arc` clone (one atomic increment per message) is the correct approach — negligible cost compared to actual deserialization work, and consistent with the established pattern. + +### 7. Update `InboundConnectorBuilder` Struct + +**File:** `aimdb-core/src/typed_api.rs` + +The builder needs a second optional field for the context-aware deserializer. Type erasure happens in `finish()`, not at set-time: + +```rust +pub struct InboundConnectorBuilder<'a, T, R> { + registrar: &'a mut RecordRegistrar<'a, T, R>, + url: String, + config: Vec<(String, String)>, + deserializer: Option>, // raw (bytes only) + context_deserializer: Option, // context-aware (already type-erased) + topic_resolver: Option, +} +``` + +The `finish()` method resolves which variant was set: + +```rust +pub fn finish(self) -> &'a mut RecordRegistrar<'a, T, R> { + // ... existing URL parsing and validation ... + + // Resolve deserializer variant (mutually exclusive, validated above) + let deser_kind = if let Some(ctx_deser) = self.context_deserializer { + DeserializerKind::Context(ctx_deser) + } else if let Some(raw_deser) = self.deserializer { + // Type-erase the raw deserializer (existing pattern) + let erased: DeserializerFn = Arc::new(move |bytes: &[u8]| { + raw_deser(bytes).map(|val| Box::new(val) as Box) + }); + DeserializerKind::Raw(erased) + } else { + panic!( + "Inbound connector requires a deserializer. Call .with_deserializer() for {}", + self.url + ); + }; + + let mut link = InboundConnectorLink::new(url, deser_kind); + // ... rest of finish() unchanged ... +} +``` + +**Note:** `InboundConnectorLink::new()` signature changes to accept `DeserializerKind` instead of `DeserializerFn`. + +### 8. Connector Adaptation — Pass Context to Router + +Each connector passes the runtime `Arc` to `router.route()` as `Some(&ctx)`. + +**Note:** There is no `runtime_any()` method on the database. The runtime `Arc` is materialized inline via Rust's coercion rules (`Arc` → `Arc`), as already done in `spawn_consumer_service()` and `spawn_producer_service()`. Connectors must capture this during the build phase. + +**MQTT example** (`aimdb-mqtt-connector/src/tokio_client.rs`): + +```rust +// During build: capture runtime Arc via coercion from the database's inner runtime +// (The exact mechanism depends on how the connector accesses the database. +// A new accessor method may be needed, or the runtime can be threaded +// through the existing connector builder API.) +let runtime_any: Arc = runtime_arc.clone(); // Arc → Arc + +// In event loop: +if let rumqttc::Event::Incoming(Packet::Publish(publish)) = notification { + if let Err(e) = router.route( + &publish.topic, + &publish.payload, + Some(&runtime_any), + ).await { + // error handling + } +} +``` + +Connectors that haven't been migrated yet pass `None` — raw deserializers work, context deserializers log a warning and skip. + +**Plumbing options for connectors to obtain the runtime Arc:** +1. Add a `runtime_any()` accessor to `AimDb` that returns `&Arc` +2. Thread the runtime through the connector builder (e.g., `MqttConnectorBuilder::with_runtime(arc)`) +3. Extract from `collect_inbound_routes()` by extending its return tuple + +Option 1 is simplest and consistent with how `collect_inbound_routes` already receives the database. + +## Alternatives Considered + +### A. Capture Context in Closure at Configuration Time + +```rust +// Not viable — RuntimeContext doesn't exist during configuration +let ctx = ???; +.with_deserializer(move |data| { + let mut temp = from_knx(data)?; + temp.timestamp = ctx.time().now(); + Ok(temp) +}) +``` + +**Rejected:** The runtime adapter hasn't been constructed yet during `builder.configure()`. The context is only available after `AimDbBuilder::build()`. + +### B. Two-Phase Deserializer (Curried) + +```rust +// User provides a factory that receives context once, returns deserializer +.with_deserializer_factory(|ctx: RuntimeContext| { + move |data: &[u8]| { /* ... */ } +}) +``` + +**Rejected:** More complex API, harder to understand, and the closure-returning-closure pattern is ergonomically awkward in Rust (lifetime and ownership friction). + +### C. Make `DeserializerFn` Always Context-Aware + +Change `DeserializerFn` to always take `(&dyn Any, &[u8])`, passing a dummy context for plain deserializers. + +**Rejected:** Forces all deserializers to accept and ignore a context argument, even when they don't need it. The `DeserializerKind` enum is more expressive and avoids dummy values. + +### D. Context via Thread-Local / Global + +Store `RuntimeContext` in a thread-local during `Router::route()`, accessible by plain `DeserializerFn` closures. + +**Rejected:** Incompatible with `no_std` / Embassy; couples deserializers to hidden global state; violates Rust's explicit-is-better principle. + +### E. Context at the InboundConnectorLink Level (Lazy Injection) + +Inject context into `InboundConnectorLink` during `collect_inbound_routes`, binding it into the existing `DeserializerFn` closure at that point. + +**Rejected:** `collect_inbound_routes` returns owned tuples (not references to links), so there's no place to bind context after configuration but before route dispatch. Would require restructuring the entire collection pipeline. + +### F. Borrowed Context via `&dyn Any` (Allocation-Free Dispatch) + +Pass context as `&dyn Any` (borrowed from Arc) instead of cloning `Arc` per message, to avoid the atomic increment cost. + +**Rejected:** Unsound. When dereferencing `Arc` where inner type is `R`, the resulting `&dyn Any` has concrete type `R`, not `Arc`. `RuntimeContext::extract_from_any` requires an owned `Arc` for `downcast::()`, and on `no_std` it leaks the Arc to `&'static R` — calling this per-message would be a memory leak. The Arc clone cost (one atomic increment) is negligible vs. deserialization. + +### G. Generate `with_deserializer()` via `ext_macros.rs` (Like `tap()`) + +Generate the context-aware method in the `impl_record_registrar_ext!` macro, mirroring how `.tap()` wraps `.tap_raw()`. + +**Rejected (unnecessary):** Unlike `RecordRegistrar` (which has no generic `R`, requiring the macro to inject the concrete runtime type), `InboundConnectorBuilder<'a, T, R>` already carries `R` as a generic parameter. The method can be added directly in `typed_api.rs` with an `R: Runtime` bound, avoiding macro complexity for no benefit. + +## Migration & Backward Compatibility + +### Breaking Changes + +- **`.with_deserializer()` signature changes** — now takes `|ctx, data|` instead of `|data|`. This is the only user-facing breaking change. +- **`Router::route()` gains `Option` context parameter** — internal to connectors, not user-facing. 5 connector call sites + 3 tests. +- **`InboundConnectorLink::deserializer` changes to `DeserializerKind`** — internal type, not user-facing. +- **`InboundConnectorLink::new()` takes `DeserializerKind`** — internal constructor change. +- **`collect_inbound_routes()` return type changes** — `DeserializerFn` → `DeserializerKind`. 2 call sites in `aimdb-websocket-connector`. +- **`RouterBuilder::from_routes()` / `add_route()` take `DeserializerKind`** — internal builder change. + +### What Stays the Same + +- **`DeserializerFn` type alias** — retained for `with_deserializer_raw()` and internal use +- **All existing connector logic** — connectors pass `None` until migrated + +### Migration Path for Users + +```rust +// BEFORE: plain deserializer +.with_deserializer(|data: &[u8]| { + records::temperature::knx::from_knx(data, "9/1/0") +}) + +// AFTER (option A): upgrade to context-aware (recommended) +.with_deserializer(|ctx, data: &[u8]| { + let mut temp = records::temperature::knx::from_knx(data, "9/1/0")?; + temp.timestamp = ctx.time().now(); + Ok(temp) +}) + +// AFTER (option B): rename to raw if context not needed +.with_deserializer_raw(|data: &[u8]| { + records::temperature::knx::from_knx(data, "9/1/0") +}) +``` + +The compiler guides this migration — existing `|data|` closures won't type-check against the new `|ctx, data|` signature, and the error message points users to `.with_deserializer_raw()`. + +## Testing Strategy + +### Unit Tests + +1. **`ContextDeserializerFn` type-erasure round-trip** — verify a typed context deserializer can be stored and called through the type-erased alias +2. **`extract_from_any` with Arc clone** — verify both `std` and `no_std` variants downcast correctly when cloning the `Arc` per call, and panic on type mismatch +3. **`InboundConnectorLink` with both deserializer types** — verify `DeserializerKind` stores and clones correctly for both `Raw` and `Context` variants + +### Integration Tests + +4. **`Router::route()` with context** — build a router with a `DeserializerKind::Context` route, invoke `route()` with `Some(&ctx)`, verify the deserialized value includes context-derived data (e.g., a timestamp field) +5. **`Router::route()` raw fallback** — verify that `DeserializerKind::Raw` routes work correctly when called with `Some(&ctx)` (context is ignored) +6. **`Router::route()` with `None` context** — verify `DeserializerKind::Raw` routes work with `None`; verify `DeserializerKind::Context` routes are skipped with a warning +7. **Mixed routes** — router with both `Raw` and `Context` deserializers on different topics, verify each dispatches correctly + +### Runtime Adapter Tests + +8. **Tokio adapter** — `#[tokio::test]` with a full `AimDb` instance, register a context-aware deserializer via `.with_deserializer(|ctx, data| ...)`, simulate an inbound message, verify the record contains a platform timestamp +9. **Embassy adapter** — cross-compile check (`cargo check --target thumbv7em-none-eabihf`) ensuring `ContextDeserializerFn` compiles for `no_std` +10. **WASM adapter** — cross-compile check (`cargo check --target wasm32-unknown-unknown`) ensuring the `no_std + alloc + Arc` path works + +### Connector Tests + +11. **MQTT connector** — verify `route()` is called with `Some(&ctx)` when processing incoming MQTT messages +12. **KNX connector** — same for KNX telegrams (both std and embassy variants) +13. **WebSocket connector** — same for WebSocket messages + +## Implementation Plan + +### Phase 1: Core Types + +- [ ] Add `ContextDeserializerFn` type alias in `connector.rs` (uses `alloc::sync::Arc`, no cfg split) +- [ ] Add `DeserializerKind` enum in `connector.rs` +- [ ] Refactor `InboundConnectorLink` to use `DeserializerKind` (update `new()`, `Clone` impl) +- [ ] Refactor `Route` to use `DeserializerKind` +- [ ] Update `Router::route()` to accept `Option<&Arc>` context (no cfg split) +- [ ] Update `RouterBuilder::from_routes()` and `add_route()` for `DeserializerKind` +- [ ] Unit tests for core types + +### Phase 2: Builder API + +- [ ] Add `context_deserializer: Option` field to `InboundConnectorBuilder` +- [ ] Rename existing `with_deserializer()` to `with_deserializer_raw()` +- [ ] Add context-aware `with_deserializer(|ctx, data| ...)` directly in `typed_api.rs` (with `R: Runtime` bound) +- [ ] Update `finish()` to resolve `DeserializerKind` from whichever field is set +- [ ] Update `collect_inbound_routes()` to return `DeserializerKind` +- [ ] Integration tests for builder round-trip + +### Phase 3: Connector Migration + +- [ ] Determine runtime Arc plumbing approach (add `runtime_any()` accessor or thread through builders) +- [ ] MQTT connector: pass `Some(&runtime_any)` to `router.route()` +- [ ] KNX connector (std + embassy): pass `Some(&runtime_any)` to `router.route()` +- [ ] WebSocket connector: pass `Some(&runtime_any)` to `router.route()` +- [ ] Update 3 unit tests in `router.rs` for new `route()` signature +- [ ] Connector-level tests + +### Phase 4: Examples & Documentation + +- [ ] Update KNX demo to show context-aware timestamping +- [ ] Add doc comment examples on `with_deserializer()` +- [ ] Verify `no_std` cross-compilation (Embassy: `thumbv7em-none-eabihf`, WASM: `wasm32-unknown-unknown`) diff --git a/examples/tokio-mqtt-connector-demo/src/main.rs b/examples/tokio-mqtt-connector-demo/src/main.rs index 82a4b74f..19dd561c 100644 --- a/examples/tokio-mqtt-connector-demo/src/main.rs +++ b/examples/tokio-mqtt-connector-demo/src/main.rs @@ -185,7 +185,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .tap(command_consumer) .link_from(CommandKey::TempIndoor.link_address().unwrap()) - .with_deserializer(|data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); @@ -193,7 +193,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .tap(command_consumer) .link_from(CommandKey::TempOutdoor.link_address().unwrap()) - .with_deserializer(|data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); From 841b6256d7da24e5b14aaea0678faeb084a1f42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 8 Apr 2026 20:18:45 +0000 Subject: [PATCH 02/11] feat: update deserializer functions to include context parameter for improved flexibility --- Cargo.lock | 2 +- _external/embassy | 2 +- examples/embassy-knx-connector-demo/src/main.rs | 10 +++++----- examples/embassy-mqtt-connector-demo/src/main.rs | 4 ++-- examples/tokio-knx-connector-demo/src/main.rs | 10 +++++----- examples/weather-mesh-demo/weather-hub/src/main.rs | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b7e8b0f..e8ab89a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3110,7 +3110,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-7bc624653af48a1e816b41fff096de816bf97da4#c2515258508e2a44cf3a6eea5ff7890630a2d78a" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-68326d96233978fbbfdc55c29cca8624dab43cd6#714b6bd91a4ca13c5b5b6a14c68a42de790e8b55" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/_external/embassy b/_external/embassy index e098a451..1781e4a4 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit e098a4519ab3def63e1411b4f99c974f79468983 +Subproject commit 1781e4a4d573cc6439763c5f16a64458d100d955 diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 230b6d83..36a352d6 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -268,7 +268,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Living Room", celsius)) }) @@ -279,7 +279,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Bedroom", celsius)) }) @@ -290,7 +290,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Kitchen", celsius)) }) @@ -306,7 +306,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/7", is_on)) }) @@ -317,7 +317,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/8", is_on)) }) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index a154be87..cfde2189 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -365,7 +365,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) .tap(command_consumer) .link_from(CommandKey::TempIndoor.link_address().unwrap()) - .with_deserializer(|data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); @@ -373,7 +373,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) .tap(command_consumer) .link_from(CommandKey::TempOutdoor.link_address().unwrap()) - .with_deserializer(|data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index 1169ccf5..66681d61 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -104,7 +104,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Living Room", celsius)) }) @@ -115,7 +115,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Bedroom", celsius)) }) @@ -126,7 +126,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Kitchen", celsius)) }) @@ -138,7 +138,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/7", is_on)) }) @@ -149,7 +149,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) - .with_deserializer(|data: &[u8]| { + .with_deserializer(|_ctx, data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/8", is_on)) }) diff --git a/examples/weather-mesh-demo/weather-hub/src/main.rs b/examples/weather-mesh-demo/weather-hub/src/main.rs index e3a3548d..04287c53 100644 --- a/examples/weather-mesh-demo/weather-hub/src/main.rs +++ b/examples/weather-mesh-demo/weather-hub/src/main.rs @@ -35,7 +35,7 @@ async fn main() -> aimdb_core::DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) .tap(move |ctx, consumer| log_tap(ctx, consumer, key.as_str())) .link_from(&topic) - .with_deserializer(Temperature::from_bytes) + .with_deserializer(|_ctx, data: &[u8]| Temperature::from_bytes(data)) .finish(); }); } @@ -48,7 +48,7 @@ async fn main() -> aimdb_core::DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) .tap(move |ctx, consumer| log_tap(ctx, consumer, key.as_str())) .link_from(&topic) - .with_deserializer(Humidity::from_bytes) + .with_deserializer(|_ctx, data: &[u8]| Humidity::from_bytes(data)) .finish(); }); } From e730eeb8be50990069db8d03ac8657f2c32fe7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 8 Apr 2026 20:22:49 +0000 Subject: [PATCH 03/11] feat: update changelogs to document context-aware deserializer changes and compatibility updates across connectors --- CHANGELOG.md | 17 ++++++++++++++++- aimdb-core/CHANGELOG.md | 19 ++++++++++++++++++- aimdb-knx-connector/CHANGELOG.md | 4 +++- aimdb-mqtt-connector/CHANGELOG.md | 4 +++- aimdb-websocket-connector/CHANGELOG.md | 4 +++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d612ece9..f02aa457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Added + +- **Context-Aware Deserializers (Design 026)**: Inbound connector deserializers can now receive a `RuntimeContext` for platform-independent timestamps and logging + - New `.with_deserializer(|ctx, bytes| ...)` API on `InboundConnectorBuilder` provides `RuntimeContext` to deserialization closures + - New `.with_deserializer_raw(|bytes| ...)` for plain bytes-only deserialization when context is unnecessary + - `DeserializerKind` enum enforces mutual exclusivity between raw and context-aware deserializers + - `Router::route()` propagates optional runtime context to context-aware routes +- Design document: 026 (Context-Aware Deserializers) + +### Changed + +- **aimdb-core**: Breaking API changes to `InboundConnectorLink`, `Router`, and `RouterBuilder` to support `DeserializerKind` (see [aimdb-core/CHANGELOG.md](aimdb-core/CHANGELOG.md)) +- **aimdb-mqtt-connector**: Updated router dispatch for new `route()` signature +- **aimdb-knx-connector**: Updated router dispatch for new `route()` signature +- **aimdb-websocket-connector**: Updated router dispatch for new `route()` signature +- All connector examples updated to use new `.with_deserializer(|_ctx, bytes| ...)` signature ## [1.0.0] - 2026-03-16 diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 6795bd0d..a62fd9fc 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Added + +- **Context-Aware Deserializers (Design 026)**: Inbound connector deserializers can now receive a `RuntimeContext` for platform-independent timestamps and logging during deserialization + - New `ContextDeserializerFn` type alias for context-aware type-erased deserializer callbacks + - New `DeserializerKind` enum (`Raw` / `Context`) to enforce mutual exclusivity between plain and context-aware deserializers + - `.with_deserializer(|ctx, bytes| ...)` now accepts a context-aware closure receiving `RuntimeContext` + - `.with_deserializer_raw(|bytes| ...)` added for plain bytes-only deserialization (no context needed) + - `Router::route()` now accepts an optional type-erased runtime context (`Option<&Arc>`) + - Context deserializer routes are gracefully skipped when no context is provided + +### Changed + +- **Breaking**: `InboundConnectorLink::deserializer` field type changed from `DeserializerFn` to `DeserializerKind` +- **Breaking**: `InboundConnectorLink::new()` now takes `DeserializerKind` instead of `DeserializerFn` +- **Breaking**: `Router::route()` signature changed to accept an additional `ctx` parameter +- **Breaking**: `RouterBuilder::from_routes()` and `RouterBuilder::add_route()` now take `DeserializerKind` instead of `DeserializerFn` +- **Breaking**: `.with_deserializer()` on `InboundConnectorBuilder` now expects `Fn(RuntimeContext, &[u8]) -> Result` instead of `Fn(&[u8]) -> Result` — use `.with_deserializer_raw()` for the previous bytes-only signature +- `AimDb::collect_inbound_routes()` return type updated to use `DeserializerKind` ## [1.0.0] - 2026-03-11 diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index f7f4908a..5e58279a 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- Updated `Router::route()` calls to pass `None` context in both Tokio and Embassy clients, compatible with aimdb-core context-aware deserializer changes (Design 026) ## [0.3.1] - 2026-03-16 diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 04612c4a..4a0ac7f2 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- Updated `Router::route()` calls to pass `None` context, compatible with aimdb-core context-aware deserializer changes (Design 026) ## [0.5.1] - 2026-03-16 diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 17f173d9..5200b328 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- Updated `Router::route()` calls to pass `None` context in both client connector and session handler, compatible with aimdb-core context-aware deserializer changes (Design 026) ## [0.1.0] - 2026-03-16 From 5192880bed61873496a8aa4608701fa76b8695d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 8 Apr 2026 21:35:59 +0000 Subject: [PATCH 04/11] feat: implement runtime context for context-aware deserializers across connectors --- aimdb-core/src/builder.rs | 8 +++ aimdb-knx-connector/src/embassy_client.rs | 30 ++++++--- aimdb-knx-connector/src/tokio_client.rs | 64 +++++++++++++------ aimdb-mqtt-connector/src/embassy_client.rs | 9 ++- aimdb-mqtt-connector/src/tokio_client.rs | 57 +++++++++++------ aimdb-websocket-connector/src/builder.rs | 1 + .../src/client/connector.rs | 14 +++- aimdb-websocket-connector/src/session.rs | 8 ++- 8 files changed, 135 insertions(+), 56 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 737f4448..841ccf87 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1213,6 +1213,14 @@ impl AimDb { &self.runtime } + /// Returns the runtime as a type-erased `Arc` + /// + /// Used by connectors to provide `RuntimeContext` to context-aware + /// deserializers during inbound message routing. + pub fn runtime_any(&self) -> Arc { + self.runtime.clone() + } + /// Lists all registered records (std only) /// /// Returns metadata for all registered records, useful for remote access introspection. diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 11c5dd67..5269de9d 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -133,15 +133,20 @@ where ); // Build the actual connector - let connector = - KnxConnectorImpl::build_internal(self.gateway_url.as_str(), router, db.runtime()) - .await - .map_err(|_e| { - #[cfg(feature = "defmt")] - defmt::error!("Failed to build KNX connector"); + let runtime_ctx = db.runtime_any(); + let connector = KnxConnectorImpl::build_internal( + self.gateway_url.as_str(), + router, + db.runtime(), + Some(runtime_ctx), + ) + .await + .map_err(|_e| { + #[cfg(feature = "defmt")] + defmt::error!("Failed to build KNX connector"); - aimdb_core::DbError::RuntimeError { _message: () } - })?; + aimdb_core::DbError::RuntimeError { _message: () } + })?; // Collect and spawn outbound publishers let outbound_routes = db.collect_outbound_routes("knx"); @@ -258,6 +263,7 @@ impl KnxConnectorImpl { gateway_url: &str, router: Router, runtime: &R, + runtime_ctx: Option>, ) -> Result where R: aimdb_executor::Spawn + aimdb_embassy_adapter::EmbassyNetwork + 'static, @@ -298,6 +304,7 @@ impl KnxConnectorImpl { port, router_for_task, command_channel, + runtime_ctx, ) .await; } @@ -320,6 +327,7 @@ impl KnxConnectorImpl { gateway_port: u16, router: Arc, command_channel: &'static Channel, + runtime_ctx: Option>, ) { loop { #[cfg(feature = "defmt")] @@ -335,6 +343,7 @@ impl KnxConnectorImpl { gateway_port, &router, command_channel, + runtime_ctx.as_ref(), ) .await { @@ -363,6 +372,7 @@ impl KnxConnectorImpl { gateway_port: u16, router: &Router, command_channel: &'static Channel, + runtime_ctx: Option<&Arc>, ) -> Result<(), &'static str> { // Create UDP socket with static buffers let mut rx_meta = [PacketMetadata::EMPTY; 4]; @@ -555,7 +565,9 @@ impl KnxConnectorImpl { data.len() ); - if let Err(_e) = router.route(&resource_id, &data, None).await { + if let Err(_e) = + router.route(&resource_id, &data, runtime_ctx).await + { #[cfg(feature = "defmt")] defmt::warn!( "Failed to route telegram to {}", diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index 4aee9aa2..cddaca62 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -112,20 +112,22 @@ impl ConnectorBuilder for KnxConnectorBui ); // Build the actual connector - let connector = KnxConnectorImpl::build_internal(&self.gateway_url, router) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build KNX connector: {}", e), + let runtime_ctx = db.runtime_any(); + let connector = + KnxConnectorImpl::build_internal(&self.gateway_url, router, Some(runtime_ctx)) + .await + .map_err(|e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build KNX connector: {}", e), + } } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; // Collect and spawn outbound publishers let outbound_routes = db.collect_outbound_routes("knx"); @@ -166,7 +168,11 @@ impl KnxConnectorImpl { /// # Arguments /// * `gateway_url` - Gateway URL (knx://host:port) /// * `router` - Pre-configured router with all routes - async fn build_internal(gateway_url: &str, router: Router) -> Result { + async fn build_internal( + gateway_url: &str, + router: Router, + runtime_ctx: Option>, + ) -> Result { // Parse the gateway URL let mut url = gateway_url.to_string(); @@ -191,8 +197,12 @@ impl KnxConnectorImpl { let router_arc = Arc::new(router); // Spawn background connection task with reconnection - let command_tx = - spawn_connection_task(gateway_ip.clone(), gateway_port, router_arc.clone()); + let command_tx = spawn_connection_task( + gateway_ip.clone(), + gateway_port, + router_arc.clone(), + runtime_ctx, + ); Ok(Self { router: router_arc, @@ -415,6 +425,7 @@ impl aimdb_core::transport::Connector for KnxConnectorImpl { /// * `gateway_ip` - Gateway IP address /// * `gateway_port` - Gateway port (typically 3671) /// * `router` - Router for dispatching telegrams to producers +/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers /// /// # Returns /// * Command sender for publishing outbound telegrams @@ -422,6 +433,7 @@ fn spawn_connection_task( gateway_ip: String, gateway_port: u16, router: Arc, + runtime_ctx: Option>, ) -> mpsc::Sender { let (command_tx, mut command_rx) = mpsc::channel(32); // Queue size: 32 @@ -434,8 +446,14 @@ fn spawn_connection_task( ); loop { - match connect_and_listen(&gateway_ip, gateway_port, router.clone(), &mut command_rx) - .await + match connect_and_listen( + &gateway_ip, + gateway_port, + router.clone(), + &mut command_rx, + runtime_ctx.as_ref(), + ) + .await { Ok(_) => { #[cfg(feature = "tracing")] @@ -567,11 +585,13 @@ impl ChannelState { /// * `gateway_port` - Gateway port /// * `router` - Router for dispatching messages /// * `command_rx` - Command receiver for outbound publishing +/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers async fn connect_and_listen( gateway_ip: &str, gateway_port: u16, router: Arc, command_rx: &mut mpsc::Receiver, + runtime_ctx: Option<&Arc>, ) -> Result<(), String> { // 1. Create UDP socket let socket = UdpSocket::bind("0.0.0.0:0") @@ -688,7 +708,7 @@ async fn connect_and_listen( tracing::debug!("KNX telegram: {} ({} bytes)", resource_id, data.len()); // Dispatch via router - if let Err(_e) = router.route(&resource_id, &data, None).await { + if let Err(_e) = router.route(&resource_id, &data, runtime_ctx).await { #[cfg(feature = "tracing")] tracing::warn!("Router dispatch failed for {}: {:?}", resource_id, _e); } @@ -1103,14 +1123,16 @@ mod tests { #[tokio::test] async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); - let connector = KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", router).await; + let connector = + KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", router, None).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_connector_with_port() { let router = RouterBuilder::new().build(); - let connector = KnxConnectorImpl::build_internal("knx://gateway.local:3672", router).await; + let connector = + KnxConnectorImpl::build_internal("knx://gateway.local:3672", router, None).await; assert!(connector.is_ok()); } diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 305d83da..e19f7f83 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -303,11 +303,13 @@ where defmt::info!("MQTT router has {} topics", router.resource_ids().len()); // Build the actual connector + let runtime_ctx = db.runtime_any(); let connector = MqttConnectorImpl::build_internal( &self.broker_url, &self.client_id, router, db.runtime(), + Some(runtime_ctx), ) .await .map_err(|_e| { @@ -363,11 +365,13 @@ impl MqttConnectorImpl { /// * `client_id` - MQTT client identifier /// * `router` - Pre-configured router with all routes /// * `runtime` - Embassy runtime adapter for spawning and network access + /// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers async fn build_internal( broker_url: &str, client_id: &str, router: Router, runtime: &R, + runtime_ctx: Option>, ) -> Result where R: aimdb_executor::Spawn + aimdb_embassy_adapter::EmbassyNetwork + 'static, @@ -493,7 +497,10 @@ impl MqttConnectorImpl { ); // Route the message through the router to the appropriate producer - if let Err(_e) = router_for_task.route(&topic, &payload, None).await { + if let Err(_e) = router_for_task + .route(&topic, &payload, runtime_ctx.as_ref()) + .await + { #[cfg(feature = "defmt")] defmt::warn!("Failed to route MQTT message from '{}'", topic.as_str()); } diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index a1f5c163..ead1aee7 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -117,21 +117,26 @@ impl ConnectorBuilder for MqttConnectorBu tracing::info!("MQTT router has {} topics", router.resource_ids().len()); // Build the actual connector - let connector = - MqttConnectorImpl::build_internal(&self.broker_url, self.client_id.clone(), router) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build MQTT connector: {}", e).into(), - } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; + let runtime_ctx = db.runtime_any(); + let connector = MqttConnectorImpl::build_internal( + &self.broker_url, + self.client_id.clone(), + router, + Some(runtime_ctx), + ) + .await + .map_err(|e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build MQTT connector: {}", e).into(), + } + } + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; // NEW: Collect and spawn outbound publishers let outbound_routes = db.collect_outbound_routes("mqtt"); @@ -171,10 +176,12 @@ impl MqttConnectorImpl { /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) /// * `client_id` - Optional client ID (if None, generates UUID-based ID) /// * `router` - Pre-configured router with all routes + /// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers async fn build_internal( broker_url: &str, client_id: Option, router: Router, + runtime_ctx: Option>, ) -> Result { // Parse the broker URL - we accept it with or without a topic let mut url = broker_url.to_string(); @@ -243,7 +250,7 @@ impl MqttConnectorImpl { let client_arc = Arc::new(client); // CRITICAL: Spawn event loop BEFORE subscribing to topics - spawn_event_loop(event_loop, broker_key, router_arc.clone()); + spawn_event_loop(event_loop, broker_key, router_arc.clone(), runtime_ctx); // Yield to ensure the event loop task is scheduled before we start subscribing tokio::task::yield_now().await; @@ -468,7 +475,13 @@ impl aimdb_core::transport::Connector for MqttConnectorImpl { /// * `event_loop` - The rumqttc EventLoop to run /// * `_broker_key` - Broker identifier for logging (unused in release builds) /// * `router` - Router for dispatching messages to producers -fn spawn_event_loop(mut event_loop: EventLoop, _broker_key: String, router: Arc) { +/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers +fn spawn_event_loop( + mut event_loop: EventLoop, + _broker_key: String, + router: Arc, + runtime_ctx: Option>, +) { tokio::spawn(async move { #[cfg(feature = "tracing")] tracing::debug!("MQTT event loop started for {}", _broker_key); @@ -489,7 +502,8 @@ fn spawn_event_loop(mut event_loop: EventLoop, _broker_key: String, router: Arc< ); // Route to appropriate producer(s) - if let Err(_e) = router.route(&topic, &payload, None).await { + if let Err(_e) = router.route(&topic, &payload, runtime_ctx.as_ref()).await + { #[cfg(feature = "tracing")] tracing::error!("Failed to route message on topic '{}': {}", topic, _e); } @@ -516,7 +530,7 @@ mod tests { async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router).await; + MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router, None).await; assert!(connector.is_ok()); } @@ -524,14 +538,15 @@ mod tests { async fn test_connector_with_port() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router).await; + MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router, None).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_invalid_url() { let router = RouterBuilder::new().build(); - let connector = MqttConnectorImpl::build_internal("not-a-valid-url", None, router).await; + let connector = + MqttConnectorImpl::build_internal("not-a-valid-url", None, router, None).await; assert!(connector.is_err()); } } diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/builder.rs index a1c7aa0e..426615bd 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/builder.rs @@ -358,6 +358,7 @@ where auto_subscribe_topics: self.auto_subscribe_topics.clone(), query_handler: self.query_handler.clone(), known_topics, + runtime_ctx: Some(db.runtime_any()), }; // ── Build connector & spawn outbound publishers ─────────────── diff --git a/aimdb-websocket-connector/src/client/connector.rs b/aimdb-websocket-connector/src/client/connector.rs index 76f12e20..c753df6e 100644 --- a/aimdb-websocket-connector/src/client/connector.rs +++ b/aimdb-websocket-connector/src/client/connector.rs @@ -129,6 +129,7 @@ impl WsClientConnectorImpl { let auto_reconnect = config.auto_reconnect; let max_reconnect_attempts = config.max_reconnect_attempts; let router_for_reconnect = router.clone(); + let runtime_ctx: Arc = db.runtime_any(); db.runtime() .spawn({ @@ -149,8 +150,9 @@ impl WsClientConnectorImpl { db.runtime() .spawn({ let router = router.clone(); + let runtime_ctx = runtime_ctx.clone(); async move { - Self::run_read_loop(ws_read, &router).await; + Self::run_read_loop(ws_read, &router, Some(&runtime_ctx)).await; #[cfg(feature = "tracing")] tracing::warn!("WS client: read loop ended"); @@ -173,6 +175,7 @@ impl WsClientConnectorImpl { db.runtime() .spawn({ let state = state.clone(); + let runtime_ctx = runtime_ctx.clone(); async move { Self::run_reconnect_watcher( state, @@ -180,6 +183,7 @@ impl WsClientConnectorImpl { reconnect_topics, router_for_reconnect, max_reconnect_attempts, + Some(runtime_ctx), ) .await; } @@ -321,6 +325,7 @@ impl WsClientConnectorImpl { >, >, router: &Router, + runtime_ctx: Option<&Arc>, ) { while let Some(Ok(msg)) = ws_read.next().await { let text = match msg { @@ -358,7 +363,7 @@ impl WsClientConnectorImpl { continue; } }; - if let Err(_e) = router.route(&topic, &bytes, None).await { + if let Err(_e) = router.route(&topic, &bytes, runtime_ctx).await { #[cfg(feature = "tracing")] tracing::warn!( "WS client: route failed for topic '{}': {:?}", @@ -424,6 +429,7 @@ impl WsClientConnectorImpl { subscribe_topics: Vec, router: Arc, max_attempts: usize, + runtime_ctx: Option>, ) { let backoff = [500u64, 1_000, 2_000, 4_000, 8_000]; let mut attempt = 0usize; @@ -480,8 +486,10 @@ impl WsClientConnectorImpl { // Spawn new read loop let router_clone = router.clone(); + let runtime_ctx_clone = runtime_ctx.clone(); tokio::spawn(async move { - Self::run_read_loop(ws_read, &router_clone).await; + Self::run_read_loop(ws_read, &router_clone, runtime_ctx_clone.as_ref()) + .await; }); // Re-subscribe diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/session.rs index b8e5fd72..74c8cbdc 100644 --- a/aimdb-websocket-connector/src/session.rs +++ b/aimdb-websocket-connector/src/session.rs @@ -124,6 +124,8 @@ pub(crate) struct SessionContext { pub query_handler: Arc, /// All outbound topics served by this endpoint, returned on `list_topics`. pub known_topics: Vec, + /// Type-erased runtime for context-aware deserializers. + pub runtime_ctx: Option>, } /// Provides the current serialized value of a record for late-join snapshots. @@ -379,7 +381,11 @@ async fn handle_write( }; // Dispatch through the inbound router - if let Err(_e) = ctx.router.route(&topic, &bytes, None).await { + if let Err(_e) = ctx + .router + .route(&topic, &bytes, ctx.runtime_ctx.as_ref()) + .await + { #[cfg(feature = "tracing")] tracing::warn!("{}: write routing failed for '{}': {}", id, topic, _e); From bc6f4502a21d23e182a8e2aa67085e152f30b5f4 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:38:28 +0200 Subject: [PATCH 05/11] Update docs/design/026-context-aware-deserializers.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/design/026-context-aware-deserializers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/026-context-aware-deserializers.md b/docs/design/026-context-aware-deserializers.md index 9c2b682b..ff1f5d2b 100644 --- a/docs/design/026-context-aware-deserializers.md +++ b/docs/design/026-context-aware-deserializers.md @@ -1,7 +1,7 @@ # Design: Context-Aware Deserializers -**Status:** 📋 Planned -**Author:** GitHub Copilot +**Status:** ✅ Implemented +**Author:** AimDB Maintainers **Date:** 2026-04-08 **Issue:** [#56 — Context-aware deserializers](https://github.com/aimdb-dev/aimdb/issues/56) **Target:** Both `std` and `no_std` (with `alloc`) environments From 8010ad77594d1f61b84c7ee2571ec5ab65db43d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 9 Apr 2026 19:44:21 +0000 Subject: [PATCH 06/11] feat: add tests for inbound connector deserializer functionality and context handling --- aimdb-core/src/typed_api.rs | 259 ++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index b64335de..62efa160 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -973,4 +973,263 @@ mod tests { assert_eq!(url.host, "factory"); assert_eq!(url.path, Some("/floor1/sensors/temp".to_string())); } + + // ==================================================================== + // Test infrastructure for InboundConnectorBuilder deserializer tests + // ==================================================================== + + /// Minimal mock runtime implementing Spawn (and Runtime for context tests) + struct MockRuntime; + + impl aimdb_executor::RuntimeAdapter for MockRuntime { + fn runtime_name() -> &'static str { + "mock" + } + } + + impl aimdb_executor::Spawn for MockRuntime { + type SpawnToken = (); + fn spawn(&self, _future: F) -> aimdb_executor::ExecutorResult + where + F: Future + Send + 'static, + { + Ok(()) + } + } + + impl aimdb_executor::TimeOps for MockRuntime { + type Instant = u64; + type Duration = u64; + fn now(&self) -> u64 { + 0 + } + fn duration_since(&self, _later: u64, _earlier: u64) -> Option { + Some(0) + } + fn millis(&self, ms: u64) -> u64 { + ms + } + fn secs(&self, secs: u64) -> u64 { + secs * 1000 + } + fn micros(&self, micros: u64) -> u64 { + micros + } + fn sleep(&self, _duration: u64) -> impl Future + Send { + core::future::ready(()) + } + } + + impl aimdb_executor::Logger for MockRuntime { + fn info(&self, _message: &str) {} + fn debug(&self, _message: &str) {} + fn warn(&self, _message: &str) {} + fn error(&self, _message: &str) {} + } + + /// Minimal mock buffer so `has_buffer()` returns true + struct MockBuffer; + + impl crate::buffer::DynBuffer for MockBuffer { + fn push(&self, _value: TestRecord) {} + fn subscribe_boxed(&self) -> Box + Send> { + unimplemented!("not needed for deserializer tests") + } + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + /// Mock connector builder that reports a given scheme + struct MockConnectorBuilder { + scheme: String, + } + + impl crate::connector::ConnectorBuilder for MockConnectorBuilder { + fn build<'a>( + &'a self, + _db: &'a crate::AimDb, + ) -> Pin>> + Send + 'a>> + { + unimplemented!("not needed for deserializer tests") + } + fn scheme(&self) -> &str { + &self.scheme + } + } + + /// Helper: build a RecordRegistrar wired to a TypedRecord with a buffer and a + /// mock connector builder for the given scheme. + fn make_registrar<'a>( + rec: &'a mut crate::typed_record::TypedRecord, + builders: &'a [Box>], + extensions: &'a crate::extensions::Extensions, + ) -> RecordRegistrar<'a, TestRecord, MockRuntime> { + RecordRegistrar { + rec, + connector_builders: builders, + record_key: "test::Record".to_string(), + extensions, + } + } + + // ==================================================================== + // Deserializer-kind selection tests + // ==================================================================== + + #[test] + fn inbound_finish_stores_raw_deserializer_kind() { + use crate::connector::DeserializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_from("mqtt://broker/topic") + .with_deserializer_raw(|bytes: &[u8]| { + Ok(TestRecord { + value: bytes.len() as i32, + }) + }) + .finish(); + + assert_eq!(rec.inbound_connectors().len(), 1); + let link = &rec.inbound_connectors()[0]; + + // Variant must be Raw + assert!( + matches!(link.deserializer, DeserializerKind::Raw(_)), + "expected DeserializerKind::Raw, got Context" + ); + + // Verify the type-erased deserializer round-trips correctly + if let DeserializerKind::Raw(ref f) = link.deserializer { + let result = f(&[1, 2, 3]).expect("deserializer should succeed"); + let record = result + .downcast::() + .expect("should downcast to TestRecord"); + assert_eq!(record.value, 3); + } + } + + #[test] + fn inbound_finish_stores_context_deserializer_kind() { + use crate::connector::DeserializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_from("mqtt://broker/topic") + .with_deserializer(|_ctx: crate::RuntimeContext, bytes: &[u8]| { + Ok(TestRecord { + value: bytes.len() as i32 * 10, + }) + }) + .finish(); + + assert_eq!(rec.inbound_connectors().len(), 1); + + assert!( + matches!( + rec.inbound_connectors()[0].deserializer, + DeserializerKind::Context(_) + ), + "expected DeserializerKind::Context, got Raw" + ); + } + + #[test] + fn inbound_raw_overrides_previous_context_deserializer() { + use crate::connector::DeserializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // Set context first, then override with raw — raw should win + reg.link_from("mqtt://broker/topic") + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + Ok(TestRecord { value: 0 }) + }) + .with_deserializer_raw(|bytes: &[u8]| { + Ok(TestRecord { + value: bytes.len() as i32, + }) + }) + .finish(); + + assert!(matches!( + rec.inbound_connectors()[0].deserializer, + DeserializerKind::Raw(_) + )); + } + + #[test] + fn inbound_context_overrides_previous_raw_deserializer() { + use crate::connector::DeserializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // Set raw first, then override with context — context should win + reg.link_from("mqtt://broker/topic") + .with_deserializer_raw(|_bytes: &[u8]| Ok(TestRecord { value: 0 })) + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + Ok(TestRecord { value: 99 }) + }) + .finish(); + + assert!(matches!( + rec.inbound_connectors()[0].deserializer, + DeserializerKind::Context(_) + )); + } + + #[test] + #[should_panic(expected = "Inbound connector requires a deserializer")] + fn inbound_finish_panics_without_deserializer() { + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // No deserializer set — should panic + reg.link_from("mqtt://broker/topic").finish(); + } } From e063032c8a802f39db569ce871b14d45e2b7145f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 9 Apr 2026 20:03:01 +0000 Subject: [PATCH 07/11] feat: refactor RuntimeContext to use Arc for no_std compatibility and simplify context creation --- aimdb-core/src/context.rs | 27 +++++++++++++-------------- aimdb-core/src/database.rs | 16 +++------------- aimdb-core/src/typed_api.rs | 3 +++ 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/aimdb-core/src/context.rs b/aimdb-core/src/context.rs index 309cf13a..10860501 100644 --- a/aimdb-core/src/context.rs +++ b/aimdb-core/src/context.rs @@ -24,7 +24,7 @@ where #[cfg(feature = "std")] runtime: std::sync::Arc, #[cfg(not(feature = "std"))] - runtime: &'static R, + runtime: alloc::sync::Arc, } #[cfg(feature = "std")] @@ -64,8 +64,15 @@ impl RuntimeContext where R: Runtime, { - /// Create a new RuntimeContext with static reference (no_std version) - pub fn new(runtime: &'static R) -> Self { + /// Create a new RuntimeContext (no_std version uses Arc internally) + pub fn new(runtime: R) -> Self { + Self { + runtime: alloc::sync::Arc::new(runtime), + } + } + + /// Create from an existing Arc to avoid double-wrapping + pub fn from_arc(runtime: alloc::sync::Arc) -> Self { Self { runtime } } @@ -74,21 +81,13 @@ where /// This is a helper for runtime adapters to convert the raw `Arc` /// context passed to `.source_raw()` and `.tap_raw()` into a typed `RuntimeContext`. /// - /// For no_std, this leaks the Arc to obtain a `&'static` reference, which is safe - /// because the runtime lives for the entire program lifetime in embedded contexts. - /// /// # Panics /// Panics if the runtime type doesn't match `R`. pub fn extract_from_any(ctx_any: alloc::sync::Arc) -> Self { let runtime = ctx_any .downcast::() .expect("Runtime type mismatch - expected matching runtime adapter"); - - // Convert Arc to &'static R by leaking it - // This is safe because in embedded contexts, the runtime lives for the entire program - let runtime_ref: &'static R = &*alloc::boxed::Box::leak(runtime.into()); - - Self::new(runtime_ref) + Self::from_arc(runtime) } } @@ -117,8 +116,8 @@ where } #[cfg(not(feature = "std"))] - pub fn runtime(&self) -> &'static R { - self.runtime + pub fn runtime(&self) -> &R { + &self.runtime } } diff --git a/aimdb-core/src/database.rs b/aimdb-core/src/database.rs index 4693efd0..33a3442f 100644 --- a/aimdb-core/src/database.rs +++ b/aimdb-core/src/database.rs @@ -12,10 +12,10 @@ use core::fmt::Debug; extern crate alloc; #[cfg(not(feature = "std"))] -use alloc::boxed::Box; +use alloc::{boxed::Box, sync::Arc}; #[cfg(feature = "std")] -use std::boxed::Box; +use std::{boxed::Box, sync::Arc}; /// AimDB Database implementation /// @@ -136,17 +136,7 @@ impl Database { where A: aimdb_executor::Runtime + Clone, { - #[cfg(feature = "std")] - { - RuntimeContext::from_arc(std::sync::Arc::new(self.adapter.clone())) - } - #[cfg(not(feature = "std"))] - { - // For no_std, we need a static reference - this would typically be handled - // by the caller storing the adapter in a static cell first - // For now, we'll document this limitation - panic!("context() not supported in no_std without a static reference. To use context(), store your adapter in a static cell (e.g., StaticCell from portable-atomic or embassy-sync), or use adapter() directly.") - } + RuntimeContext::from_arc(Arc::new(self.adapter.clone())) } } diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 62efa160..99b24d28 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -944,6 +944,9 @@ pub trait RecordT: mod tests { use super::*; + #[cfg(not(feature = "std"))] + use alloc::vec; + #[allow(dead_code)] #[derive(Clone, Debug)] struct TestRecord { From a1b5b8f837f06ce8c7dd953657d88a55dd5d0db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 9 Apr 2026 20:06:22 +0000 Subject: [PATCH 08/11] feat: enhance routing debug messages to differentiate between matched routes and missing routes --- aimdb-core/src/router.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 7257343e..1bcaf106 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -103,11 +103,13 @@ impl Router { ctx: Option<&Arc>, ) -> Result<(), String> { let mut routed = false; + let mut matched = false; // Linear search through all routes // Note: Multiple routes may match the same resource_id (different types) for route in &self.routes { if route.resource_id.as_ref() == resource_id { + matched = true; // Deserialize the payload based on deserializer kind let result = match &route.deserializer { DeserializerKind::Raw(deser) => (deser)(payload), @@ -178,11 +180,19 @@ impl Router { } if !routed { - #[cfg(feature = "tracing")] - tracing::debug!("No route found for resource: '{}'", resource_id); - - #[cfg(feature = "defmt")] - defmt::debug!("No route found for resource: '{}'", resource_id); + if matched { + #[cfg(feature = "tracing")] + tracing::debug!("Route matched for '{}' but message was not produced (missing context or errors)", resource_id); + + #[cfg(feature = "defmt")] + defmt::debug!("Route matched for '{}' but not produced", resource_id); + } else { + #[cfg(feature = "tracing")] + tracing::debug!("No route found for resource: '{}'", resource_id); + + #[cfg(feature = "defmt")] + defmt::debug!("No route found for resource: '{}'", resource_id); + } } Ok(()) From e2cc31bf5b33ea7de9c095824d98bce003273fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 9 Apr 2026 20:13:23 +0000 Subject: [PATCH 09/11] feat: update Router::route() calls to pass runtime context for context-aware deserializers --- aimdb-knx-connector/CHANGELOG.md | 2 +- aimdb-mqtt-connector/CHANGELOG.md | 2 +- aimdb-websocket-connector/CHANGELOG.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 5e58279a..96d91211 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Updated `Router::route()` calls to pass `None` context in both Tokio and Embassy clients, compatible with aimdb-core context-aware deserializer changes (Design 026) +- Updated `Router::route()` calls to pass runtime context via `db.runtime_any()` in both Tokio and Embassy clients, enabling context-aware deserializers (Design 026) ## [0.3.1] - 2026-03-16 diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 4a0ac7f2..dbbc1dc6 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Updated `Router::route()` calls to pass `None` context, compatible with aimdb-core context-aware deserializer changes (Design 026) +- Updated `Router::route()` calls to pass runtime context via `db.runtime_any()`, enabling context-aware deserializers (Design 026) ## [0.5.1] - 2026-03-16 diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 5200b328..4d6ad99a 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Updated `Router::route()` calls to pass `None` context in both client connector and session handler, compatible with aimdb-core context-aware deserializer changes (Design 026) +- Updated `Router::route()` calls to pass runtime context via `db.runtime_any()` in both client connector and session handler, enabling context-aware deserializers (Design 026) ## [0.1.0] - 2026-03-16 From 824c0e5ab7dc6050b7136322c8db791c29d5d539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 10 Apr 2026 13:24:02 +0000 Subject: [PATCH 10/11] feat: implement context-aware serializers in outbound publishers - Updated outbound publishers in both Tokio and Embassy clients to utilize `SerializerKind`, enabling context-aware serialization with `db.runtime_any()`. - Refactored serializer calls to support both raw and context-aware serializers, enhancing flexibility in serialization logic. - Adjusted tests and examples to reflect the new serializer API, ensuring compatibility with the updated design. --- CHANGELOG.md | 15 +- aimdb-codegen/src/rust.rs | 4 +- aimdb-core/CHANGELOG.md | 8 + aimdb-core/src/builder.rs | 4 +- aimdb-core/src/connector.rs | 40 ++- aimdb-core/src/typed_api.rs | 229 ++++++++++++++++-- aimdb-knx-connector/CHANGELOG.md | 1 + aimdb-knx-connector/src/embassy_client.rs | 35 ++- aimdb-knx-connector/src/lib.rs | 4 +- aimdb-knx-connector/src/tokio_client.rs | 38 ++- .../tests/topic_provider_tests.rs | 4 +- aimdb-mqtt-connector/CHANGELOG.md | 1 + aimdb-mqtt-connector/src/embassy_client.rs | 29 ++- aimdb-mqtt-connector/src/tokio_client.rs | 34 ++- .../tests/topic_provider_tests.rs | 6 +- aimdb-tokio-adapter/src/connector.rs | 2 +- aimdb-websocket-connector/CHANGELOG.md | 1 + .../src/client/connector.rs | 40 ++- aimdb-websocket-connector/src/connector.rs | 38 ++- aimdb-websocket-connector/src/lib.rs | 4 +- .../design/026-context-aware-deserializers.md | 2 +- .../embassy-knx-connector-demo/src/main.rs | 12 +- .../embassy-mqtt-connector-demo/src/main.rs | 10 +- examples/tokio-knx-connector-demo/src/main.rs | 12 +- .../tokio-mqtt-connector-demo/src/main.rs | 10 +- .../weather-mesh-demo/weather-hub/src/main.rs | 23 +- .../weather-station-alpha/src/main.rs | 4 +- .../weather-station-beta/src/main.rs | 14 +- .../weather-station-gamma/src/main.rs | 4 +- 29 files changed, 510 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02aa457..2f9f1da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,15 +34,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `.with_deserializer_raw(|bytes| ...)` for plain bytes-only deserialization when context is unnecessary - `DeserializerKind` enum enforces mutual exclusivity between raw and context-aware deserializers - `Router::route()` propagates optional runtime context to context-aware routes +- **Context-Aware Serializers**: Outbound connector serializers can now receive a `RuntimeContext`, symmetric with deserializers + - New `.with_serializer(|ctx, value| ...)` API on `OutboundConnectorBuilder` provides `RuntimeContext` to serialization closures + - New `.with_serializer_raw(|value| ...)` for plain value-only serialization when context is unnecessary + - `SerializerKind` enum (`Raw` / `Context`) enforces mutual exclusivity + - All outbound connector publishers updated to propagate runtime context via `db.runtime_any()` - Design document: 026 (Context-Aware Deserializers) ### Changed - **aimdb-core**: Breaking API changes to `InboundConnectorLink`, `Router`, and `RouterBuilder` to support `DeserializerKind` (see [aimdb-core/CHANGELOG.md](aimdb-core/CHANGELOG.md)) -- **aimdb-mqtt-connector**: Updated router dispatch for new `route()` signature -- **aimdb-knx-connector**: Updated router dispatch for new `route()` signature -- **aimdb-websocket-connector**: Updated router dispatch for new `route()` signature -- All connector examples updated to use new `.with_deserializer(|_ctx, bytes| ...)` signature +- **aimdb-core**: Breaking API change — `ConnectorLink.serializer` now stores `SerializerKind` instead of `SerializerFn` +- **aimdb-core**: `.with_serializer()` renamed to `.with_serializer_raw()` for the old single-argument pattern +- **aimdb-mqtt-connector**: Updated router dispatch for new `route()` signature; outbound publishers dispatch via `SerializerKind` +- **aimdb-knx-connector**: Updated router dispatch for new `route()` signature; outbound publishers dispatch via `SerializerKind` +- **aimdb-websocket-connector**: Updated router dispatch for new `route()` signature; outbound publishers dispatch via `SerializerKind` +- All connector examples updated to use new `.with_deserializer(|_ctx, bytes| ...)` and `.with_serializer_raw(|value| ...)` signatures ## [1.0.0] - 2026-03-16 diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index 2789a8cc..cf021d0a 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -905,7 +905,7 @@ fn emit_connector_chain( chain = quote! { #chain .link_to(#addr_var) - .with_serializer(|v: &#value_type| { + .with_serializer_raw(|v: &#value_type| { v.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) @@ -1515,7 +1515,7 @@ fn emit_transform_configure_block(rec: &RecordDef, task: &TaskDef) -> TokenStrea let outbound_chain = if has_outbound { quote! { .link_to(addr) - .with_serializer(|v: &#value_type| { + .with_serializer_raw(|v: &#value_type| { v.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index a62fd9fc..e8fe89f6 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `.with_deserializer_raw(|bytes| ...)` added for plain bytes-only deserialization (no context needed) - `Router::route()` now accepts an optional type-erased runtime context (`Option<&Arc>`) - Context deserializer routes are gracefully skipped when no context is provided +- **Context-Aware Serializers**: Outbound connector serializers can now receive a `RuntimeContext`, symmetric with deserializers + - New `ContextSerializerFn` type alias for context-aware type-erased serializer callbacks + - New `SerializerKind` enum (`Raw` / `Context`) to enforce mutual exclusivity between plain and context-aware serializers + - `.with_serializer(|ctx, value| ...)` now accepts a context-aware closure receiving `RuntimeContext` + - `.with_serializer_raw(|value| ...)` added for plain value-only serialization (no context needed) ### Changed @@ -23,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking**: `InboundConnectorLink::new()` now takes `DeserializerKind` instead of `DeserializerFn` - **Breaking**: `Router::route()` signature changed to accept an additional `ctx` parameter - **Breaking**: `RouterBuilder::from_routes()` and `RouterBuilder::add_route()` now take `DeserializerKind` instead of `DeserializerFn` +- **Breaking**: `ConnectorLink::serializer` field type changed from `Option` to `Option` +- **Breaking**: `.with_serializer()` renamed to `.with_serializer_raw()` — old single-argument pattern +- **Breaking**: `OutboundRoute` type alias updated to use `SerializerKind` - **Breaking**: `.with_deserializer()` on `InboundConnectorBuilder` now expects `Fn(RuntimeContext, &[u8]) -> Result` instead of `Fn(&[u8]) -> Result` — use `.with_deserializer_raw()` for the previous bytes-only signature - `AimDb::collect_inbound_routes()` return type updated to use `DeserializerKind` diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 841ccf87..2997a9cc 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -55,14 +55,14 @@ use crate::{DbError, DbResult}; /// Each tuple contains: /// - `String` - Default topic/destination from the URL path /// - `Box` - Consumer for subscribing to record values -/// - `SerializerFn` - User-provided serializer for the record type +/// - `SerializerKind` - User-provided serializer for the record type (raw or context-aware) /// - `Vec<(String, String)>` - Configuration options from the URL query /// - `Option` - Optional dynamic topic provider #[cfg(feature = "alloc")] pub type OutboundRoute = ( String, Box, - crate::connector::SerializerFn, + crate::connector::SerializerKind, Vec<(String, String)>, Option, ); diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index b0d23e8a..9cc93feb 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -105,6 +105,34 @@ impl std::error::Error for SerializeError {} pub type SerializerFn = Arc Result, SerializeError> + Send + Sync>; +/// Type alias for context-aware type-erased serializer callbacks +/// +/// Like `SerializerFn`, but receives a type-erased runtime context +/// for platform-independent timestamps and logging during serialization. +/// +/// The first argument is the type-erased runtime (as `Arc`), +/// which is downcast to the concrete runtime type via `RuntimeContext::extract_from_any`. +pub type ContextSerializerFn = Arc< + dyn Fn( + Arc, + &dyn core::any::Any, + ) -> Result, SerializeError> + + Send + + Sync, +>; + +/// Which serializer variant is registered for an outbound link +/// +/// Enforces mutual exclusivity between raw value-only serializers +/// and context-aware serializers. +#[derive(Clone)] +pub enum SerializerKind { + /// Plain value-only serializer (from `.with_serializer_raw()`) + Raw(SerializerFn), + /// Context-aware serializer (from `.with_serializer()`) + Context(ContextSerializerFn), +} + // ============================================================================ // TopicProvider - Dynamic topic/destination routing // ============================================================================ @@ -434,13 +462,14 @@ pub struct ConnectorLink { /// Serialization callback that converts record values to bytes for publishing /// - /// This is a type-erased function that takes `&dyn Any` and returns `Result, String>`. - /// The connector implementation will downcast to the concrete type and call the serializer. + /// Either a plain value-only serializer (`Raw`) or a context-aware + /// serializer (`Context`) that receives `RuntimeContext` for timestamps + /// and logging. /// /// If `None`, the connector must provide a default serialization mechanism or fail. /// /// Available in both `std` and `no_std` (with `alloc` feature) environments. - pub serializer: Option, + pub serializer: Option, /// Consumer factory callback (alloc feature) /// @@ -471,7 +500,10 @@ impl Debug for ConnectorLink { .field("config", &self.config) .field( "serializer", - &self.serializer.as_ref().map(|_| ""), + &self.serializer.as_ref().map(|s| match s { + SerializerKind::Raw(_) => "", + SerializerKind::Context(_) => "", + }), ) .field( "consumer_factory", diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 99b24d28..bab120cf 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -485,6 +485,7 @@ where url: url.to_string(), config: Vec::new(), serializer: None, + context_serializer: None, topic_provider: None, } } @@ -509,6 +510,7 @@ where url: url.to_string(), config: Vec::new(), serializer: None, + context_serializer: None, topic_provider: None, } } @@ -553,6 +555,7 @@ pub struct OutboundConnectorBuilder< url: String, config: Vec<(String, String)>, serializer: Option>, + context_serializer: Option, topic_provider: Option, } @@ -567,12 +570,53 @@ where self } - /// Sets a serialization callback - pub fn with_serializer(mut self, f: F) -> Self + /// Sets a raw serialization callback (value only, no context) + /// + /// Prefer `.with_serializer(|ctx, value| ...)` for access to + /// `RuntimeContext` (timestamps, logging). Use this raw variant + /// only when context is unnecessary. + pub fn with_serializer_raw(mut self, f: F) -> Self where F: Fn(&T) -> Result, crate::connector::SerializeError> + Send + Sync + 'static, { self.serializer = Some(Arc::new(f)); + self.context_serializer = None; // mutually exclusive + self + } + + /// Sets a context-aware serialization callback + /// + /// The closure receives a `RuntimeContext` for platform-independent + /// timestamps and logging, plus the typed value being serialized. + /// + /// # Example + /// + /// ```rust,ignore + /// .link_to("mqtt://broker/sensors/temp") + /// .with_serializer(|ctx, value: &Temperature| { + /// ctx.log().debug("Serializing temperature for MQTT"); + /// value.to_bytes() + /// .map_err(|_| SerializeError::InvalidData) + /// }) + /// ``` + pub fn with_serializer(mut self, f: F) -> Self + where + F: Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> + + Send + + Sync + + 'static, + R: aimdb_executor::Runtime + Send + Sync, + { + let f = Arc::new(f); + self.context_serializer = Some(Arc::new(move |ctx_any, value_any| { + let ctx = crate::RuntimeContext::::extract_from_any(ctx_any); + if let Some(value) = value_any.downcast_ref::() { + (f)(ctx, value) + } else { + Err(crate::connector::SerializeError::TypeMismatch) + } + })); + self.serializer = None; // mutually exclusive self } @@ -648,17 +692,28 @@ where let mut link = ConnectorLink::new(url.clone()); link.config = self.config.clone(); - if let Some(typed_callback) = self.serializer.clone() { + // Resolve serializer variant (mutually exclusive) + let ser_kind = if let Some(ctx_ser) = self.context_serializer { + crate::connector::SerializerKind::Context(ctx_ser) + } else if let Some(raw_ser) = self.serializer.clone() { + // Type-erase the raw serializer let erased: crate::connector::SerializerFn = Arc::new(move |any: &dyn core::any::Any| { if let Some(value) = any.downcast_ref::() { - (typed_callback)(value) + (raw_ser)(value) } else { Err(crate::connector::SerializeError::TypeMismatch) } }); - link.serializer = Some(erased); - } + crate::connector::SerializerKind::Raw(erased) + } else { + panic!( + "Outbound connector requires a serializer. Call .with_serializer() or .with_serializer_raw() for {}", + self.url + ); + }; + + link.serializer = Some(ser_kind); // Wire through the topic provider link.topic_provider = self.topic_provider; @@ -677,14 +732,6 @@ where ); } - // Validation: Serializer must be provided - if link.serializer.is_none() { - panic!( - "Outbound connector requires a serializer. Call .with_serializer() for {}", - url_string - ); - } - // Store consumer factory that captures type T and record key // This allows the connector to subscribe to values without knowing T at compile time { @@ -1235,4 +1282,158 @@ mod tests { // No deserializer set — should panic reg.link_from("mqtt://broker/topic").finish(); } + + // ==================================================================== + // Serializer-kind selection tests + // ==================================================================== + + #[test] + fn outbound_finish_stores_raw_serializer_kind() { + use crate::connector::SerializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_to("mqtt://broker/topic") + .with_serializer_raw(|record: &TestRecord| Ok(record.value.to_le_bytes().to_vec())) + .finish(); + + assert_eq!(rec.outbound_connectors().len(), 1); + let link = &rec.outbound_connectors()[0]; + + // Variant must be Raw + let ser = link.serializer.as_ref().expect("serializer should be set"); + assert!( + matches!(ser, SerializerKind::Raw(_)), + "expected SerializerKind::Raw, got Context" + ); + + // Verify the type-erased serializer round-trips correctly + if let SerializerKind::Raw(ref f) = ser { + let val = TestRecord { value: 42 }; + let result = f(&val as &dyn core::any::Any).expect("serializer should succeed"); + assert_eq!(result, 42i32.to_le_bytes().to_vec()); + } + } + + #[test] + fn outbound_finish_stores_context_serializer_kind() { + use crate::connector::SerializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_to("mqtt://broker/topic") + .with_serializer( + |_ctx: crate::RuntimeContext, record: &TestRecord| { + Ok(record.value.to_le_bytes().to_vec()) + }, + ) + .finish(); + + assert_eq!(rec.outbound_connectors().len(), 1); + let ser = rec.outbound_connectors()[0] + .serializer + .as_ref() + .expect("serializer should be set"); + + assert!( + matches!(ser, SerializerKind::Context(_)), + "expected SerializerKind::Context, got Raw" + ); + } + + #[test] + fn outbound_raw_overrides_previous_context_serializer() { + use crate::connector::SerializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // Set context first, then override with raw — raw should win + reg.link_to("mqtt://broker/topic") + .with_serializer( + |_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![0]), + ) + .with_serializer_raw(|record: &TestRecord| Ok(record.value.to_le_bytes().to_vec())) + .finish(); + + let ser = rec.outbound_connectors()[0] + .serializer + .as_ref() + .expect("serializer should be set"); + assert!(matches!(ser, SerializerKind::Raw(_))); + } + + #[test] + fn outbound_context_overrides_previous_raw_serializer() { + use crate::connector::SerializerKind; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // Set raw first, then override with context — context should win + reg.link_to("mqtt://broker/topic") + .with_serializer_raw(|_record: &TestRecord| Ok(vec![0])) + .with_serializer( + |_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![99]), + ) + .finish(); + + let ser = rec.outbound_connectors()[0] + .serializer + .as_ref() + .expect("serializer should be set"); + assert!(matches!(ser, SerializerKind::Context(_))); + } + + #[test] + #[should_panic(expected = "Outbound connector requires a serializer")] + fn outbound_finish_panics_without_serializer() { + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + let builders: Vec>> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + // No serializer set — should panic + reg.link_to("mqtt://broker/topic").finish(); + } } diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 96d91211..9f000249 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated `Router::route()` calls to pass runtime context via `db.runtime_any()` in both Tokio and Embassy clients, enabling context-aware deserializers (Design 026) +- Updated outbound publishers (Tokio and Embassy) to dispatch via `SerializerKind`, enabling context-aware serializers with `db.runtime_any()` ## [0.3.1] - 2026-03-16 diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 5269de9d..6396eedd 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -996,12 +996,14 @@ impl KnxConnectorImpl { R: aimdb_executor::Spawn + 'static, { let runtime = db.runtime(); + let runtime_ctx: Arc = db.runtime_any(); for (default_group_addr_str, consumer, serializer, _config, topic_provider) in outbound_routes { let command_channel = self.command_channel; let default_group_addr_clone = default_group_addr_str.clone(); + let runtime_ctx = runtime_ctx.clone(); runtime.spawn(Box::pin(SendFutureWrapper(async move { // Parse default group address using knx-pico's type-safe parser @@ -1067,15 +1069,30 @@ impl KnxConnectorImpl { }; // Serialize the type-erased value - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "defmt")] - defmt::error!( - "Failed to serialize for group address '{}'", - group_addr_str.as_str() - ); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::error!( + "Failed to serialize for group address '{}'", + group_addr_str.as_str() + ); + continue; + } + }, + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::error!( + "Failed to serialize for group address '{}'", + group_addr_str.as_str() + ); + continue; + } + } } }; diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index 9f06747c..2648cac4 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -58,7 +58,7 @@ //! .finish() //! // Outbound: Send commands to KNX //! .link_to("knx://1/0/8") -//! .with_serializer(|state: &LightState| { +//! .with_serializer_raw(|state: &LightState| { //! Ok(vec![if state.is_on { 1 } else { 0 }]) //! }) //! .finish(); @@ -88,7 +88,7 @@ //! .finish() //! // Outbound: Send to KNX //! .link_to("knx://1/0/11") -//! .with_serializer(|data| data.to_knx_bytes()) +//! .with_serializer_raw(|data| data.to_knx_bytes()) //! .finish(); //! }) //! .build().await?; diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index cddaca62..b8e3588a 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -243,10 +243,12 @@ impl KnxConnectorImpl { R: aimdb_executor::Spawn + 'static, { let runtime = db.runtime(); + let runtime_ctx: Arc = db.runtime_any(); for (default_group_addr_str, consumer, serializer, _config, topic_provider) in routes { let command_tx = self.command_tx.clone(); let default_group_addr_clone = default_group_addr_str.clone(); + let runtime_ctx = runtime_ctx.clone(); runtime.spawn(async move { // Parse default group address using knx-pico's type-safe parser @@ -312,16 +314,32 @@ impl KnxConnectorImpl { }; // Serialize the type-erased value - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for group address '{}': {:?}", - group_addr_str, - _e - ); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "Failed to serialize for group address '{}': {:?}", + group_addr_str, + _e + ); + continue; + } + }, + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "Failed to serialize for group address '{}': {:?}", + group_addr_str, + _e + ); + continue; + } + } } }; diff --git a/aimdb-knx-connector/tests/topic_provider_tests.rs b/aimdb-knx-connector/tests/topic_provider_tests.rs index f32e1940..9f1d878d 100644 --- a/aimdb-knx-connector/tests/topic_provider_tests.rs +++ b/aimdb-knx-connector/tests/topic_provider_tests.rs @@ -333,7 +333,7 @@ async fn test_knx_topic_provider_with_connector_registration() { ) .link_to("knx://1/0/0") // Fallback group address .with_topic_provider(RoomBasedGroupAddressProvider::new(1, 0)) - .with_serializer(|dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) + .with_serializer_raw(|dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) .finish(); }); @@ -393,7 +393,7 @@ async fn test_hvac_zone_routing() { ) .link_to("knx://5/0/0") // Fallback for invalid zones .with_topic_provider(HvacZoneProvider) - .with_serializer(|sp: &TemperatureSetpoint| Ok(sp.to_knx_bytes())) + .with_serializer_raw(|sp: &TemperatureSetpoint| Ok(sp.to_knx_bytes())) .finish(); }); diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index dbbc1dc6..065caf9d 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated `Router::route()` calls to pass runtime context via `db.runtime_any()`, enabling context-aware deserializers (Design 026) +- Updated outbound publishers (Tokio and Embassy) to dispatch via `SerializerKind`, enabling context-aware serializers with `db.runtime_any()` ## [0.5.1] - 2026-03-16 diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index e19f7f83..0533b118 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -565,10 +565,12 @@ impl MqttConnectorImpl { R: aimdb_executor::Spawn + 'static, { let runtime = db.runtime(); + let runtime_ctx: Arc = db.runtime_any(); for (default_topic, consumer, serializer, config, topic_provider) in routes { let action_sender = self.action_sender; let default_topic_clone = default_topic.clone(); + let runtime_ctx = runtime_ctx.clone(); // Parse config options let mut qos = QualityOfService::Qos1; // Default @@ -618,12 +620,27 @@ impl MqttConnectorImpl { .unwrap_or_else(|| default_topic_clone.clone()); // Serialize the type-erased value - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "defmt")] - defmt::error!("Failed to serialize for topic '{}'", topic.as_str()); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::error!("Failed to serialize for topic '{}'", topic.as_str()); + continue; + } + }, + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::error!( + "Failed to serialize for topic '{}'", + topic.as_str() + ); + continue; + } + } } }; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index ead1aee7..f5009cda 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -317,10 +317,12 @@ impl MqttConnectorImpl { R: aimdb_executor::Spawn + 'static, { let runtime = db.runtime(); + let runtime_ctx: Arc = db.runtime_any(); for (default_topic, consumer, serializer, config, topic_provider) in routes { let client = self.client.clone(); let default_topic_clone = default_topic.clone(); + let runtime_ctx = runtime_ctx.clone(); // Parse config options let mut qos = rumqttc::QoS::AtLeastOnce; // Default @@ -376,12 +378,32 @@ impl MqttConnectorImpl { .unwrap_or_else(|| default_topic_clone.clone()); // Serialize the type-erased value - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to serialize for topic '{}': {:?}", topic, _e); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "Failed to serialize for topic '{}': {:?}", + topic, + _e + ); + continue; + } + }, + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "Failed to serialize for topic '{}': {:?}", + topic, + _e + ); + continue; + } + } } }; diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index 0f6c3868..53777587 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -276,7 +276,7 @@ async fn test_topic_provider_with_connector_registration() { ) .link_to("mqtt://sensors/temp/default") // Fallback topic .with_topic_provider(SensorIdTopicProvider) // Dynamic routing! - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -338,7 +338,7 @@ async fn test_mixed_static_and_dynamic_topics() { }, ) .link_to("mqtt://sensors/temp/static-topic") - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -356,7 +356,7 @@ async fn test_mixed_static_and_dynamic_topics() { ) .link_to("mqtt://sensors/temp/fallback") .with_topic_provider(SensorIdTopicProvider) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); diff --git a/aimdb-tokio-adapter/src/connector.rs b/aimdb-tokio-adapter/src/connector.rs index 09643502..fc5ecbe9 100644 --- a/aimdb-tokio-adapter/src/connector.rs +++ b/aimdb-tokio-adapter/src/connector.rs @@ -192,7 +192,7 @@ mod tests { reg.source(|_ctx, _msg| async {}) .tap(|_ctx, _consumer| async {}) .link_to("mqtt://broker.example.com:1883") - .with_serializer(|_msg: &TestMessage| { + .with_serializer_raw(|_msg: &TestMessage| { // Dummy serializer for testing Ok(vec![1, 2, 3]) }) diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 4d6ad99a..95c45636 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated `Router::route()` calls to pass runtime context via `db.runtime_any()` in both client connector and session handler, enabling context-aware deserializers (Design 026) +- Updated outbound publishers (server and client) to dispatch via `SerializerKind`, enabling context-aware serializers with `db.runtime_any()` ## [0.1.0] - 2026-03-16 diff --git a/aimdb-websocket-connector/src/client/connector.rs b/aimdb-websocket-connector/src/client/connector.rs index c753df6e..a6b90cce 100644 --- a/aimdb-websocket-connector/src/client/connector.rs +++ b/aimdb-websocket-connector/src/client/connector.rs @@ -207,10 +207,12 @@ impl WsClientConnectorImpl { R: aimdb_executor::Spawn + 'static, { let runtime = db.runtime(); + let runtime_ctx: Arc = db.runtime_any(); for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { let state = self.state.clone(); let default_topic_clone = default_topic.clone(); + let runtime_ctx = runtime_ctx.clone(); runtime .spawn(async move { @@ -241,16 +243,34 @@ impl WsClientConnectorImpl { .unwrap_or_else(|| default_topic_clone.clone()); // Serialize - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => { + match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "WS client outbound: serialize error for '{}': {:?}", + topic, + _e + ); + continue; + } + } + } + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "WS client outbound: serialize error for '{}': {:?}", + topic, + _e + ); + continue; + } + } } }; diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/connector.rs index 5b0cebe8..1a797be2 100644 --- a/aimdb-websocket-connector/src/connector.rs +++ b/aimdb-websocket-connector/src/connector.rs @@ -61,11 +61,13 @@ impl WebSocketConnectorImpl { { let runtime = db.runtime(); let raw_payload = self.raw_payload; + let runtime_ctx: Arc = db.runtime_any(); for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { let client_mgr = self.client_mgr.clone(); let snap = snapshot_map.clone(); let default_topic_clone = default_topic.clone(); + let runtime_ctx = runtime_ctx.clone(); runtime.spawn(async move { let mut reader = match consumer.subscribe_any().await { @@ -95,16 +97,32 @@ impl WebSocketConnectorImpl { .unwrap_or_else(|| default_topic_clone.clone()); // Serialize - let bytes = match serializer(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; + let bytes = match &serializer { + aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "WS outbound: serialize error for '{}': {:?}", + topic, + _e + ); + continue; + } + }, + aimdb_core::connector::SerializerKind::Context(ser) => { + match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "WS outbound: serialize error for '{}': {:?}", + topic, + _e + ); + continue; + } + } } }; diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index f053f846..0a195fb7 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -32,7 +32,7 @@ //! .configure::(TempKey::Vienna, |reg| { //! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) //! .link_to("ws://sensors/temperature/vienna") -//! .with_serializer(|t| serde_json::to_vec(t).map_err(Into::into)) +//! .with_serializer_raw(|t| serde_json::to_vec(t).map_err(Into::into)) //! .finish(); //! }) //! .build().await?; @@ -51,7 +51,7 @@ //! .configure::("sensors/temp", |reg| { //! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) //! .link_to("ws-client://sensors/temp") -//! .with_serializer(|t| serde_json::to_vec(t).map_err(Into::into)) +//! .with_serializer_raw(|t| serde_json::to_vec(t).map_err(Into::into)) //! .finish() //! .link_from("ws-client://config/threshold") //! .with_deserializer(|data| serde_json::from_slice(data)) diff --git a/docs/design/026-context-aware-deserializers.md b/docs/design/026-context-aware-deserializers.md index ff1f5d2b..4d93f77a 100644 --- a/docs/design/026-context-aware-deserializers.md +++ b/docs/design/026-context-aware-deserializers.md @@ -62,7 +62,7 @@ These workarounds violate AimDB's principle of clean, declarative data pipelines - Backward compatibility for `.with_deserializer()` call sites (this is an intentional breaking change; existing callers migrate to `|ctx, data|` or rename to `.with_deserializer_raw()`) - Providing mutable database access from within deserializers - Async deserializers (deserialization should remain synchronous) -- Providing context to serializers (outbound direction — separate concern) +- ~~Providing context to serializers (outbound direction — separate concern)~~ — **Implemented**: Context-aware serializers were added in the same PR, following the identical pattern (`ContextSerializerFn`, `SerializerKind`, `.with_serializer(|ctx, value| ...)` / `.with_serializer_raw(|value| ...)`) ## Current Architecture diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 36a352d6..7badbfb3 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -268,7 +268,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Living Room", celsius)) }) @@ -279,7 +279,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Bedroom", celsius)) }) @@ -290,7 +290,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Kitchen", celsius)) }) @@ -306,7 +306,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/7", is_on)) }) @@ -317,7 +317,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/8", is_on)) }) @@ -333,7 +333,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) .source_with_context(button, button_handler) .link_to(LightControlKey::Control.link_address().unwrap()) - .with_serializer(|state: &LightControl| { + .with_serializer_raw(|state: &LightControl| { let mut buf = [0u8; 1]; let len = Dpt1::Switch.encode(state.is_on, &mut buf).unwrap_or(0); Ok(buf[..len].to_vec()) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index cfde2189..cd4d23f0 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -334,7 +334,7 @@ async fn main(spawner: Spawner) { .source(indoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempIndoor.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -343,7 +343,7 @@ async fn main(spawner: Spawner) { .source(outdoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempOutdoor.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -352,7 +352,7 @@ async fn main(spawner: Spawner) { .source(server_room_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempServerRoom.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -365,7 +365,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) .tap(command_consumer) .link_from(CommandKey::TempIndoor.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); @@ -373,7 +373,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) .tap(command_consumer) .link_from(CommandKey::TempOutdoor.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index 66681d61..a9c3b8a1 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -104,7 +104,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Living Room", celsius)) }) @@ -115,7 +115,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Bedroom", celsius)) }) @@ -126,7 +126,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let celsius = Dpt9::Temperature.decode(data).unwrap_or(0.0); Ok(TemperatureReading::new("Kitchen", celsius)) }) @@ -138,7 +138,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/7", is_on)) }) @@ -149,7 +149,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| { + .with_deserializer_raw(|data: &[u8]| { let is_on = Dpt1::Switch.decode(data).unwrap_or(false); Ok(LightState::new("1/0/8", is_on)) }) @@ -161,7 +161,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SingleLatest) .source(input_handler) .link_to(LightControlKey::Control.link_address().unwrap()) - .with_serializer(|state: &LightControl| { + .with_serializer_raw(|state: &LightControl| { let mut buf = [0u8; 1]; let len = Dpt1::Switch.encode(state.is_on, &mut buf).unwrap_or(0); Ok(buf[..len].to_vec()) diff --git a/examples/tokio-mqtt-connector-demo/src/main.rs b/examples/tokio-mqtt-connector-demo/src/main.rs index 19dd561c..12fbd184 100644 --- a/examples/tokio-mqtt-connector-demo/src/main.rs +++ b/examples/tokio-mqtt-connector-demo/src/main.rs @@ -158,7 +158,7 @@ async fn main() -> DbResult<()> { .source(indoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempIndoor.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -167,7 +167,7 @@ async fn main() -> DbResult<()> { .source(outdoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempOutdoor.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -176,7 +176,7 @@ async fn main() -> DbResult<()> { .source(server_room_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempServerRoom.link_address().unwrap()) - .with_serializer(|temp: &Temperature| Ok(temp.to_json_vec())) + .with_serializer_raw(|temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -185,7 +185,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .tap(command_consumer) .link_from(CommandKey::TempIndoor.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); @@ -193,7 +193,7 @@ async fn main() -> DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .tap(command_consumer) .link_from(CommandKey::TempOutdoor.link_address().unwrap()) - .with_deserializer(|_ctx, data: &[u8]| TemperatureCommand::from_json(data)) + .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) .finish(); }); diff --git a/examples/weather-mesh-demo/weather-hub/src/main.rs b/examples/weather-mesh-demo/weather-hub/src/main.rs index 04287c53..20c3b397 100644 --- a/examples/weather-mesh-demo/weather-hub/src/main.rs +++ b/examples/weather-mesh-demo/weather-hub/src/main.rs @@ -35,7 +35,17 @@ async fn main() -> aimdb_core::DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) .tap(move |ctx, consumer| log_tap(ctx, consumer, key.as_str())) .link_from(&topic) - .with_deserializer(|_ctx, data: &[u8]| Temperature::from_bytes(data)) + .with_deserializer(|ctx, data: &[u8]| { + ctx.log() + .debug("Deserializing temperature from MQTT payload"); + let temp = Temperature::from_bytes(data)?; + ctx.log().info(&format!( + "🌡️ Received: {:.1}°C (deserialized at runtime tick {:?})", + temp.celsius, + ctx.time().now() + )); + Ok(temp) + }) .finish(); }); } @@ -48,7 +58,16 @@ async fn main() -> aimdb_core::DbResult<()> { reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) .tap(move |ctx, consumer| log_tap(ctx, consumer, key.as_str())) .link_from(&topic) - .with_deserializer(|_ctx, data: &[u8]| Humidity::from_bytes(data)) + .with_deserializer(|ctx, data: &[u8]| { + ctx.log().debug("Deserializing humidity from MQTT payload"); + let humidity = Humidity::from_bytes(data)?; + ctx.log().info(&format!( + "💧 Received: {:.1}% humidity (deserialized at runtime tick {:?})", + humidity.percent, + ctx.time().now() + )); + Ok(humidity) + }) .finish(); }); } diff --git a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs index 67d27bb8..2251ac50 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs @@ -54,7 +54,7 @@ async fn main() -> Result<(), Box> { builder.configure::(TempKey::Alpha, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .link_to(temp_topic) - .with_serializer(|t: &Temperature| { + .with_serializer_raw(|t: &Temperature| { t.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) @@ -66,7 +66,7 @@ async fn main() -> Result<(), Box> { builder.configure::(HumidityKey::Alpha, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .link_to(humidity_topic) - .with_serializer(|h: &Humidity| { + .with_serializer_raw(|h: &Humidity| { h.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) diff --git a/examples/weather-mesh-demo/weather-station-beta/src/main.rs b/examples/weather-mesh-demo/weather-station-beta/src/main.rs index f0ee10b9..3fc307bd 100644 --- a/examples/weather-mesh-demo/weather-station-beta/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-beta/src/main.rs @@ -44,7 +44,12 @@ async fn main() -> Result<(), Box> { builder.configure::(TempKey::Beta, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .link_to(temp_topic) - .with_serializer(|t: &Temperature| { + .with_serializer(|ctx, t: &Temperature| { + ctx.log().info(&format!( + "Serializing temp {:.1}°C @ tick {:?}", + t.celsius, + ctx.time().now() + )); t.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) @@ -56,7 +61,12 @@ async fn main() -> Result<(), Box> { builder.configure::(HumidityKey::Beta, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) .link_to(humidity_topic) - .with_serializer(|h: &Humidity| { + .with_serializer(|ctx, h: &Humidity| { + ctx.log().info(&format!( + "Serializing humidity {:.1}% @ tick {:?}", + h.percent, + ctx.time().now() + )); h.to_bytes() .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) }) diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index 4f769041..23e603c6 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -272,7 +272,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<16, 1>(EmbassyBufferType::SpmcRing) .source(temperature_producer) .link_to(temp_topic) - .with_serializer(|t: &Temperature| { + .with_serializer_raw(|t: &Temperature| { // Manual JSON serialization for no_std let whole = t.celsius as i32; let frac = ((t.celsius - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; @@ -294,7 +294,7 @@ async fn main(spawner: Spawner) { reg.buffer_sized::<16, 1>(EmbassyBufferType::SpmcRing) .source(humidity_producer) .link_to(humidity_topic) - .with_serializer(|h: &Humidity| { + .with_serializer_raw(|h: &Humidity| { // Manual JSON serialization for no_std let whole = h.percent as i32; let frac = ((h.percent - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; From 192d4178bfe306609f3925d99d8b1f169aeafc63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 10 Apr 2026 13:56:26 +0000 Subject: [PATCH 11/11] feat: update deserializer and serializer methods to use raw variants across connectors --- aimdb-core/src/router.rs | 4 ++-- aimdb-core/src/typed_api.rs | 4 ++-- aimdb-knx-connector/src/lib.rs | 4 ++-- aimdb-mqtt-connector/src/lib.rs | 8 ++++---- aimdb-websocket-connector/src/lib.rs | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 1bcaf106..0a69ae54 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -87,8 +87,8 @@ impl Router { /// * `ctx` - Optional type-erased runtime context for context-aware deserializers /// /// # Returns - /// * `Ok(())` - At least one route successfully processed the message - /// * `Err(_)` - All routes failed (or no routes found) + /// * `Ok(())` - Always returns Ok, even if no routes matched or processing failed. + /// Failures are logged (via tracing/defmt) but do not propagate as errors. /// /// # Behavior /// - Checks all routes that match the resource_id (may be multiple) diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index bab120cf..c70cf536 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -45,7 +45,7 @@ //! .source(|producer, ctx| temperature_service(ctx, producer)) //! .tap(|consumer| temperature_logger(consumer)) //! .link_to("mqtt://sensors/temp") -//! .with_serializer(|t| serde_json::to_vec(t)) +//! .with_serializer_raw(|t| serde_json::to_vec(t)) //! .finish(); //! }); //! ``` @@ -500,7 +500,7 @@ where /// builder.configure::(|reg| { /// reg.buffer(BufferCfg::SingleLatest) /// .link_to("mqtt://broker/sensors/temp") - /// .with_serializer(|t| serde_json::to_vec(t).unwrap()) + /// .with_serializer_raw(|t| serde_json::to_vec(t).unwrap()) /// .finish() /// }); /// ``` diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index 2648cac4..0b045109 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -51,7 +51,7 @@ //! reg.buffer(BufferCfg::SingleLatest) //! // Inbound: Monitor KNX bus //! .link_from("knx://1/0/7") -//! .with_deserializer(|data: &[u8]| { +//! .with_deserializer_raw(|data: &[u8]| { //! let is_on = data.get(0).map(|&b| b != 0).unwrap_or(false); //! Ok(Box::new(LightState { is_on })) //! }) @@ -84,7 +84,7 @@ //! .source(sensor_producer) //! // Inbound: Monitor KNX bus //! .link_from("knx://1/0/10") -//! .with_deserializer(|data| SensorData::from_knx(data)) +//! .with_deserializer_raw(|data| SensorData::from_knx(data)) //! .finish() //! // Outbound: Send to KNX //! .link_to("knx://1/0/11") diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 7d4a62dc..d8ac4f96 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -28,14 +28,14 @@ //! reg.source(temperature_producer) //! // Outbound: Publish to MQTT //! .link_to("mqtt://sensors/temperature") -//! .with_serializer(|t| { +//! .with_serializer_raw(|t| { //! serde_json::to_vec(t) //! .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) //! }) //! .finish() //! // Inbound: Subscribe from MQTT //! .link_from("mqtt://commands/temperature") -//! .with_deserializer(|data| Temperature::from_json(data)) +//! .with_deserializer_raw(|data| Temperature::from_json(data)) //! .finish(); //! }) //! .build().await?; @@ -59,11 +59,11 @@ //! .source(sensor_producer) //! // Outbound: Publish to MQTT //! .link_to("mqtt://sensors/data") -//! .with_serializer(|data| postcard::to_vec(data).map_err(|_| /* ... */)) +//! .with_serializer_raw(|data| postcard::to_vec(data).map_err(|_| /* ... */)) //! .finish() //! // Inbound: Subscribe from MQTT //! .link_from("mqtt://commands/sensor") -//! .with_deserializer(|data| SensorCommand::from_bytes(data)) +//! .with_deserializer_raw(|data| SensorCommand::from_bytes(data)) //! .finish(); //! }) //! .build().await?; diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 0a195fb7..1561313e 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -54,7 +54,7 @@ //! .with_serializer_raw(|t| serde_json::to_vec(t).map_err(Into::into)) //! .finish() //! .link_from("ws-client://config/threshold") -//! .with_deserializer(|data| serde_json::from_slice(data)) +//! .with_deserializer_raw(|data| serde_json::from_slice(data)) //! .finish(); //! }) //! .build().await?;