From 4088960e3f7f4380b871706a79d1f0226671596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:07:19 +0000 Subject: [PATCH 01/20] feat(mqtt): implement platform-agnostic MqttConnector with Native and Embedded backends --- aimdb-mqtt-connector/src/connector.rs | 139 ++++++++++++++++++ aimdb-mqtt-connector/src/lib.rs | 28 ++-- .../embassy-mqtt-connector-demo/src/main.rs | 4 +- .../weather-station-gamma/src/main.rs | 4 +- 4 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 aimdb-mqtt-connector/src/connector.rs diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs new file mode 100644 index 00000000..5f36c3bd --- /dev/null +++ b/aimdb-mqtt-connector/src/connector.rs @@ -0,0 +1,139 @@ +//! One `MqttConnector` over two protocol backends. +//! +//! Unlike the other connectors, MQTT does not converge on a single protocol +//! implementation. `rumqttc` owns its socket, TLS and reconnect — its +//! `Transport` is a closed enum, so no stream can be injected — while +//! `mountain-mqtt` is generic over `embedded-io-async`. The two stay separate, +//! and this type is the seam between them: a backend can be swapped or removed +//! without touching the other. +//! +//! | Backend | Client | QoS | TLS | +//! |---|---|---|---| +//! | [`Native`] | `rumqttc` (std) | 0–2 | rustls | +//! | [`Embedded`] | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | + +use alloc::boxed::Box; +use alloc::vec::Vec; +use core::future::Future; +use core::pin::Pin; + +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::{AimDb, DbResult}; + +/// The `rumqttc` backend: a host client owning its own socket and TLS. +#[cfg(feature = "tokio-runtime")] +pub struct Native(crate::tokio_client::MqttConnectorBuilder); + +/// The `mountain-mqtt` backend: `no_std`, over the device's network stack. +#[cfg(feature = "embassy-runtime")] +pub struct Embedded(crate::embassy_client::MqttConnectorBuilder); + +/// An MQTT connector over the backend `B`. +pub struct MqttConnector { + backend: B, +} + +#[cfg(feature = "tokio-runtime")] +impl MqttConnector { + /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). + /// + /// Without [`with_client_id`](Self::with_client_id) a random UUID-based + /// client id is generated at build. + pub fn new(broker_url: impl Into) -> Self { + Self { + backend: Native(crate::tokio_client::MqttConnectorBuilder::new(broker_url)), + } + } + + /// Set the MQTT client id. + pub fn with_client_id(self, client_id: impl Into) -> Self { + Self { + backend: Native(self.backend.0.with_client_id(client_id)), + } + } +} + +#[cfg(feature = "embassy-runtime")] +impl MqttConnector { + /// Connect to `broker_url` over the device's network stack. + /// + /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS + /// (default 8883) and needs the `embassy-tls` feature plus + /// [`with_tls`](Self::with_tls). + pub fn new( + broker_url: impl Into, + stack: &'static embassy_net::Stack<'static>, + ) -> Self { + Self { + backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new( + broker_url, stack, + )), + } + } + + /// Set the MQTT client id (defaults to `aimdb-client`). + pub fn with_client_id(self, client_id: impl Into) -> Self { + Self { + backend: Embedded(self.backend.0.with_client_id(client_id)), + } + } + + /// Set the broker username and password. + pub fn with_credentials( + self, + username: impl Into, + password: impl Into, + ) -> Self { + Self { + backend: Embedded(self.backend.0.with_credentials(username, password)), + } + } + + /// Provide the TLS materials for an `mqtts://` broker. + #[cfg(feature = "embassy-tls")] + pub fn with_tls(self, options: crate::embassy_tls::TlsOptions) -> Self { + Self { + backend: Embedded(self.backend.0.with_tls(options)), + } + } +} + +#[cfg(feature = "tokio-runtime")] +impl ConnectorBuilder for MqttConnector { + fn build<'a>( + &'a self, + db: &'a AimDb, + ) -> Pin< + Box< + dyn Future + Send>>>>> + + Send + + 'a, + >, + > { + self.backend.0.build(db) + } + + fn scheme(&self) -> &str { + self.backend.0.scheme() + } +} + +#[cfg(feature = "embassy-runtime")] +impl ConnectorBuilder for MqttConnector { + fn build<'a>( + &'a self, + db: &'a AimDb, + ) -> Pin< + Box< + dyn Future + Send>>>>> + + Send + + 'a, + >, + > { + self.backend.0.build(db) + } + + fn scheme(&self) -> &str { + self.backend.0.scheme() + } +} diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 6e8c064c..1e597063 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -95,6 +95,10 @@ extern crate alloc; // MQTT knobs over core's generic link builders (works on every feature leg) +// One `MqttConnector` over the `Native` and `Embedded` protocol backends. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub mod connector; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; @@ -116,21 +120,9 @@ pub mod embassy_tls; #[cfg(feature = "embassy-tls")] pub mod sntp; -// Re-export platform-specific types -// Both implementations use MqttConnectorBuilder for API consistency -// When both features are enabled (e.g., during testing), prefer tokio -#[cfg(all(feature = "tokio-runtime", not(feature = "embassy-runtime")))] -pub use tokio_client::MqttConnectorBuilder as MqttConnector; - -#[cfg(all(feature = "embassy-runtime", not(feature = "tokio-runtime")))] -pub use embassy_client::MqttConnectorBuilder as MqttConnector; - -// When both features are enabled, export both with different names -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use tokio_client::MqttConnectorBuilder as TokioMqttConnector; - -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use embassy_client::MqttConnectorBuilder as EmbassyMqttConnector; - -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use tokio_client::MqttConnectorBuilder as MqttConnector; // Default to tokio when both enabled +#[cfg(feature = "embassy-runtime")] +pub use connector::Embedded; +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub use connector::MqttConnector; +#[cfg(feature = "tokio-runtime")] +pub use connector::Native; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index abd996c8..4aa7e5cb 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -90,7 +90,7 @@ use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] use aimdb_mqtt_connector::embassy_client::TlsOptions; @@ -385,7 +385,7 @@ async fn main(spawner: Spawner) { // Read-only: each record has a single writer (a sensor source, or MQTT for the // command records), so remote `record.set` is refused — peers can // list/drain/subscribe, not write. - let mqtt = MqttConnectorBuilder::new(&broker_url, stack).with_client_id("embassy-demo-001"); + let mqtt = MqttConnector::new(&broker_url, stack).with_client_id("embassy-demo-001"); // TLS materials: the board's TRNG, the broker's root CA, and the record // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer 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 7202efd0..7baa0967 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -28,7 +28,7 @@ use aimdb_core::{AimDbBuilder, RecordKey}; #[cfg(feature = "sim")] use aimdb_data_contracts::{RandomWalkParams, SimProfile, SimulatableRegistrarExt}; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; use defmt::*; use embassy_executor::Spawner; use embassy_net::StackResources; @@ -251,7 +251,7 @@ async fn main(spawner: Spawner) { let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); let mut builder = AimDbBuilder::new().runtime(runtime.clone()).with_connector( - MqttConnectorBuilder::new(&broker_url, stack).with_client_id("weather-station-gamma"), + MqttConnector::new(&broker_url, stack).with_client_id("weather-station-gamma"), ); // Configure temperature record From ce28e5320f9cb1e4e2b96121ee6b1fb8611f48b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:21:41 +0000 Subject: [PATCH 02/20] feat(mqtt): implement embedded backend transport for MQTT connector --- aimdb-embassy-adapter/src/net.rs | 52 ++++++++++++++++++++ aimdb-mqtt-connector/Cargo.toml | 2 + aimdb-mqtt-connector/src/lib.rs | 4 ++ aimdb-mqtt-connector/src/transport.rs | 71 +++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 aimdb-mqtt-connector/src/transport.rs diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index cac48efe..a2e4d74d 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -208,6 +208,58 @@ impl ByteStream for EmbassyTcpStream { } } +// `embedded-io-async` by delegation, so a protocol client that consumes those +// traits (mountain-mqtt, embedded-tls) sees the type it expects. `ReadReady` is +// the one `ByteStream` cannot express, and the socket has it. +impl embedded_io_async::ErrorType for EmbassyTcpStream { + type Error = embedded_io_async::ErrorKind; +} + +impl embedded_io_async::Read for EmbassyTcpStream { + async fn read(&mut self, buf: &mut [u8]) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Read::read(socket, buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +impl embedded_io_async::Write for EmbassyTcpStream { + async fn write(&mut self, buf: &[u8]) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Write::write(socket, buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Write::flush(socket) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +impl embedded_io_async::ReadReady for EmbassyTcpStream { + fn read_ready(&mut self) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::ReadReady::read_ready(socket) + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + /// Dials TCP connections over one caller-owned socket. pub struct EmbassyTcpDialer { slot: Arc, diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 42189d9c..b1e5548c 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -46,6 +46,8 @@ embassy-runtime = [ "embassy-net", "mountain-mqtt", "mountain-mqtt-embassy", + # The `SocketTransport` bridge names these traits in its bounds. + "dep:embedded-io-async", "heapless", "static_cell", ] diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 1e597063..7e61adfc 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -99,6 +99,10 @@ extern crate alloc; #[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] pub mod connector; +// The broker transport seam for the `Embedded` backend. +#[cfg(feature = "embassy-runtime")] +pub mod transport; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs new file mode 100644 index 00000000..50d28b3a --- /dev/null +++ b/aimdb-mqtt-connector/src/transport.rs @@ -0,0 +1,71 @@ +//! The broker transport seam for the [`Embedded`](crate::connector::Embedded) +//! backend. +//! +//! Built on `mountain-mqtt`'s own [`Connection`] rather than core's +//! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs +//! `receive_if_ready` — a non-blocking peek — which a byte stream does not +//! express and a TLS session cannot provide (its readiness is two-layered; +//! see [`crate::embassy_tls`]). Wrapping core's trait would mean every +//! TLS-like transport faking a capability, so the client's own seam is the +//! honest one. +//! +//! A new runtime supplies MQTT by implementing this once. Anything offering +//! `embedded_io_async::{Read, Write}` plus `ReadReady` — an lwIP socket, say — +//! gets there through `mountain_mqtt::embedded_io_async::ConnectionEmbedded` +//! with no protocol code to touch. + +use aimdb_core::session::TransportResult; +use core::future::Future; +use mountain_mqtt::packet_client::Connection; + +/// Opens one broker connection per session. +/// +/// The connector calls this once per reconnect cycle, so an implementation +/// must be able to produce a fresh connection each time. +pub trait BrokerTransport { + /// The connection this transport produces. + type Connection: Connection; + + /// Open a connection to the broker. + fn connect(&self) -> impl Future> + Send; +} + +/// Bridges core's [`StreamDialer`](aimdb_core::session::StreamDialer) to +/// [`BrokerTransport`] for any adapter whose stream also offers the +/// `embedded-io-async` trio. +/// +/// This is the path a new runtime takes: implement `StreamDialer` and delegate +/// `Read`/`Write`/`ReadReady` on the stream, and MQTT follows with no code +/// here. TLS does not come this way — its readiness is two-layered, so it +/// implements [`BrokerTransport`] directly. +pub struct SocketTransport { + dialer: D, + host: alloc::string::String, + port: u16, +} + +impl SocketTransport { + /// Dial `host:port` through `dialer` for each broker session. + pub fn new(dialer: D, host: impl Into, port: u16) -> Self { + Self { + dialer, + host: host.into(), + port, + } + } +} + +impl BrokerTransport for SocketTransport +where + D: aimdb_core::session::StreamDialer + Sync, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ + type Connection = mountain_mqtt::embedded_io_async::ConnectionEmbedded; + + async fn connect(&self) -> TransportResult { + let stream = self.dialer.connect(&self.host, self.port).await?; + Ok(mountain_mqtt::embedded_io_async::ConnectionEmbedded::new( + stream, + )) + } +} From 781fc1c18cc90ad7f9fb9c77a4f2d24e253671b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:38:51 +0000 Subject: [PATCH 03/20] feat(mqtt): enhance embassy runtime support and improve documentation --- aimdb-mqtt-connector/Cargo.toml | 1 + aimdb-mqtt-connector/src/connector.rs | 6 +- aimdb-mqtt-connector/src/embassy_client.rs | 71 ++++++++--------- aimdb-mqtt-connector/src/embassy_tls.rs | 16 ++-- aimdb-mqtt-connector/src/sntp.rs | 2 +- aimdb-mqtt-connector/src/transport.rs | 89 +++++++++++++++++++++- 6 files changed, 136 insertions(+), 49 deletions(-) diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index b1e5548c..8071be10 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -40,6 +40,7 @@ embassy-runtime = [ "dep:aimdb-embassy-adapter", # Enable the optional dependency "aimdb-embassy-adapter/embassy-net-support", # Enable EmbassyNetwork trait for network stack access "aimdb-embassy-adapter/connectors", # `EmbassySink`/`EmbassySource`/`into_box_future` spine + "aimdb-embassy-adapter/net", # `EmbassyNet::tcp` — the adapter owns the socket "embassy-executor", "embassy-time", "embassy-sync", diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 5f36c3bd..8af3b25b 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -9,8 +9,8 @@ //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | [`Native`] | `rumqttc` (std) | 0–2 | rustls | -//! | [`Embedded`] | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | `Native` (feature `tokio-runtime`) | `rumqttc` (std) | 0–2 | rustls | +//! | `Embedded` (feature `embassy-runtime`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; use alloc::vec::Vec; @@ -59,7 +59,7 @@ impl MqttConnector { /// /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS /// (default 8883) and needs the `embassy-tls` feature plus - /// [`with_tls`](Self::with_tls). + /// `with_tls` (feature `embassy-tls`). pub fn new( broker_url: impl Into, stack: &'static embassy_net::Stack<'static>, diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 2ec8dab4..ca0d9419 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -68,7 +68,7 @@ use static_cell::StaticCell; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use mountain_mqtt_embassy::mqtt_manager::{self, MqttEvent, Settings}; +use mountain_mqtt_embassy::mqtt_manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] pub use crate::embassy_tls::TlsOptions; @@ -504,9 +504,10 @@ fn static_connection_settings( } /// Sender half of the event channel (used by the broker manager tasks). -type EventSender = Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; +pub(crate) type EventSender = + Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; /// Receiver half of the action channel (drained by the broker manager tasks). -type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; +pub(crate) type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; /// Initialise the static action/event channels shared by both transports /// (one MQTT connector per firmware — `StaticCell` enforces single init). @@ -527,12 +528,11 @@ fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) ) } -/// Set up the plain-TCP broker manager -/// (mountain-mqtt-embassy's `run_with_subscriptions`), returning the action -/// sender (outbound), the event receiver (inbound), and the manager task -/// future. The manager re-subscribes the inbound topics on every connection, -/// so routing survives reconnects. Synchronous — no `.await` — so the caller's -/// `build` future stays `Send`. +/// Set up the plain-TCP broker session loop, returning the action sender +/// (outbound), the event receiver (inbound), and the task future. The loop +/// re-subscribes the inbound topics on every connection, so routing survives +/// reconnects. Synchronous — no `.await` — so the caller's `build` future +/// stays `Send`. fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, @@ -550,36 +550,37 @@ fn setup_manager( let settings = Settings::new(broker_addr, broker.port); let network = stack.get(); - // Manager task: run the broker loop (never returns). The manager - // re-subscribes these topics on every connection, so inbound routing - // survives reconnects (unlike queuing subscribe actions once at startup). - let manager_task = into_box_future(async move { - let subscribe_topics: Vec<(&str, QualityOfService)> = topics - .iter() - .map(|topic| (topic.as_str(), QualityOfService::Qos1)) - .collect(); + // The socket buffers the dialer owns for the process lifetime. `StaticCell` + // enforces one MQTT connector per firmware, as the channels above do. + static SOCKET_RX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); + static SOCKET_TX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); + + // The transport the session loop dials each cycle. Sockets come from the + // adapter; `run_with_subscriptions` is gone because it binds the stack and + // cannot take one. + let transport = crate::transport::SocketTransport::new( + aimdb_embassy_adapter::net::EmbassyNet::tcp( + *network, + SOCKET_RX.init([0; BUFFER_SIZE]), + SOCKET_TX.init([0; BUFFER_SIZE]), + ), + broker.host.clone(), + broker.port, + ); + let manager_task = into_box_future(async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); - #[allow(unreachable_code)] - { - let _: () = mqtt_manager::run_with_subscriptions::< - AimdbMqttAction, - AimdbMqttEvent, - MAX_PROPERTIES, - BUFFER_SIZE, - CHANNEL_SIZE, - >( - *network, - connection_settings, - settings, - &subscribe_topics, - event_sender, - action_receiver, - ) - .await; - } + crate::transport::run_sessions( + transport, + topics, + connection_settings, + settings, + event_sender, + action_receiver, + ) + .await }); Ok((action_sender, event_receiver, alloc::vec![manager_task])) diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 07d35a0f..14038ca6 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -1,16 +1,14 @@ //! TLS transport for the Embassy MQTT client. //! //! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy -//! TCP socket, wrapped in mountain-mqtt's [`ConnectionEmbedded`] so the MQTT -//! layer is identical to the plain path. Certificate verification is -//! `rustpki` (pure Rust) against the application-embedded root CA, with time -//! from the [`sntp`](crate::sntp) task; entropy comes from the -//! application-injected TRNG ([`TlsOptions::new`]). +//! TCP socket, presented to the MQTT layer as its own `Connection` — not +//! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give +//! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) +//! against the application-embedded root CA, with time from the [`sntp`] task; +//! entropy comes from the application-injected TRNG ([`TlsOptions::new`]). //! -//! The session loop is mountain-mqtt-embassy's own public -//! [`handle_messages`](mountain_mqtt_embassy::mqtt_manager::handle_messages) -//! (with [`State`](mountain_mqtt_embassy::mqtt_manager::State) / -//! [`ChannelEventHandler`](mountain_mqtt_embassy::mqtt_manager::ChannelEventHandler)): +//! The session loop is mountain-mqtt-embassy's own public `handle_messages` +//! (with `State` / `ChannelEventHandler`): //! it is transport-agnostic (generic over `Client`), so the only thing this //! module supplies is the transport — resolve → TCP → TLS handshake → session. //! Upstream `run()` shares that exact loop, keeping the plain and TLS paths in diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/sntp.rs index cdd28bf7..8c6e97a1 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/sntp.rs @@ -4,7 +4,7 @@ //! certificate's validity window needs the current Unix time. This module //! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch //! (boot), written after each SNTP sync and read through [`unix_now`] / -//! [`SntpClock`]. The TLS manager spawns [`run`] alongside its broker loop +//! [`SntpClock`]. The TLS manager spawns `run` alongside its broker loop //! and holds the first handshake until the first sync lands. use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs index 50d28b3a..2d5d4bed 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/transport.rs @@ -5,7 +5,7 @@ //! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs //! `receive_if_ready` — a non-blocking peek — which a byte stream does not //! express and a TLS session cannot provide (its readiness is two-layered; -//! see [`crate::embassy_tls`]). Wrapping core's trait would mean every +//! see the `embassy_tls` module). Wrapping core's trait would mean every //! TLS-like transport faking a capability, so the client's own seam is the //! honest one. //! @@ -69,3 +69,90 @@ where )) } } + +/// The broker session loop: connect, run MQTT until the session ends, wait, +/// repeat. Never returns. +/// +/// One implementation for every transport. `handle_messages` re-subscribes +/// `subscribe_topics` on each connection, so inbound routing survives a +/// reconnect — the property `run_with_subscriptions` used to provide, now +/// explicit here because injecting a transport means giving that helper up. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_sessions( + transport: T, + topics: alloc::vec::Vec, + connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, + settings: mountain_mqtt_embassy::mqtt_manager::Settings, + event_sender: crate::embassy_client::EventSender, + mut action_receiver: crate::embassy_client::ActionReceiver, +) -> ! +where + T: BrokerTransport, +{ + use core::cell::RefCell; + use mountain_mqtt::client::ClientNoQueue; + use mountain_mqtt::data::quality_of_service::QualityOfService; + use mountain_mqtt::mqtt_manager::ConnectionId; + use mountain_mqtt_embassy::mqtt_manager::{ + handle_messages, ChannelEventHandler, MqttEvent, State, + }; + + // Built once and borrowed for the loop; re-sent on every connection. + let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics + .iter() + .map(|topic| (topic.as_str(), QualityOfService::Qos1)) + .collect(); + + let mut mqtt_buffer = [0u8; crate::embassy_client::BUFFER_SIZE]; + let mut connection_index = 0u32; + + loop { + let connection = match transport.connect().await { + Ok(connection) => connection, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: connect failed, will retry"); + embassy_time::Timer::after(settings.reconnection_delay).await; + continue; + } + }; + + let state: RefCell> = + RefCell::new(State::new()); + let connection_id = ConnectionId::new(connection_index); + connection_index += 1; + + let event_handler = ChannelEventHandler::new(connection_id, &event_sender, &state); + let mut client = ClientNoQueue::new( + connection, + &mut mqtt_buffer, + mountain_mqtt::embedded_hal_async::DelayEmbedded::new(embassy_time::Delay), + settings.response_timeout.as_millis() as u32, + event_handler, + ); + + if let Err(error) = handle_messages( + connection_id, + &mut client, + &state, + &connection_settings, + &subscribe_topics, + &event_sender, + &mut action_receiver, + &settings, + ) + .await + { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: session errored: {:?}", error); + event_sender + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + } + + embassy_time::Timer::after(settings.reconnection_delay).await; + } +} From e19d1eb72d6da788d5c2839d197e0a7ee9194880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 12:22:36 +0000 Subject: [PATCH 04/20] feat(mqtt): add internal test for Embassy broker session loop and update dependencies --- Cargo.lock | 4 + Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 25 ++ aimdb-mqtt-connector/tests/embassy_broker.rs | 356 +++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 aimdb-mqtt-connector/tests/embassy_broker.rs diff --git a/Cargo.lock b/Cargo.lock index 1251e840..ef97da55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,13 +295,17 @@ dependencies = [ "aimdb-mountain-mqtt-embassy", "aimdb-tokio-adapter", "async-stream", + "critical-section", "defmt 1.1.1", "embassy-executor", "embassy-net", + "embassy-net-driver-channel", "embassy-sync", "embassy-time", + "embassy-time-driver", "embedded-io-async 0.7.0", "embedded-tls", + "futures", "futures-core", "futures-util", "heapless 0.8.0", diff --git a/Makefile b/Makefile index 97110e57..2c378dbf 100644 --- a/Makefile +++ b/Makefile @@ -227,6 +227,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback @printf "$(YELLOW) → Testing TCP connector (accept pool over two embassy-net stacks)$(NC)\n" cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool + @printf "$(YELLOW) → Testing MQTT connector (broker session loop against a fake broker)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -356,6 +358,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -D warnings @printf "$(YELLOW) → Clippy on TCP connector (accept pool, host)$(NC)\n" cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (broker session loop, host)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 8071be10..4bd1edb2 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -72,6 +72,23 @@ tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] defmt = ["dep:defmt", "aimdb-core/defmt"] +# Internal: the Embassy broker session loop's host smoke +# (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an +# in-memory driver-channel crossover, with a fake broker on one side. Kept off +# `embassy-runtime` (production pulls no network device or critical-section +# impl). Run with `--features _test-embassy-broker`. +_test-embassy-broker = [ + "embassy-runtime", + # The test builds an `AimDb`; `EmbassyAdapter`'s `RuntimeOps` impl is gated + # on the adapter's own clock feature, which production never needs here. + "aimdb-embassy-adapter/embassy-time", + "aimdb-embassy-adapter/embassy-sync", + "embassy-net/medium-ip", + "embassy-net/proto-ipv4", + "dep:embassy-net-driver-channel", + "dep:critical-section", +] + [dependencies] aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false } aimdb-embassy-adapter = { version = "0.6.0", path = "../aimdb-embassy-adapter", default-features = false, optional = true } @@ -137,8 +154,16 @@ static_cell = { version = "2.0", optional = true } # Optional observability defmt = { workspace = true, optional = true } +embassy-net-driver-channel = { version = "0.4.0", optional = true } +critical-section = { version = "1.1", features = ["std"], optional = true } + [dev-dependencies] tokio = { workspace = true, features = ["full"] } +heapless = { workspace = true } +futures = "0.3" +embassy-time-driver = "0.2.2" +# The loopback harness must supply the defmt symbols smoltcp references. +defmt = { workspace = true } tokio-test = "0.4" serde = { workspace = true } aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = false, features = [ diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs new file mode 100644 index 00000000..2f01cdb4 --- /dev/null +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -0,0 +1,356 @@ +//! Host smoke for the Embassy broker session loop (`_test-embassy-broker`). +//! +//! The loop is what replaced mountain-mqtt-embassy's `run_with_subscriptions` +//! when the transport became injectable, so reconnect-and-resubscribe is this +//! crate's behaviour now rather than the helper's. Two `embassy-net` stacks +//! wired by an in-memory driver-channel crossover drive it against a fake +//! broker that speaks just enough MQTT: CONNECT/CONNACK, SUBSCRIBE/SUBACK, and +//! a server-initiated PUBLISH. +#![cfg(feature = "_test-embassy-broker")] + +extern crate alloc; + +use core::future::Future; + +use embassy_net::{Config, Ipv4Address, Ipv4Cidr, Stack, StaticConfigV4}; +use embassy_net_driver_channel as ch; +use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +// No `defmt::timestamp!` here: this config enables the adapter's `embassy-time`, +// whose `defmt-timestamp-uptime` already defines `_defmt_timestamp`. + +/// Real wall-clock time; a frozen `now()` stalls the stack's timers and the +/// session loop's reconnection delay. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +const MTU: usize = 1514; +const BROKER_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const CLIENT_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); +const BROKER_PORT: u16 = 1883; + +type ChState = ch::State; + +fn leak(v: T) -> &'static mut T { + alloc::boxed::Box::leak(alloc::boxed::Box::new(v)) +} + +fn buf() -> &'static mut [u8] { + alloc::boxed::Box::leak(alloc::vec![0u8; 2048].into_boxed_slice()) +} + +fn make_stack( + ip: Ipv4Address, + seed: u64, +) -> ( + Stack<'static>, + embassy_net::Runner<'static, ch::Device<'static, MTU>>, + ch::Runner<'static, MTU>, +) { + let state: &'static mut ChState = leak(ch::State::new()); + let (ch_runner, device) = ch::new(state, HardwareAddress::Ip); + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers: Default::default(), + }); + let resources = leak(embassy_net::StackResources::<4>::new()); + let (stack, net_runner) = embassy_net::new(device, config, resources, seed); + (stack, net_runner, ch_runner) +} + +async fn cable(mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, MTU>) -> ! { + loop { + let tx_slot = tx.tx_buf().await; + let len = tx_slot.len(); + let mut rx_slot = rx.rx_buf().await; + rx_slot[..len].copy_from_slice(&tx_slot[..len]); + tx_slot.tx_done(); + rx_slot.rx_done(len); + } +} + +/// Run `foreground` while both stacks poll in the background, watchdogged so a +/// hang fails the test rather than the CI job. +fn drive(foreground: F) -> Result<(), &'static str> +where + Fut: Future, + F: FnOnce(Stack<'static>, Stack<'static>) -> Fut, +{ + use core::future::poll_fn; + use core::task::Poll; + use std::time::{Duration, Instant}; + + use futures::future::{join4, select, Either}; + use futures::pin_mut; + + const WATCHDOG: Duration = Duration::from_secs(20); + + let (broker_stack, mut broker_net, broker_ch) = make_stack(BROKER_IP, 0x1111_2222); + let (client_stack, mut client_net, client_ch) = make_stack(CLIENT_IP, 0x3333_4444); + + let (broker_state, broker_rx, broker_tx) = broker_ch.split(); + let (client_state, client_rx, client_tx) = client_ch.split(); + broker_state.set_link_state(LinkState::Up); + client_state.set_link_state(LinkState::Up); + + let background = join4( + broker_net.run(), + client_net.run(), + cable(broker_tx, client_rx), + cable(client_tx, broker_rx), + ); + let foreground = foreground(broker_stack, client_stack); + + futures::executor::block_on(async { + pin_mut!(foreground); + pin_mut!(background); + let session = select(foreground, background); + pin_mut!(session); + + let deadline = Instant::now() + WATCHDOG; + let watchdog = poll_fn(move |cx| { + if Instant::now() >= deadline { + Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + Poll::Pending + } + }); + pin_mut!(watchdog); + + match select(session, watchdog).await { + Either::Left((Either::Left(_), _)) => Ok(()), + Either::Left((Either::Right(_), _)) => Err("background ended before the test"), + Either::Right(_) => Err("watchdog: foreground stuck"), + } + }) +} + +// --------------------------------------------------------------------------- +// A fake broker: just enough MQTT 5 to complete a session. +// --------------------------------------------------------------------------- + +/// Accept one TCP connection and answer CONNECT and SUBSCRIBE, then push a +/// PUBLISH. Records what it saw so the test can assert on the wire, not on +/// side effects. +#[derive(Default)] +struct Seen { + connect: bool, + subscribed_topics: alloc::vec::Vec, +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet( + socket: &mut embassy_net::tcp::TcpSocket<'_>, + buf: &mut alloc::vec::Vec, +) -> Option<(u8, alloc::vec::Vec)> { + use embedded_io_async::Read; + + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut alloc::vec::Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +async fn fake_broker(stack: Stack<'static>, seen: &core::cell::RefCell) { + use embedded_io_async::Write; + + let mut socket = embassy_net::tcp::TcpSocket::new(stack, buf(), buf()); + socket.set_timeout(None); + if socket.accept(BROKER_PORT).await.is_err() { + return; + } + + let mut payload = alloc::vec::Vec::new(); + loop { + let Some((first, body)) = read_packet(&mut socket, &mut payload).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK (session present = 0, reason = success, no props) + 1 => { + seen.borrow_mut().connect = true; + let _ = socket.write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]).await; + } + // SUBSCRIBE -> SUBACK granting QoS 1 for each requested topic. + 8 => { + // body: packet id (2) + property length (varint, 0 here) + payload + let packet_id = [body[0], body[1]]; + let mut i = 2; + // Skip the property length varint. + while i < body.len() && body[i] & 0x80 != 0 { + i += 1; + } + i += 1; + let mut granted = alloc::vec::Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + seen.borrow_mut().subscribed_topics.push( + alloc::string::String::from_utf8_lossy(&body[i..i + len]).into_owned(), + ); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + let mut ack = alloc::vec::Vec::new(); + let mut rest = alloc::vec::Vec::new(); + rest.extend_from_slice(&packet_id); + rest.push(0x00); // no properties + rest.extend_from_slice(&granted); + ack.push(0x90); + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + let _ = socket.write_all(&ack).await; + } + // PINGREQ -> PINGRESP + 12 => { + let _ = socket.write_all(&[0xD0, 0x00]).await; + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// The test. +// --------------------------------------------------------------------------- + +/// The session loop completes a broker session over the injected transport: +/// CONNECT is answered, and the inbound topics are **subscribed on the wire**. +/// +/// That subscribe is the property `run_with_subscriptions` used to provide and +/// this crate now owns — without it, inbound routing dies silently on the first +/// reconnect. +#[test] +fn the_session_loop_connects_and_subscribes() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + use alloc::sync::Arc; + use core::cell::RefCell; + + let seen = RefCell::new(Seen::default()); + + let outcome = drive(|broker_stack, client_stack| { + let seen = &seen; + async move { + let stack: &'static Stack<'static> = leak(client_stack); + + let connector = MqttConnector::new( + alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT), + stack, + ) + .with_client_id("host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| alloc::string::String::from("bad payload")) + }) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + + // Drive the runner (which owns the session loop) and the broker + // together until the broker has seen a subscribe. + let session = runner.run(); + let broker = fake_broker(broker_stack, seen); + let until_subscribed = async { + loop { + if !seen.borrow().subscribed_topics.is_empty() { + return; + } + embassy_time::Timer::after(embassy_time::Duration::from_millis(10)).await; + } + }; + + futures::pin_mut!(session); + futures::pin_mut!(broker); + futures::pin_mut!(until_subscribed); + let running = futures::future::select(session, broker); + futures::pin_mut!(running); + let _ = futures::future::select(running, until_subscribed).await; + } + }); + + assert_eq!(outcome, Ok(())); + let seen = seen.borrow(); + assert!(seen.connect, "the broker never saw a CONNECT"); + assert!( + seen.subscribed_topics + .iter() + .any(|t| t == "sensors/temperature"), + "the session must subscribe the inbound topic on the wire; saw {:?}", + seen.subscribed_topics + ); +} From 6ddc1842ff34c23651c20f7717944cd129d0f572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 12:53:17 +0000 Subject: [PATCH 05/20] feat(mqtt): enhance transport flexibility by allowing caller-supplied StreamDialer --- aimdb-embassy-adapter/src/net.rs | 1 + aimdb-mqtt-connector/src/connector.rs | 56 +++--- aimdb-mqtt-connector/src/embassy_client.rs | 167 +++++++++++------- aimdb-mqtt-connector/tests/embassy_broker.rs | 13 +- .../embassy-mqtt-connector-demo/src/main.rs | 41 +++-- .../weather-station-gamma/src/main.rs | 13 +- 6 files changed, 190 insertions(+), 101 deletions(-) diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index a2e4d74d..7ac8a5d6 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -261,6 +261,7 @@ impl embedded_io_async::ReadReady for EmbassyTcpStream { } /// Dials TCP connections over one caller-owned socket. +#[derive(Clone)] pub struct EmbassyTcpDialer { slot: Arc, } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 8af3b25b..3ce1684f 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -24,9 +24,11 @@ use aimdb_core::{AimDb, DbResult}; #[cfg(feature = "tokio-runtime")] pub struct Native(crate::tokio_client::MqttConnectorBuilder); -/// The `mountain-mqtt` backend: `no_std`, over the device's network stack. +/// The `mountain-mqtt` backend: `no_std`, over a caller-supplied transport. #[cfg(feature = "embassy-runtime")] -pub struct Embedded(crate::embassy_client::MqttConnectorBuilder); +pub struct Embedded( + crate::embassy_client::MqttConnectorBuilder, +); /// An MQTT connector over the backend `B`. pub struct MqttConnector { @@ -55,22 +57,38 @@ impl MqttConnector { #[cfg(feature = "embassy-runtime")] impl MqttConnector { - /// Connect to `broker_url` over the device's network stack. - /// - /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS - /// (default 8883) and needs the `embassy-tls` feature plus - /// `with_tls` (feature `embassy-tls`). - pub fn new( - broker_url: impl Into, + /// Connect to `broker_url`, then supply the transport with + /// [`transport`](Self::transport) (`mqtt://`) or [`tls`](Self::tls) + /// (`mqtts://`, feature `embassy-tls`). + pub fn new(broker_url: impl Into) -> Self { + Self { + backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), + } + } + + /// Dial plain sessions through an adapter's stream dialer — the same call + /// on any runtime's adapter, with no change in this crate. + pub fn transport(self, dialer: D) -> MqttConnector> { + MqttConnector { + backend: Embedded(self.backend.0.transport(dialer)), + } + } + + /// Provide the network stack and TLS materials for an `mqtts://` broker. + #[cfg(feature = "embassy-tls")] + pub fn tls( + self, stack: &'static embassy_net::Stack<'static>, + options: crate::embassy_tls::TlsOptions, ) -> Self { Self { - backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new( - broker_url, stack, - )), + backend: Embedded(self.backend.0.tls(stack, options)), } } +} +#[cfg(feature = "embassy-runtime")] +impl MqttConnector> { /// Set the MQTT client id (defaults to `aimdb-client`). pub fn with_client_id(self, client_id: impl Into) -> Self { Self { @@ -88,14 +106,6 @@ impl MqttConnector { backend: Embedded(self.backend.0.with_credentials(username, password)), } } - - /// Provide the TLS materials for an `mqtts://` broker. - #[cfg(feature = "embassy-tls")] - pub fn with_tls(self, options: crate::embassy_tls::TlsOptions) -> Self { - Self { - backend: Embedded(self.backend.0.with_tls(options)), - } - } } #[cfg(feature = "tokio-runtime")] @@ -119,7 +129,11 @@ impl ConnectorBuilder for MqttConnector { } #[cfg(feature = "embassy-runtime")] -impl ConnectorBuilder for MqttConnector { +impl ConnectorBuilder for MqttConnector> +where + D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a AimDb, diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index ca0d9419..efca8d6e 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -290,38 +290,94 @@ type TlsSlot = aimdb_core::session::OneShot; /// transport: `mqtt://` is plain TCP (default port 1883), `mqtts://` is TLS /// (default port 8883) and requires both the `embassy-tls` feature and the /// `with_tls` method it gates. -pub struct MqttConnectorBuilder { +/// Where the broker connection comes from. +/// +/// Plain sessions dial through a caller-supplied [`StreamDialer`], so a new +/// runtime supplies MQTT by passing its own. TLS keeps the stack: it resolves +/// DNS itself and owns buffers across sessions, which a per-session dialer +/// cannot express. +pub(crate) enum Transport { + Plain(D), + #[cfg(feature = "embassy-tls")] + Tls(aimdb_embassy_adapter::connectors::NetStack, TlsSlot), +} + +/// A dialer placeholder for TLS-only connectors, which never dial through one. +#[derive(Clone, Copy, Default)] +pub struct NoTransport; + +impl aimdb_core::session::StreamDialer for NoTransport { + type Stream = aimdb_embassy_adapter::net::EmbassyTcpStream; + + async fn connect( + &self, + _host: &str, + _port: u16, + ) -> aimdb_core::session::TransportResult { + Err(aimdb_core::session::TransportError::Io) + } +} + +pub struct MqttConnectorBuilder { broker_url: String, client_id: String, credentials: Option<(String, String)>, - #[cfg(feature = "embassy-tls")] - tls: TlsSlot, - stack: aimdb_embassy_adapter::connectors::NetStack, + pub(crate) transport: Transport, } -impl MqttConnectorBuilder { +impl MqttConnectorBuilder { /// Create a new MQTT connector builder for Embassy. /// - /// # Arguments - /// * `broker_url` - Broker URL in format `mqtt://host:port` (plain TCP) - /// or `mqtts://host:port` (TLS, see `with_tls`, feature `embassy-tls`) - /// * `stack` - The device's network stack (the runtime travels as - /// `Arc` and cannot surface it) - pub fn new(broker_url: impl Into, stack: &'static embassy_net::Stack<'static>) -> Self { + /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, + /// or [`tls`](Self::tls) for `mqtts://`. + pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), client_id: "aimdb-client".to_string(), credentials: None, - #[cfg(feature = "embassy-tls")] - tls: TlsSlot::default(), + transport: Transport::Plain(NoTransport), + } + } + + /// Dial plain `mqtt://` sessions through an adapter's stream dialer. + /// + /// `EmbassyNet::tcp(stack, rx, tx)` on Embassy; the same call on any other + /// runtime's adapter, with no change here. + pub fn transport(self, dialer: D) -> MqttConnectorBuilder { + MqttConnectorBuilder { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + transport: Transport::Plain(dialer), + } + } + + /// Provide the network stack and TLS materials for an `mqtts://` broker. + /// + /// TLS keeps the stack rather than taking a dialer: it resolves DNS itself + /// and owns buffers across sessions. + #[cfg(feature = "embassy-tls")] + pub fn tls( + self, + stack: &'static embassy_net::Stack<'static>, + options: TlsOptions, + ) -> MqttConnectorBuilder { + MqttConnectorBuilder { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, // SAFETY: AimDB's Embassy integration requires a single-core // cooperative executor (the adapter's module-level invariant); - // every future touching this stack — including the broker task - // built from this builder — is polled on that executor. - stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + // every future touching this stack is polled on that executor. + transport: Transport::Tls( + unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + TlsSlot::new(options), + ), } } +} +impl MqttConnectorBuilder { /// Set the MQTT client ID (should be unique per device). pub fn with_client_id(mut self, client_id: impl Into) -> Self { self.client_id = client_id.into(); @@ -340,15 +396,6 @@ impl MqttConnectorBuilder { self.credentials = Some((username.into(), password.into())); self } - - /// Provide the TLS materials for an `mqtts://` broker. - /// - /// Required for `mqtts://` URLs; rejected at `build()` for `mqtt://`. - #[cfg(feature = "embassy-tls")] - pub fn with_tls(mut self, options: TlsOptions) -> Self { - self.tls = TlsSlot::new(options); - self - } } /// Implement ConnectorBuilder trait for Embassy. @@ -356,7 +403,11 @@ impl MqttConnectorBuilder { /// The network stack is taken at construction (see /// [`MqttConnectorBuilder::new`]), so the builder needs nothing from the /// runtime beyond the dyn-safe capabilities the database already holds. -impl ConnectorBuilder for MqttConnectorBuilder { +impl ConnectorBuilder for MqttConnectorBuilder +where + D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a aimdb_core::builder::AimDb, @@ -385,25 +436,21 @@ impl ConnectorBuilder for MqttConnectorBuilder { // Broker manager task(s) + the channel ends for the pumps. // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] - let (action_sender, event_receiver, manager_tasks) = { - let tls_options = self.tls.take(); - match (broker.tls, tls_options) { - (true, Some(options)) => setup_tls_manager( - &broker, - options, - connection_settings, - self.stack, - topics, - )?, - (true, None) => { - return Err(build_err("mqtts:// broker URLs require .with_tls(...)")) - } - (false, Some(_)) => { - return Err(build_err(".with_tls(...) requires an mqtts:// broker URL")) - } - (false, None) => { - setup_manager(&broker, connection_settings, self.stack, topics)? - } + let (action_sender, event_receiver, manager_tasks) = match &self.transport { + Transport::Tls(stack, slot) if broker.tls => { + let options = slot.take().ok_or_else(|| { + build_err("TLS materials already taken; build() ran twice") + })?; + setup_tls_manager(&broker, options, connection_settings, *stack, topics)? + } + Transport::Tls(..) => { + return Err(build_err(".tls(...) requires an mqtts:// broker URL")) + } + Transport::Plain(_) if broker.tls => { + return Err(build_err("mqtts:// broker URLs require .tls(...)")) + } + Transport::Plain(dialer) => { + setup_manager(&broker, connection_settings, dialer.clone(), topics)? } }; #[cfg(not(feature = "embassy-tls"))] @@ -413,7 +460,8 @@ impl ConnectorBuilder for MqttConnectorBuilder { "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", )); } - setup_manager(&broker, connection_settings, self.stack, topics)? + let Transport::Plain(dialer) = &self.transport; + setup_manager(&broker, connection_settings, dialer.clone(), topics)? }; // Outbound publishes + inbound routing ride core's pumps. @@ -533,12 +581,16 @@ fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) /// re-subscribes the inbound topics on every connection, so routing survives /// reconnects. Synchronous — no `.await` — so the caller's `build` future /// stays `Send`. -fn setup_manager( +fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, + dialer: D, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { +) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> +where + D: aimdb_core::session::StreamDialer + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ let broker_ip = Ipv4Addr::from_str(&broker.host).map_err(|_| { build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") })?; @@ -548,25 +600,12 @@ fn setup_manager( let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); let settings = Settings::new(broker_addr, broker.port); - let network = stack.get(); - - // The socket buffers the dialer owns for the process lifetime. `StaticCell` - // enforces one MQTT connector per firmware, as the channels above do. - static SOCKET_RX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); - static SOCKET_TX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); // The transport the session loop dials each cycle. Sockets come from the // adapter; `run_with_subscriptions` is gone because it binds the stack and // cannot take one. - let transport = crate::transport::SocketTransport::new( - aimdb_embassy_adapter::net::EmbassyNet::tcp( - *network, - SOCKET_RX.init([0; BUFFER_SIZE]), - SOCKET_TX.init([0; BUFFER_SIZE]), - ), - broker.host.clone(), - broker.port, - ); + let transport = + crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); let manager_task = into_box_future(async move { #[cfg(feature = "defmt")] diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs index 2f01cdb4..25b997e6 100644 --- a/aimdb-mqtt-connector/tests/embassy_broker.rs +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -299,11 +299,14 @@ fn the_session_loop_connects_and_subscribes() { async move { let stack: &'static Stack<'static> = leak(client_stack); - let connector = MqttConnector::new( - alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT), - stack, - ) - .with_client_id("host-smoke"); + let connector = + MqttConnector::new(alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT)) + .transport(aimdb_embassy_adapter::net::EmbassyNet::tcp( + *stack, + buf(), + buf(), + )) + .with_client_id("host-smoke"); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 4aa7e5cb..248b7ca0 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -90,6 +90,7 @@ use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; +use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] use aimdb_mqtt_connector::embassy_client::TlsOptions; @@ -385,21 +386,41 @@ async fn main(spawner: Spawner) { // Read-only: each record has a single writer (a sensor source, or MQTT for the // command records), so remote `record.set` is refused — peers can // list/drain/subscribe, not write. - let mqtt = MqttConnector::new(&broker_url, stack).with_client_id("embassy-demo-001"); + // Plain `mqtt://`: the adapter owns the socket, so the buffers are the + // caller's and visible here. The same line on another runtime's adapter + // needs no change in the connector. + #[cfg(not(feature = "tls"))] + let mqtt = { + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); + MqttConnector::new(&broker_url) + .transport(EmbassyNet::tcp( + *stack, + MQTT_RX.init([0; 4096]), + MQTT_TX.init([0; 4096]), + )) + .with_client_id("embassy-demo-001") + }; - // TLS materials: the board's TRNG, the broker's root CA, and the record - // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer - // may send full-size records). `init_with` keeps the arrays off the stack. + // `mqtts://` keeps the stack: TLS resolves DNS itself and owns its buffers + // across sessions. The board's TRNG, the broker's root CA, and the record + // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer may + // send full-size records). `init_with` keeps the arrays off the stack. #[cfg(feature = "tls")] let mqtt = { static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); - let mqtt = mqtt.with_tls(TlsOptions::new( - rng, - MQTT_CA_DER, - TLS_READ_BUF.init_with(|| [0; 16_640]), - TLS_WRITE_BUF.init_with(|| [0; 4_096]), - )); + let mqtt = MqttConnector::new(&broker_url) + .tls( + stack, + TlsOptions::new( + rng, + MQTT_CA_DER, + TLS_READ_BUF.init_with(|| [0; 16_640]), + TLS_WRITE_BUF.init_with(|| [0; 4_096]), + ), + ) + .with_client_id("embassy-demo-001"); match MQTT_CREDENTIALS { Some((username, password)) => mqtt.with_credentials(username, password), None => mqtt, 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 7baa0967..1623c374 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -27,6 +27,7 @@ extern crate alloc; use aimdb_core::{AimDbBuilder, RecordKey}; #[cfg(feature = "sim")] use aimdb_data_contracts::{RandomWalkParams, SimProfile, SimulatableRegistrarExt}; +use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; use aimdb_mqtt_connector::MqttConnector; use defmt::*; @@ -250,8 +251,18 @@ async fn main(spawner: Spawner) { use alloc::format; let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + // The adapter owns the socket, so its buffers are the caller's and visible + // here; the same line works on any runtime's adapter. + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); let mut builder = AimDbBuilder::new().runtime(runtime.clone()).with_connector( - MqttConnector::new(&broker_url, stack).with_client_id("weather-station-gamma"), + MqttConnector::new(&broker_url) + .transport(EmbassyNet::tcp( + *stack, + MQTT_RX.init([0; 4096]), + MQTT_TX.init([0; 4096]), + )) + .with_client_id("weather-station-gamma"), ); // Configure temperature record From 237e3cdf3ca594f2ecbfa0925e6c0d7d32e083bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 14:50:32 +0000 Subject: [PATCH 06/20] docs(mqtt-connector): record the runtime-neutral migration Co-Authored-By: Claude Opus 5 --- aimdb-mqtt-connector/CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a08977a8..a507fc00 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **One `MqttConnector` over two protocol backends (breaking on Embassy).** + `Native` is `rumqttc` (QoS 0–2, rustls); `Embedded` is `mountain-mqtt` over + a caller-supplied transport. The Tokio path is unchanged; Embassy callers now + write `MqttConnector::new(url).transport(EmbassyNet::tcp(..))` or + `.tls(stack, opts)` instead of passing the stack to `new`. The + `Tokio*`/`Embassy*` aliases and `MqttConnectorBuilder` are gone. +- **`run_with_subscriptions` replaced by an owned session loop.** It binds + `embassy_net::Stack` and cannot take a transport, so reconnect-and-resubscribe + is now explicit in `transport::run_sessions` — one loop for both plain and + TLS, extracted from the TLS path already running it. - **Reports through the `log_*` facade instead of `tracing::` directly** (design 050 §10.5), so a `log` destination — an FFI layer's, say — sees this crate's events too. Each call site also shed the hand-written @@ -18,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`transport` — the broker transport seam.** `BrokerTransport` over + `mountain-mqtt`'s own `Connection` (the client needs a non-blocking peek that + a byte stream cannot express and TLS cannot provide), plus `SocketTransport` + bridging from core's `StreamDialer`. A new runtime supplies MQTT by + implementing that dialer — no code here. +- **`tests/embassy_broker.rs`** — the connector against a fake broker over two + crossover-wired `embassy-net` stacks, asserting CONNECT *and* SUBSCRIBE reach + the wire. - **Tokio client: the TLS backend for `mqtts://` is now a build-time choice.** Two new features — `tokio-native-tls` (system OpenSSL, what this crate linked before) and `tokio-rustls` (pure Rust, no `libssl`/`libcrypto`) — plus the From be27274ee934347e43426572172de93c56220494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 15:01:02 +0000 Subject: [PATCH 07/20] docs(mqtt-connector): unlink feature-gated items from ungated docs `Self::tls` is `embassy-tls`-gated, but `make doc` builds this crate with `embassy-runtime` only, so the link failed the docs gate in CI. Co-Authored-By: Claude Opus 5 --- aimdb-mqtt-connector/src/connector.rs | 4 ++-- aimdb-mqtt-connector/src/embassy_client.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 3ce1684f..dc990f12 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -58,8 +58,8 @@ impl MqttConnector { #[cfg(feature = "embassy-runtime")] impl MqttConnector { /// Connect to `broker_url`, then supply the transport with - /// [`transport`](Self::transport) (`mqtt://`) or [`tls`](Self::tls) - /// (`mqtts://`, feature `embassy-tls`). + /// [`transport`](Self::transport) for `mqtt://`, or `tls` (feature + /// `embassy-tls`) for `mqtts://`. pub fn new(broker_url: impl Into) -> Self { Self { backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index efca8d6e..33b4f115 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -329,7 +329,7 @@ impl MqttConnectorBuilder { /// Create a new MQTT connector builder for Embassy. /// /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, - /// or [`tls`](Self::tls) for `mqtts://`. + /// or `tls` (feature `embassy-tls`) for `mqtts://`. pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), From 19caee96631bb375a1dfb1b32f1ed3d19a1edb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:11:56 +0000 Subject: [PATCH 08/20] feat(tokio-adapter): add embedded-io support for async streams and enhance documentation --- Cargo.lock | 1 + Makefile | 8 ++- aimdb-tokio-adapter/CHANGELOG.md | 5 ++ aimdb-tokio-adapter/Cargo.toml | 9 +++ aimdb-tokio-adapter/src/net.rs | 114 +++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ef97da55..36dbb0a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-uds-connector", + "embedded-io-async 0.7.0", "futures", "log", "serde", diff --git a/Makefile b/Makefile index 2c378dbf..bc8241f4 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,8 @@ build: cargo build --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" @printf "$(YELLOW) → Building tokio adapter (runtime-neutral transports)$(NC)\n" cargo build --package aimdb-tokio-adapter --features "net" + @printf "$(YELLOW) → Building tokio adapter (embedded-io streams)$(NC)\n" + cargo build --package aimdb-tokio-adapter --features "embedded-io" @printf "$(YELLOW) → Building sync wrapper$(NC)\n" cargo build --package aimdb-sync @printf "$(YELLOW) → Building sync wrapper (no_std)$(NC)\n" @@ -175,6 +177,8 @@ test: cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" @printf "$(YELLOW) → Testing tokio adapter (runtime-neutral transports)$(NC)\n" cargo test --package aimdb-tokio-adapter --features "net" + @printf "$(YELLOW) → Testing tokio adapter (embedded-io streams)$(NC)\n" + cargo test --package aimdb-tokio-adapter --features "embedded-io" @printf "$(YELLOW) → Testing embassy adapter (host, no executor: buffers, join-queue, connector spine, doctests)$(NC)\n" cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time,connectors" @printf "$(YELLOW) → Testing embassy adapter (host: runtime-neutral transports, UART + UDP over two embassy-net stacks)$(NC)\n" @@ -284,6 +288,8 @@ clippy: cargo clippy --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on tokio adapter (runtime-neutral transports)$(NC)\n" cargo clippy --package aimdb-tokio-adapter --features "net" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on tokio adapter (embedded-io streams)$(NC)\n" + cargo clippy --package aimdb-tokio-adapter --features "embedded-io" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter$(NC)\n" cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter with network support$(NC)\n" @@ -376,7 +382,7 @@ doc: @printf "$(YELLOW) → Building cloud/edge documentation$(NC)\n" cargo doc --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json,linkable-postcard" --no-deps cargo doc --package aimdb-core --features "std,tracing,observability" --no-deps - cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net" --no-deps + cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net,embedded-io" --no-deps cargo doc --package aimdb-sync --no-deps cargo doc --package aimdb-mqtt-connector --features "std,tokio-runtime" --no-deps cargo doc --package aimdb-knx-connector --features "std,tokio-runtime" --no-deps diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index ce01dde3..8cadf8a6 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`embedded-io` feature — the `embedded-io-async` trio on the `net` streams.** + `TokioByteStream` implements `Read`/`Write` for any + `AsyncRead`/`AsyncWrite`, and `ReadReady` on `TokioByteStream` via + a non-destructive `poll_peek`. Lets `mountain-mqtt` and `embedded-tls` run on + a host over `TokioNet::tcp()`. - **`net` feature — Tokio behind core's neutral I/O traits.** `TokioNet::tcp`, `listen`, `udp` and `delay()` supply `StreamDialer`/`StreamListener`/ `DatagramBinder`/`Delay`, with `TokioByteStream` covering any diff --git a/aimdb-tokio-adapter/Cargo.toml b/aimdb-tokio-adapter/Cargo.toml index 84c5b9a2..5ca5ba96 100644 --- a/aimdb-tokio-adapter/Cargo.toml +++ b/aimdb-tokio-adapter/Cargo.toml @@ -25,6 +25,11 @@ tokio-runtime = ["tokio", "tokio-util", "std"] # runtime-neutral I/O traits, so connector crates need no tokio dependency. net = ["tokio-runtime", "aimdb-core/connector-session", "tokio/net", "tokio/io-util"] +# `embedded_io_async::{Read, Write, ReadReady}` on the `net` streams, so a +# protocol client written against those traits (mountain-mqtt, embedded-tls) +# runs on a host over `TokioNet::tcp()`. +embedded-io = ["net", "dep:embedded-io-async"] + # Observability features tracing = ["aimdb-core/tracing", "dep:tracing"] observability = ["aimdb-core/observability", "tokio-runtime"] @@ -51,6 +56,10 @@ tokio = { workspace = true, optional = true, features = [ # reader round-trips the receiver through a stored, reused future. tokio-util = { version = "0.7", optional = true, default-features = false } +# `std` supplies `From`, so Tokio's error detail survives +# instead of collapsing to `Other`. +embedded-io-async = { workspace = true, optional = true, features = ["std"] } + # `RuntimeOps::log` forwards to the `log` facade; the binary picks the backend. log = "0.4" diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index 574d6f09..c90d1221 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -114,6 +114,61 @@ impl StreamListener for TokioTcpListener { } } +// `embedded-io-async` by delegation, so a protocol client written against those +// traits (mountain-mqtt, embedded-tls) runs on a host. `ReadReady` is a +// synchronous probe, so it takes the concrete `TcpStream` and its `poll_peek`. +#[cfg(feature = "embedded-io")] +mod embedded_io_impls { + use super::TokioByteStream; + use core::task::{Context, Poll, Waker}; + use embedded_io_async::ErrorKind; + use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadBuf}; + use tokio::net::TcpStream; + + impl embedded_io_async::ErrorType for TokioByteStream { + type Error = ErrorKind; + } + + impl embedded_io_async::Read for TokioByteStream + where + S: tokio::io::AsyncRead + Unpin, + { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.read(buf).await.map_err(|e| e.kind().into()) + } + } + + impl embedded_io_async::Write for TokioByteStream + where + S: tokio::io::AsyncWrite + Unpin, + { + async fn write(&mut self, buf: &[u8]) -> Result { + self.0.write(buf).await.map_err(|e| e.kind().into()) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + self.0.flush().await.map_err(|e| e.kind().into()) + } + } + + impl embedded_io_async::ReadReady for TokioByteStream { + fn read_ready(&mut self) -> Result { + let mut byte = [0u8; 1]; + let mut buf = ReadBuf::new(&mut byte); + // MSG_PEEK leaves the byte queued. `Ok(0)` is EOF, which counts as + // ready: a read returns immediately rather than blocking. + match self + .0 + .poll_peek(&mut Context::from_waker(Waker::noop()), &mut buf) + { + Poll::Ready(Ok(_)) => Ok(true), + Poll::Ready(Err(e)) => Err(e.kind().into()), + Poll::Pending => Ok(false), + } + } + } +} + // =========================================================================== // Datagrams. // =========================================================================== @@ -306,6 +361,65 @@ mod tests { assert_eq!(second.local_addr().unwrap().port(), port); } + /// The probe must not consume what it reports. + #[cfg(feature = "embedded-io")] + #[tokio::test] + async fn the_embedded_io_trio_round_trips_and_probes_without_consuming() { + use embedded_io_async::{Read, ReadReady, Write}; + + let mut listener = TokioNet::listen("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 16]; + let n = Read::read(&mut stream, &mut buf).await.unwrap(); + Write::write(&mut stream, &buf[..n]).await.unwrap(); + Write::flush(&mut stream).await.unwrap(); + }); + + let mut client = TokioNet::tcp().connect("127.0.0.1", port).await.unwrap(); + assert!( + !client.read_ready().unwrap(), + "nothing sent yet, so the probe must not claim readiness" + ); + + Write::write(&mut client, b"ping").await.unwrap(); + Write::flush(&mut client).await.unwrap(); + server.await.unwrap(); + + // The peek must leave the byte queued: the read below is what proves it. + assert!(client.read_ready().unwrap(), "the echo is waiting"); + assert!( + client.read_ready().unwrap(), + "and probing did not consume it" + ); + + let mut buf = [0u8; 16]; + let n = Read::read(&mut client, &mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"ping"); + } + + /// EOF counts as ready: a read returns `Ok(0)` without blocking. + #[cfg(feature = "embedded-io")] + #[tokio::test] + async fn the_readiness_probe_reports_eof_as_ready() { + use embedded_io_async::ReadReady; + + let mut listener = TokioNet::listen("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); + }); + + let mut client = TokioNet::tcp().connect("127.0.0.1", port).await.unwrap(); + server.await.unwrap(); + // Give the FIN a moment to land, then the probe must say "ready". + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(client.read_ready().unwrap()); + } + #[tokio::test] async fn delay_sleeps_without_boxing() { let start = std::time::Instant::now(); From 664e4fedd68dd81096de445d7f24fef47af55db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:21:09 +0000 Subject: [PATCH 09/20] feat(Cargo.toml): enhance defmt dependencies for mountain-mqtt integration --- aimdb-mqtt-connector/Cargo.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 4bd1edb2..08a10e90 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -70,7 +70,12 @@ embassy-tls = [ # where it expands, so without them this crate would emit nothing. tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] -defmt = ["dep:defmt", "aimdb-core/defmt"] +defmt = [ + "dep:defmt", + "aimdb-core/defmt", + "mountain-mqtt?/defmt", + "mountain-mqtt-embassy?/defmt", +] # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an @@ -134,9 +139,8 @@ embassy-net = { version = "0.9.0", optional = true, features = [ mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.2.1", default-features = false, optional = true, features = [ "embedded-io-async", "embedded-hal-async", - "defmt", ] } -mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", optional = true } +mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ From cc65e6320e27ec58c72ee7088fbccc8306233918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:32:50 +0000 Subject: [PATCH 10/20] feat(tests): add TokioNet embedded backend smoke test with reconnect logic --- Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 11 + aimdb-mqtt-connector/tests/tokio_broker.rs | 257 +++++++++++++++++++++ aimdb-tokio-adapter/src/net.rs | 1 + 4 files changed, 273 insertions(+) create mode 100644 aimdb-mqtt-connector/tests/tokio_broker.rs diff --git a/Makefile b/Makefile index bc8241f4..9138efee 100644 --- a/Makefile +++ b/Makefile @@ -233,6 +233,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool @printf "$(YELLOW) → Testing MQTT connector (broker session loop against a fake broker)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker + @printf "$(YELLOW) → Testing MQTT connector (embedded backend over TokioNet, reconnect)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -366,6 +368,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (broker session loop, host)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (embedded backend over TokioNet)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 08a10e90..2c48f7e8 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -77,6 +77,16 @@ defmt = [ "mountain-mqtt-embassy?/defmt", ] +# Internal: the embedded backend's host smoke over `TokioNet::tcp()` +# (`tests/tokio_broker.rs`) — a real TCP socket and a fake broker, no network +# stack. Run with `--features _test-tokio-broker`. +_test-tokio-broker = [ + "embassy-runtime", + "aimdb-embassy-adapter/embassy-time", + "aimdb-embassy-adapter/embassy-sync", + "dep:critical-section", +] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off @@ -175,6 +185,7 @@ aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = fa ] } aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", + "embedded-io", ] } [package.metadata.docs.rs] diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs new file mode 100644 index 00000000..8febd97e --- /dev/null +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -0,0 +1,257 @@ +//! Host smoke for the embedded MQTT backend over `TokioNet::tcp()` +//! (`_test-tokio-broker`). +//! +//! The same session loop the Embassy smoke drives, but over a real TCP socket +//! and a fake broker on the same host — no network stack to stand up. What it +//! adds over that smoke is the reconnect: the broker hangs up after the first +//! SUBACK, and the loop must dial again and re-subscribe. +#![cfg(feature = "_test-tokio-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} + +/// Real wall-clock time; the session loop's delays are `embassy_time`'s until +/// it takes core's `Delay`. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +// --------------------------------------------------------------------------- +// A fake broker: just enough MQTT 5 to complete a session. +// --------------------------------------------------------------------------- + +/// What the broker saw, one entry per accepted connection. +#[derive(Default)] +struct Seen { + connects: usize, + subscribes: Vec>, +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +/// Collect the topics out of a SUBSCRIBE body and answer with a SUBACK +/// granting QoS 1 for each. +fn suback(body: &[u8], topics: &mut Vec) -> Vec { + let packet_id = [body[0], body[1]]; + let mut i = 2; + // Skip the property length varint. + while i < body.len() && body[i] & 0x80 != 0 { + i += 1; + } + i += 1; + + let mut granted = Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + + let mut rest = Vec::new(); + rest.extend_from_slice(&packet_id); + rest.push(0x00); // no properties + rest.extend_from_slice(&granted); + let mut ack = vec![0x90]; + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + ack +} + +/// Serve one connection. `hang_up_after_suback` closes it the moment the +/// subscribe is acknowledged, which is what forces the reconnect. +async fn serve(socket: &mut TcpStream, seen: &Mutex, hang_up_after_suback: bool) { + let mut buf = Vec::new(); + loop { + let Some((first, body)) = read_packet(socket, &mut buf).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK (session present = 0, reason = success, no props) + 1 => { + seen.lock().unwrap().connects += 1; + if socket + .write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]) + .await + .is_err() + { + return; + } + } + // SUBSCRIBE -> SUBACK + 8 => { + let mut topics = Vec::new(); + let ack = suback(&body, &mut topics); + seen.lock().unwrap().subscribes.push(topics); + if socket.write_all(&ack).await.is_err() || hang_up_after_suback { + return; + } + } + // PINGREQ -> PINGRESP + 12 => { + if socket.write_all(&[0xD0, 0x00]).await.is_err() { + return; + } + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +/// Accept forever, hanging up on the first `hang_ups` connections. +async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: usize) { + let mut accepted = 0usize; + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + accepted += 1; + serve(&mut socket, &seen, accepted <= hang_ups).await; + } +} + +// --------------------------------------------------------------------------- +// The test. +// --------------------------------------------------------------------------- + +/// The session loop re-subscribes after the broker hangs up. +/// +/// Losing that is silent: publishes keep working and inbound routing simply +/// stops, so this is the assertion the reconnect loop exists for. +#[tokio::test(flavor = "current_thread")] +async fn the_session_loop_reconnects_and_resubscribes() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + + let broker = fake_broker(listener, seen.clone(), 1); + let until_resubscribed = async { + while seen.lock().unwrap().subscribes.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + _ = until_resubscribed => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {} subscribes", + seen.connects, + seen.subscribes.len() + ); + } + } + + let seen = seen.lock().unwrap(); + assert!( + seen.connects >= 2, + "the loop must redial after the hang-up; saw {} connects", + seen.connects + ); + for (n, topics) in seen.subscribes.iter().enumerate() { + assert!( + topics.iter().any(|t| t == "sensors/temperature"), + "connection {n} did not subscribe the inbound topic; saw {topics:?}" + ); + } +} diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index c90d1221..abd246f7 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -78,6 +78,7 @@ where } /// Dials TCP connections. +#[derive(Clone, Copy, Default)] pub struct TokioTcpDialer; impl StreamDialer for TokioTcpDialer { From fef39fc7ebe6fc54e260fe4783389d9d15078881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:05:06 +0000 Subject: [PATCH 11/20] feat: enhance MQTT connector with session management and event handling - Implemented a new `manager` module to handle per-session broker state, event handling, and message pumping. - Updated `MqttConnector` to support `Delay` and `StreamDialer` traits for embedded systems. - Refactored `MqttSink` and `MqttSource` to use action and event channels directly, removing unnecessary wrappers. - Introduced `ClientDelay` to bridge core's `Delay` with the MQTT client's requirements. - Enhanced the `run_sessions` function to manage MQTT sessions more effectively, ensuring reconnections and resubscriptions. - Updated tests to ensure proper session reconnection and resubscription behavior. - Adjusted Tokio adapter to implement `Delay` for seamless integration with the connector. --- Cargo.lock | 16 +- aimdb-embassy-adapter/src/net.rs | 9 + aimdb-mqtt-connector/Cargo.toml | 20 +- aimdb-mqtt-connector/src/connector.rs | 7 +- aimdb-mqtt-connector/src/embassy_client.rs | 341 +++++++++--------- aimdb-mqtt-connector/src/embassy_tls.rs | 48 ++- aimdb-mqtt-connector/src/lib.rs | 4 + aimdb-mqtt-connector/src/manager.rs | 392 +++++++++++++++++++++ aimdb-mqtt-connector/src/transport.rs | 96 +++-- aimdb-mqtt-connector/tests/tokio_broker.rs | 2 +- aimdb-tokio-adapter/src/net.rs | 8 + 11 files changed, 704 insertions(+), 239 deletions(-) create mode 100644 aimdb-mqtt-connector/src/manager.rs diff --git a/Cargo.lock b/Cargo.lock index 36dbb0a1..bf9f7bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,19 +271,6 @@ dependencies = [ "heapless 0.8.0", ] -[[package]] -name = "aimdb-mountain-mqtt-embassy" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ab4d7bdbef7a5e8a95edda95652887c2de8898a4fe1771345d8791ec127d3c" -dependencies = [ - "aimdb-mountain-mqtt", - "defmt 1.1.1", - "embassy-net", - "embassy-sync", - "embassy-time", -] - [[package]] name = "aimdb-mqtt-connector" version = "0.6.0" @@ -292,7 +279,6 @@ dependencies = [ "aimdb-data-contracts", "aimdb-embassy-adapter", "aimdb-mountain-mqtt", - "aimdb-mountain-mqtt-embassy", "aimdb-tokio-adapter", "async-stream", "critical-section", @@ -303,6 +289,7 @@ dependencies = [ "embassy-sync", "embassy-time", "embassy-time-driver", + "embedded-hal-async", "embedded-io-async 0.7.0", "embedded-tls", "futures", @@ -313,7 +300,6 @@ dependencies = [ "rumqttc", "rustls-native-certs", "serde", - "static_cell", "thiserror 2.0.17", "tokio", "tokio-test", diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 7ac8a5d6..5df10dcc 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -634,6 +634,15 @@ impl EmbassyNet { } } +/// The dialer is also the clock, so a connector generic over it needs no +/// separate handle. +#[cfg(feature = "embassy-time")] +impl aimdb_core::session::Delay for EmbassyTcpDialer { + fn sleep(&self, d: core::time::Duration) -> impl Future + Send { + EmbassyDelay.sleep(d) + } +} + /// [`Delay`](aimdb_core::session::Delay) over `embassy_time::Timer`, which is /// `Send` and allocates nothing. /// diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 2c48f7e8..2ca60497 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -46,11 +46,11 @@ embassy-runtime = [ "embassy-sync", "embassy-net", "mountain-mqtt", - "mountain-mqtt-embassy", - # The `SocketTransport` bridge names these traits in its bounds. + # The `SocketTransport` bridge names these traits in its bounds, and the + # session loop bridges core's `Delay` to the client's `DelayNs`. "dep:embedded-io-async", + "dep:embedded-hal-async", "heapless", - "static_cell", ] # TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 # session over the Embassy TCP socket, pure-Rust certificate verification @@ -68,13 +68,16 @@ embassy-tls = [ # `aimdb_core::__private`, so neither dependency is declared here any more. # The *features* stay: a `#[cfg]` in a `#[macro_export]`ed macro is resolved # where it expands, so without them this crate would emit nothing. +# The session channels use `CriticalSectionRawMutex`, so a std binary must link +# a `critical-section` impl. Off by default: an MCU's HAL already provides one. +critical-section-std-impl = ["dep:critical-section", "critical-section/std"] + tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] defmt = [ "dep:defmt", "aimdb-core/defmt", "mountain-mqtt?/defmt", - "mountain-mqtt-embassy?/defmt", ] # Internal: the embedded backend's host smoke over `TokioNet::tcp()` @@ -84,7 +87,7 @@ _test-tokio-broker = [ "embassy-runtime", "aimdb-embassy-adapter/embassy-time", "aimdb-embassy-adapter/embassy-sync", - "dep:critical-section", + "critical-section-std-impl", ] # Internal: the Embassy broker session loop's host smoke @@ -101,7 +104,7 @@ _test-embassy-broker = [ "embassy-net/medium-ip", "embassy-net/proto-ipv4", "dep:embassy-net-driver-channel", - "dep:critical-section", + "critical-section-std-impl", ] [dependencies] @@ -150,7 +153,6 @@ mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.2.1", default-fe "embedded-io-async", "embedded-hal-async", ] } -mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ @@ -159,17 +161,17 @@ embedded-tls = { version = "0.19", default-features = false, optional = true, fe "p384", ] } embedded-io-async = { workspace = true, optional = true } +embedded-hal-async = { workspace = true, optional = true } rand_core = { version = "0.6", default-features = false, optional = true } # Embedded utilities heapless = { workspace = true, optional = true } -static_cell = { version = "2.0", optional = true } # Optional observability defmt = { workspace = true, optional = true } embassy-net-driver-channel = { version = "0.4.0", optional = true } -critical-section = { version = "1.1", features = ["std"], optional = true } +critical-section = { version = "1.1", optional = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index dc990f12..cf466b13 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -131,7 +131,12 @@ impl ConnectorBuilder for MqttConnector { #[cfg(feature = "embassy-runtime")] impl ConnectorBuilder for MqttConnector> where - D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 33b4f115..39f47394 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -1,20 +1,10 @@ -//! Embassy MQTT client implementation using mountain-mqtt-embassy +//! The `mountain-mqtt` backend: broker session plus the data-plane bridges. //! -//! This module provides production-ready MQTT connectivity for Embassy-based -//! embedded systems using mountain-mqtt-embassy's `run()` function. -//! -//! # Architecture -//! -//! The data-flow (outbound publish, inbound routing) rides core's -//! [`pump_sink`] / [`pump_source`] via the force-`Send` -//! [`EmbassySink`]/[`EmbassySource`] bridges in `aimdb-embassy-adapter`, exactly -//! like the Tokio half rides them. This crate contributes only the -//! transport-specific bits: the broker **manager task** (mountain-mqtt's `run`), -//! the `MqttSink`/`MqttSource` over its action/event channels, and the -//! `MqttOperations`/`FromApplicationMessage` glue. The single `unsafe` block -//! is the [`NetStack`](aimdb_embassy_adapter::connectors::NetStack) -//! construction in [`MqttConnectorBuilder::new`], acknowledging the adapter's -//! single-core executor invariant. +//! Outbound publishes and inbound routing ride core's [`pump_sink`] / +//! [`pump_source`] directly — the session channels are `Sync`, so nothing +//! force-`Send` stands between them and the runner. This module contributes +//! the connector builder, the `MqttSink`/`MqttSource` over those channels, and +//! the `MqttOperations`/`FromApplicationMessage` glue. //! //! # Usage //! @@ -56,19 +46,15 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; -use aimdb_embassy_adapter::connectors::{ - into_box_future, EmbassySink, EmbassySinkRaw, EmbassySource, EmbassySourceRaw, -}; -use embassy_net::Ipv4Address; -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use embassy_sync::channel::{Channel, Receiver, Sender}; +#[cfg(feature = "embassy-tls")] +use aimdb_embassy_adapter::connectors::into_box_future; use embassy_sync::once_lock::OnceLock; -use static_cell::StaticCell; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use mountain_mqtt_embassy::mqtt_manager::{MqttEvent, Settings}; + +use crate::manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] pub use crate::embassy_tls::TlsOptions; @@ -87,10 +73,14 @@ pub(crate) const MAX_PROPERTIES: usize = 16; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; -/// Sender half of the action channel (outbound publishes + subscriptions). -type ActionSender = Sender<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; -/// Receiver half of the event channel (inbound messages from the broker). -type EventReceiver = Receiver<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; +/// What a transport's setup hands back: the two channel ends the pumps ride, +/// plus the tasks that serve them. +type ManagerSetup = (Arc, Arc, Vec); + +/// Outbound publishes and subscriptions: pumps to broker session. +pub(crate) type ActionChannel = crate::manager::ActionChannel; +/// Inbound messages: broker session to pumps. +pub(crate) type EventChannel = crate::manager::EventChannel; /// MQTT actions that can be performed /// @@ -193,9 +183,7 @@ pub enum AimdbMqttEvent { }, } -impl mountain_mqtt_embassy::mqtt_manager::FromApplicationMessage - for AimdbMqttEvent -{ +impl crate::manager::FromApplicationMessage for AimdbMqttEvent { fn from_application_message( message: &mountain_mqtt::packets::publish::ApplicationMessage, ) -> Result { @@ -214,62 +202,68 @@ impl mountain_mqtt_embassy::mqtt_manager::FromApplicationMessage } // =========================================================================== -// Data-plane bridges — ride core's pumps via the adapter's force-`Send` wrappers. +// Data-plane bridges — core's pumps drive these directly. The channels are +// `Sync` (their mutex is `CriticalSectionRawMutex`), so no force-`Send` +// wrapper stands between them and the runner. // =========================================================================== -/// Outbound sink: turns a `pump_sink` publish into an `AimdbMqttAction::Publish` -/// enqueued onto the manager's action channel. Wrapped in -/// [`EmbassySink`] so it drives core's `pump_sink` despite the `!Send` channel. +/// Outbound sink: turns a `pump_sink` publish into an +/// `AimdbMqttAction::Publish` enqueued onto the session's action channel. struct MqttSink { - sender: ActionSender, + actions: Arc, } -impl EmbassySinkRaw for MqttSink { - async fn publish( +impl aimdb_core::transport::Connector for MqttSink { + fn publish( &self, - destination: String, - config: ConnectorConfig, - payload: Vec, - ) -> Result<(), PublishError> { + destination: &str, + config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { // `qos`/`retain` arrive via the URL query (passed through in // `protocol_options`); default to QoS 1 (legacy behaviour), no retain. - let qos = opt_u8(&config, "qos") + let qos = opt_u8(config, "qos") .map(map_qos) .unwrap_or(QualityOfService::Qos1); - let retain = opt_bool(&config, "retain").unwrap_or(false); + let retain = opt_bool(config, "retain").unwrap_or(false); + let topic = destination.to_string(); + let payload = payload.to_vec(); - self.sender - .send(AimdbMqttAction::Publish { - topic: destination, - payload, - qos, - retain, - }) - .await; - Ok(()) + Box::pin(async move { + self.actions + .send(AimdbMqttAction::Publish { + topic, + payload, + qos, + retain, + }) + .await; + Ok(()) + }) } } -/// Inbound source: drains the manager's event channel, yielding each received -/// message as `(topic, payload)`. Wrapped in [`EmbassySource`] so it drives -/// core's `pump_source` (which fans out to the matching record producers). +/// Inbound source: drains the session's event channel, yielding each received +/// message as `(topic, payload)` for `pump_source` to fan out. struct MqttSource { - receiver: EventReceiver, + events: Arc, } -impl EmbassySourceRaw for MqttSource { - async fn next(&mut self) -> Option<(String, Payload)> { - loop { - match self.receiver.receive().await { - MqttEvent::ApplicationEvent { - event: AimdbMqttEvent::MessageReceived { topic, payload }, - .. - } => return Some((topic, Payload::from(payload))), - // Connection lifecycle events (Connected/Disconnected/…) carry no - // record data; skip and keep draining. - _ => continue, +impl aimdb_core::session::Source for MqttSource { + fn next(&mut self) -> aimdb_core::BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { + loop { + match self.events.receive().await { + MqttEvent::ApplicationEvent { + event: AimdbMqttEvent::MessageReceived { topic, payload }, + .. + } => return Some((topic, Payload::from(payload))), + // Connection lifecycle events carry no record data; skip + // and keep draining. + _ => continue, + } } - } + }) } } @@ -405,7 +399,12 @@ impl MqttConnectorBuilder { /// runtime beyond the dyn-safe capabilities the database already holds. impl ConnectorBuilder for MqttConnectorBuilder where - D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( @@ -413,9 +412,6 @@ where db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { - // No `.await` in this body, so the future is `Send` without a wrapper: the - // `!Send` channel ends are immediately moved into the force-`Send` - // `EmbassySink`/`EmbassySource`/manager-task and never held across a suspend. Box::pin(async move { // Inbound topics to subscribe to (the manager sends `Subscribe` for each). let inbound_routes = db.collect_inbound_routes("mqtt"); @@ -436,12 +432,19 @@ where // Broker manager task(s) + the channel ends for the pumps. // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] - let (action_sender, event_receiver, manager_tasks) = match &self.transport { + let (actions, events, manager_tasks) = match &self.transport { Transport::Tls(stack, slot) if broker.tls => { let options = slot.take().ok_or_else(|| { build_err("TLS materials already taken; build() ran twice") })?; - setup_tls_manager(&broker, options, connection_settings, *stack, topics)? + setup_tls_manager( + &broker, + options, + connection_settings, + *stack, + topics, + db.runtime_ops(), + )? } Transport::Tls(..) => { return Err(build_err(".tls(...) requires an mqtts:// broker URL")) @@ -449,38 +452,35 @@ where Transport::Plain(_) if broker.tls => { return Err(build_err("mqtts:// broker URLs require .tls(...)")) } - Transport::Plain(dialer) => { - setup_manager(&broker, connection_settings, dialer.clone(), topics)? - } + Transport::Plain(dialer) => setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )?, }; #[cfg(not(feature = "embassy-tls"))] - let (action_sender, event_receiver, manager_tasks) = { + let (actions, events, manager_tasks) = { if broker.tls { return Err(build_err( "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", )); } let Transport::Plain(dialer) = &self.transport; - setup_manager(&broker, connection_settings, dialer.clone(), topics)? + setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )? }; // Outbound publishes + inbound routing ride core's pumps. - let mut futures = pump_sink( - db, - "mqtt", - Arc::new(EmbassySink(MqttSink { - sender: action_sender, - })), - ); - futures.extend(pump_source( - db, - "mqtt", - EmbassySource(MqttSource { - receiver: event_receiver, - }), - )); - // The broker manager protocol loop (plus the SNTP time-source - // task on the TLS path), force-`Send` via the adapter. + let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); + futures.extend(pump_source(db, "mqtt", MqttSource { events })); + // The broker session loop, plus the SNTP time source on TLS. futures.extend(manager_tasks); Ok(futures) @@ -551,33 +551,8 @@ fn static_connection_settings( } } -/// Sender half of the event channel (used by the broker manager tasks). -pub(crate) type EventSender = - Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; -/// Receiver half of the action channel (drained by the broker manager tasks). -pub(crate) type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; - -/// Initialise the static action/event channels shared by both transports -/// (one MQTT connector per firmware — `StaticCell` enforces single init). -fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) { - static ACTION_CHANNEL: StaticCell> = - StaticCell::new(); - static EVENT_CHANNEL: StaticCell< - Channel, CHANNEL_SIZE>, - > = StaticCell::new(); - let action_channel = ACTION_CHANNEL.init(Channel::new()); - let event_channel = EVENT_CHANNEL.init(Channel::new()); - - ( - action_channel.sender(), - action_channel.receiver(), - event_channel.sender(), - event_channel.receiver(), - ) -} - -/// Set up the plain-TCP broker session loop, returning the action sender -/// (outbound), the event receiver (inbound), and the task future. The loop +/// Set up the plain-TCP broker session loop, returning the action channel +/// (outbound), the event channel (inbound), and the task future. The loop /// re-subscribes the inbound topics on every connection, so routing survives /// reconnects. Synchronous — no `.await` — so the caller's `build` future /// stays `Send`. @@ -586,43 +561,57 @@ fn setup_manager( connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> + runtime: Arc, +) -> Result where - D: aimdb_core::session::StreamDialer + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { - let broker_ip = Ipv4Addr::from_str(&broker.host).map_err(|_| { + Ipv4Addr::from_str(&broker.host).map_err(|_| { build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") })?; - let octets = broker_ip.octets(); - let broker_addr = Ipv4Address::new(octets[0], octets[1], octets[2], octets[3]); - let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); - let settings = Settings::new(broker_addr, broker.port); - - // The transport the session loop dials each cycle. Sockets come from the - // adapter; `run_with_subscriptions` is gone because it binds the stack and - // cannot take one. + // The transport the session loop dials each cycle, and the clock it runs + // on — both come from the caller-supplied dialer. + let delay = dialer.clone(); let transport = crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); - let manager_task = into_box_future(async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT background task starting"); - - crate::transport::run_sessions( - transport, - topics, - connection_settings, - settings, - event_sender, - action_receiver, - ) - .await + // SAFETY: every value the session holds is `Send` — `StreamDialer` + // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` + // and the state cell is a blocking mutex. See `SendSession`. + let manager_task: EmbassyBoxFuture = Box::pin(unsafe { + crate::transport::SendSession::new({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT background task starting"); + + crate::transport::run_sessions( + transport, + topics, + connection_settings, + Settings::default(), + events, + actions, + delay, + runtime, + ) + .await + } + }) }); - Ok((action_sender, event_receiver, alloc::vec![manager_task])) + Ok((actions, events, alloc::vec![manager_task])) } /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source @@ -635,7 +624,8 @@ fn setup_tls_manager( connection_settings: ConnectionSettings<'static>, stack: aimdb_embassy_adapter::connectors::NetStack, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { + runtime: Arc, +) -> Result { match host_ip_literal(&broker.host) { Some(core::net::IpAddr::V6(_)) => { return Err(build_err( @@ -658,32 +648,37 @@ fn setup_tls_manager( )); } - let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); - // `Settings` supplies the session cadence and port; its address field is - // unused on the TLS path (the host is resolved per attempt instead). - let settings = Settings::new(Ipv4Address::UNSPECIFIED, broker.port); let network = stack.get(); let host = broker.host.clone(); + let port = broker.port; let sntp_server = options.sntp_server; - let manager_task = into_box_future(async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS background task starting"); - - #[allow(unreachable_code)] - { - let _: () = run_tls( - *network, - options, - host, - topics, - connection_settings, - settings, - event_sender, - action_receiver, - ) - .await; + let manager_task = into_box_future({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS background task starting"); + + #[allow(unreachable_code)] + { + let _: () = run_tls( + *network, + options, + host, + port, + topics, + connection_settings, + Settings::default(), + events, + actions, + runtime, + ) + .await; + } } }); let sntp_task = into_box_future(async move { @@ -693,11 +688,7 @@ fn setup_tls_manager( } }); - Ok(( - action_sender, - event_receiver, - alloc::vec![manager_task, sntp_task], - )) + Ok((actions, events, alloc::vec![manager_task, sntp_task])) } /// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 14038ca6..58cdaa6b 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -19,11 +19,10 @@ use alloc::vec::Vec; use core::cell::RefCell; use core::net::IpAddr; +use alloc::sync::Arc; use embassy_net::dns::DnsQueryType; use embassy_net::tcp::TcpSocket; use embassy_net::{IpAddress, Stack}; -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use embassy_sync::channel::{Receiver, Sender}; use embassy_time::{Delay, Timer}; use embedded_tls::pki::CertVerifier; @@ -34,15 +33,15 @@ use embedded_tls::{ use embedded_io_async::Write as _; +use crate::manager::{ + handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, Settings, +}; use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::embedded_hal_async::DelayEmbedded; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use mountain_mqtt_embassy::mqtt_manager::{ - handle_messages, ChannelEventHandler, MqttEvent, Settings, State, -}; use crate::embassy_client::{ AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, @@ -241,11 +240,13 @@ pub(crate) async fn run_tls( stack: Stack<'static>, options: TlsOptions, host: String, + port: u16, topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - event_sender: Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, - mut action_receiver: Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>, + events: Arc, + actions: Arc, + runtime: Arc, ) -> ! { let TlsOptions { rng, @@ -287,7 +288,8 @@ pub(crate) async fn run_tls( "MQTT-TLS: DNS lookup for {} failed, will retry", host.as_str() ); - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay) + .await; continue; } }; @@ -302,12 +304,12 @@ pub(crate) async fn run_tls( address, settings.port ); - if let Err(e) = socket.connect((address, settings.port)).await { + if let Err(e) = socket.connect((address, port)).await { #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: socket connect error, will retry: {:?}", e); #[cfg(not(feature = "defmt"))] let _ = e; - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; continue; } @@ -328,7 +330,7 @@ pub(crate) async fn run_tls( ); #[cfg(not(feature = "defmt"))] let _ = e; - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; continue; } #[cfg(feature = "defmt")] @@ -342,7 +344,7 @@ pub(crate) async fn run_tls( let delay = DelayEmbedded::new(Delay); let timeout_millis = settings.response_timeout.as_millis() as u32; - let state: RefCell> = RefCell::new(State::new()); + let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; @@ -353,7 +355,7 @@ pub(crate) async fn run_tls( AimdbMqttEvent, MAX_PROPERTIES, CHANNEL_SIZE, - > = ChannelEventHandler::new(connection_id, &event_sender, &state); + > = ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); let mut client = ClientNoQueue::new( connection, @@ -369,15 +371,17 @@ pub(crate) async fn run_tls( &state, &connection_settings, &subscribe_topics, - &event_sender, - &mut action_receiver, + &events, + &actions, &settings, + &EmbassyCoreDelay, + runtime.as_ref(), ) .await { #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: session errored: {:?}", error); - event_sender + events .send(MqttEvent::Disconnected { connection_id, error, @@ -385,7 +389,17 @@ pub(crate) async fn run_tls( .await; } - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; + } +} + +/// The TLS path keeps `embassy_time` for its own waits, so it supplies core's +/// [`Delay`](aimdb_core::session::Delay) to the shared message pump. +struct EmbassyCoreDelay; + +impl aimdb_core::session::Delay for EmbassyCoreDelay { + fn sleep(&self, d: core::time::Duration) -> impl core::future::Future + Send { + Timer::after(embassy_time::Duration::from_micros(d.as_micros() as u64)) } } diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 7e61adfc..77bea841 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -103,6 +103,10 @@ pub mod connector; #[cfg(feature = "embassy-runtime")] pub mod transport; +// Session state, event handler and message pump for the `Embedded` backend. +#[cfg(feature = "embassy-runtime")] +pub mod manager; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; diff --git a/aimdb-mqtt-connector/src/manager.rs b/aimdb-mqtt-connector/src/manager.rs new file mode 100644 index 00000000..7dc4df60 --- /dev/null +++ b/aimdb-mqtt-connector/src/manager.rs @@ -0,0 +1,392 @@ +//! Per-session broker state, the event handler that feeds the event channel, +//! and the pump that keeps one connection alive. +//! +//! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and +//! source are plain `Connector`/`Source` impls with no force-`Send` wrapper. +//! Time comes from core's [`Delay`] and the runtime's monotonic clock, so the +//! pump names no executor. + +use core::cell::RefCell; +use core::time::Duration; + +use aimdb_core::session::Delay; +use aimdb_core::RuntimeOps; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; +use embassy_sync::channel::Channel; +use mountain_mqtt::client::{ + Client, ClientError, ClientReceivedEvent, ConnectionSettings, EventHandler, EventHandlerError, +}; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; +use mountain_mqtt::packets::publish::ApplicationMessage; + +/// The event channel: broker session to `pump_source`. +pub(crate) type EventChannel = Channel, Q>; + +/// The action channel: `pump_sink` to broker session. +pub(crate) type ActionChannel = Channel; + +/// Monotonic milliseconds. Only differences are meaningful. +pub(crate) fn now_ms(runtime: &dyn RuntimeOps) -> u64 { + runtime.now_nanos() / 1_000_000 +} + +/// Convert a received [`ApplicationMessage`] into an application event. +pub trait FromApplicationMessage: Sized { + /// Build the event, or reject the message. + fn from_application_message(message: &ApplicationMessage

) + -> Result; +} + +/// Why a session ended. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Error { + /// The MQTT client reported an error. + Client(ClientError), + /// No acknowledgement arrived within `connection_event_max_interval`. + MqttServerUnresponsive, +} + +impl From for Error { + fn from(value: ClientError) -> Self { + Self::Client(value) + } +} + +#[cfg(feature = "defmt")] +impl defmt::Format for Error { + fn format(&self, f: defmt::Formatter) { + match self { + Error::Client(e) => defmt::write!(f, "Client({})", e), + Error::MqttServerUnresponsive => defmt::write!(f, "MqttServerUnresponsive"), + } + } +} + +/// Session cadence: how often to ping, how long to wait, when to give up. +#[derive(Debug, Clone, Copy)] +pub struct Settings { + /// Minimum interval between pings. + pub ping_interval: Duration, + /// Maximum silence from the broker before the session is declared dead. + pub connection_event_max_interval: Duration, + /// Wait between a failed session and the next dial. + pub reconnection_delay: Duration, + /// Delay applied to each pump iteration. + pub poll_interval: Duration, + /// Maximum round-trip wait for a packet that expects a response. + pub response_timeout: Duration, + /// How long a connection must hold before it counts as stable. + pub stabilisation_interval: Duration, +} + +impl Default for Settings { + fn default() -> Self { + Self { + ping_interval: Duration::from_millis(2_000), + connection_event_max_interval: Duration::from_millis(10_000), + reconnection_delay: Duration::from_millis(2_000), + poll_interval: Duration::from_millis(10), + response_timeout: Duration::from_millis(5_000), + stabilisation_interval: Duration::from_millis(5_000), + } + } +} + +/// What the session reports to the event channel. +#[derive(Debug, Clone)] +pub enum MqttEvent { + /// An application message arrived and converted to `E`. + ApplicationEvent { + /// The connection it arrived on. + connection_id: ConnectionId, + /// The converted message. + event: E, + }, + /// A new connection was established. + Connected { + /// The new connection. + connection_id: ConnectionId, + }, + /// A connection held for `stabilisation_interval`. + ConnectionStable { + /// The connection that stabilised. + connection_id: ConnectionId, + }, + /// A connection ended; the next one is dialled automatically. + Disconnected { + /// The connection that ended. + connection_id: ConnectionId, + /// Why it ended. + error: Error, + }, + /// A subscription was granted below the QoS requested. + SubscriptionGrantedBelowMaximumQos { + /// The connection it was granted on. + connection_id: ConnectionId, + /// What the broker granted. + granted_qos: QualityOfService, + /// What was asked for. + maximum_qos: QualityOfService, + }, + /// A published message reached no subscriber. + PublishedMessageHadNoMatchingSubscribers { + /// The connection it was published on. + connection_id: ConnectionId, + }, + /// An unsubscribe named a subscription the broker did not hold. + NoSubscriptionExisted { + /// The connection it was sent on. + connection_id: ConnectionId, + }, +} + +/// Per-connection bookkeeping, shared between the pump and its event handler. +/// +/// The blocking mutex is what makes `&SessionState` `Send`: a bare `RefCell` +/// is not `Sync`, so a session future holding one could not be boxed as the +/// runner requires. Every lock is a straight-line read or write, never held +/// across an `await`. +pub(crate) struct SessionState { + inner: BlockingMutex>>, +} + +struct Inner { + /// When the broker last proved it was alive. + last_connection_event_ms: u64, + /// An action whose `perform` failed, to retry on the next iteration. + pending_action: Option, +} + +impl SessionState { + /// Fresh state for a new connection; the liveness window starts now. + pub(crate) fn new(now_ms: u64) -> Self { + Self { + inner: BlockingMutex::new(RefCell::new(Inner { + last_connection_event_ms: now_ms, + pending_action: None, + })), + } + } + + fn record_connection_event(&self, now_ms: u64) { + self.inner + .lock(|state| state.borrow_mut().last_connection_event_ms = now_ms); + } + + fn last_connection_event_ms(&self) -> u64 { + self.inner + .lock(|state| state.borrow().last_connection_event_ms) + } + + fn take_pending_action(&self) -> Option { + self.inner + .lock(|state| state.borrow_mut().pending_action.take()) + } + + fn set_pending_action(&self, action: A) { + self.inner + .lock(|state| state.borrow_mut().pending_action = Some(action)); + } +} + +/// Forwards received MQTT events onto the event channel and refreshes the +/// liveness timestamp on every broker acknowledgement. +pub(crate) struct ChannelEventHandler<'a, A, E, const P: usize, const Q: usize> +where + E: FromApplicationMessage

+ Clone, +{ + connection_id: ConnectionId, + events: &'a EventChannel, + state: &'a SessionState, + runtime: &'a dyn RuntimeOps, +} + +impl<'a, A, E, const P: usize, const Q: usize> ChannelEventHandler<'a, A, E, P, Q> +where + E: FromApplicationMessage

+ Clone, +{ + pub(crate) fn new( + connection_id: ConnectionId, + events: &'a EventChannel, + state: &'a SessionState, + runtime: &'a dyn RuntimeOps, + ) -> Self { + Self { + connection_id, + events, + state, + runtime, + } + } +} + +impl EventHandler

for ChannelEventHandler<'_, A, E, P, Q> +where + E: FromApplicationMessage

+ Clone, +{ + async fn handle_event( + &mut self, + event: ClientReceivedEvent<'_, P>, + ) -> Result<(), EventHandlerError> { + let connection_id = self.connection_id; + match event { + ClientReceivedEvent::ApplicationMessage(message) => { + let event = E::from_application_message(&message)?; + self.events + .send(MqttEvent::ApplicationEvent { + connection_id, + event, + }) + .await; + } + ClientReceivedEvent::Ack => { + self.state.record_connection_event(now_ms(self.runtime)); + } + ClientReceivedEvent::SubscriptionGrantedBelowMaximumQos { + granted_qos, + maximum_qos, + } => { + self.events + .send(MqttEvent::SubscriptionGrantedBelowMaximumQos { + connection_id, + granted_qos, + maximum_qos, + }) + .await + } + ClientReceivedEvent::PublishedMessageHadNoMatchingSubscribers => { + self.events + .send(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) + .await + } + ClientReceivedEvent::NoSubscriptionExisted => { + self.events + .send(MqttEvent::NoSubscriptionExisted { connection_id }) + .await + } + } + Ok(()) + } +} + +/// Perform one action, parking it for retry if the client rejects it. +async fn try_action<'a, A, C>( + connection_id: ConnectionId, + client: &mut C, + state: &SessionState, + connection_settings: &ConnectionSettings<'static>, + mut action: A, + is_retry: bool, +) -> Result<(), ClientError> +where + C: Client<'a>, + A: MqttOperations + Clone, +{ + if let Err(e) = action + .perform( + client, + connection_settings.client_id(), + connection_id, + is_retry, + ) + .await + { + state.set_pending_action(action); + return Err(e); + } + Ok(()) +} + +/// Drive one MQTT session until an error ends it: connect, subscribe +/// `subscribe_topics`, then keep it alive while dispatching actions and +/// forwarding events. +/// +/// `subscribe_topics` is re-sent on every call, i.e. once per connection, so +/// inbound routing survives a reconnect. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn handle_messages<'a, A, C, E, D, const P: usize, const Q: usize>( + connection_id: ConnectionId, + client: &mut C, + state: &SessionState, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Result<(), Error> +where + C: Client<'a>, + A: MqttOperations + Clone, + E: FromApplicationMessage

+ Clone, + D: Delay, +{ + client.connect(connection_settings).await?; + events.send(MqttEvent::Connected { connection_id }).await; + + for (topic, qos) in subscribe_topics { + client.subscribe(topic, *qos).await?; + } + + let ping_interval = settings.ping_interval.as_millis() as u64; + let stabilisation_interval = settings.stabilisation_interval.as_millis() as u64; + let max_silence = settings.connection_event_max_interval.as_millis() as u64; + + let mut connected_at = Some(now_ms(runtime)); + let mut last_ping_ms = now_ms(runtime); + + loop { + delay.sleep(settings.poll_interval).await; + let now = now_ms(runtime); + + if now.saturating_sub(last_ping_ms) > ping_interval { + last_ping_ms = now; + client.send_ping().await?; + } + + if let Some(since) = connected_at { + if now.saturating_sub(since) > stabilisation_interval { + connected_at = None; + events + .send(MqttEvent::ConnectionStable { connection_id }) + .await; + } + } + + if now.saturating_sub(state.last_connection_event_ms()) > max_silence { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: broker unresponsive"); + return Err(Error::MqttServerUnresponsive); + } + + // Poll with no delay while packets are waiting. + while client.poll(false).await? {} + + if let Some(action) = state.take_pending_action() { + try_action( + connection_id, + client, + state, + connection_settings, + action, + true, + ) + .await?; + } + + while let Ok(action) = actions.try_receive() { + try_action( + connection_id, + client, + state, + connection_settings, + action, + false, + ) + .await?; + } + } +} diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs index 2d5d4bed..47467117 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/transport.rs @@ -70,32 +70,83 @@ where } } +/// Bridges core's [`Delay`](aimdb_core::session::Delay) to the `DelayNs` the +/// MQTT client wants, so the client's timeouts run on the adapter's clock. +pub(crate) struct ClientDelay<'a, D>(pub(crate) &'a D); + +impl embedded_hal_async::delay::DelayNs for ClientDelay<'_, D> +where + D: aimdb_core::session::Delay, +{ + async fn delay_ns(&mut self, ns: u32) { + self.0 + .sleep(core::time::Duration::from_nanos(u64::from(ns))) + .await + } +} + +/// Asserts that a broker session future is `Send`. +/// +/// Everything the session holds is `Send`: [`StreamDialer`] guarantees +/// `Stream: Send`, the channels use `CriticalSectionRawMutex`, and the state +/// cell is a blocking mutex. What the compiler cannot see through is +/// `embedded-io-async` — its traits put no `Send` bound on their futures, and +/// the loop reaches them through a generic transport, so naming the bound needs +/// return-type notation, still unstable on the pinned toolchain. +/// +/// This is weaker than an executor assumption, not stronger: it rests on a +/// trait guarantee, so it holds under a preemptive scheduler too. +pub(crate) struct SendSession(F); + +// SAFETY: upheld by the caller of `SendSession::new`. +unsafe impl Send for SendSession {} + +impl SendSession { + /// # Safety + /// + /// Every value `f` holds across a suspend point must actually be `Send`. + pub(crate) unsafe fn new(f: F) -> Self { + Self(f) + } +} + +impl Future for SendSession { + type Output = F::Output; + + fn poll( + self: core::pin::Pin<&mut Self>, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + // SAFETY: a transparent projection; `SendSession` is never moved out of. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } +} + /// The broker session loop: connect, run MQTT until the session ends, wait, /// repeat. Never returns. /// -/// One implementation for every transport. `handle_messages` re-subscribes -/// `subscribe_topics` on each connection, so inbound routing survives a -/// reconnect — the property `run_with_subscriptions` used to provide, now -/// explicit here because injecting a transport means giving that helper up. +/// One implementation for every transport. The manager re-subscribes +/// `topics` on each connection, so inbound routing survives a reconnect. #[allow(clippy::too_many_arguments)] -pub(crate) async fn run_sessions( +pub(crate) async fn run_sessions( transport: T, topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, - settings: mountain_mqtt_embassy::mqtt_manager::Settings, - event_sender: crate::embassy_client::EventSender, - mut action_receiver: crate::embassy_client::ActionReceiver, + settings: crate::manager::Settings, + events: alloc::sync::Arc, + actions: alloc::sync::Arc, + delay: D, + runtime: alloc::sync::Arc, ) -> ! where T: BrokerTransport, + D: aimdb_core::session::Delay, { - use core::cell::RefCell; use mountain_mqtt::client::ClientNoQueue; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use mountain_mqtt_embassy::mqtt_manager::{ - handle_messages, ChannelEventHandler, MqttEvent, State, - }; + + use crate::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -112,21 +163,22 @@ where Err(_e) => { #[cfg(feature = "defmt")] defmt::warn!("MQTT: connect failed, will retry"); - embassy_time::Timer::after(settings.reconnection_delay).await; + delay.sleep(settings.reconnection_delay).await; continue; } }; - let state: RefCell> = - RefCell::new(State::new()); + let state: SessionState = + SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler = ChannelEventHandler::new(connection_id, &event_sender, &state); + let event_handler = + ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); let mut client = ClientNoQueue::new( connection, &mut mqtt_buffer, - mountain_mqtt::embedded_hal_async::DelayEmbedded::new(embassy_time::Delay), + mountain_mqtt::embedded_hal_async::DelayEmbedded::new(ClientDelay(&delay)), settings.response_timeout.as_millis() as u32, event_handler, ); @@ -137,15 +189,17 @@ where &state, &connection_settings, &subscribe_topics, - &event_sender, - &mut action_receiver, + &events, + &actions, &settings, + &delay, + runtime.as_ref(), ) .await { #[cfg(feature = "defmt")] defmt::warn!("MQTT: session errored: {:?}", error); - event_sender + events .send(MqttEvent::Disconnected { connection_id, error, @@ -153,6 +207,6 @@ where .await; } - embassy_time::Timer::after(settings.reconnection_delay).await; + delay.sleep(settings.reconnection_delay).await; } } diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 8febd97e..770b6412 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -189,7 +189,7 @@ async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: us /// /// Losing that is silent: publishes keep working and inbound routing simply /// stops, so this is the assertion the reconnect loop exists for. -#[tokio::test(flavor = "current_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn the_session_loop_reconnects_and_resubscribes() { use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index abd246f7..f1894899 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -92,6 +92,14 @@ impl StreamDialer for TokioTcpDialer { } } +/// The dialer is also the clock, so a connector generic over it needs no +/// separate handle. +impl Delay for TokioTcpDialer { + fn sleep(&self, d: std::time::Duration) -> impl std::future::Future + Send { + TokioDelay.sleep(d) + } +} + /// Accepts TCP connections. pub struct TokioTcpListener(TcpListener); From ba9c435dd58b14e073fcb4adce3cf545d6dbff95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:25:16 +0000 Subject: [PATCH 12/20] feat(tests): add backend parity test for MQTT connectors and enhance test coverage --- Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 4 + aimdb-mqtt-connector/src/embassy_client.rs | 25 +- aimdb-mqtt-connector/tests/backend_parity.rs | 182 +++++++++++ aimdb-mqtt-connector/tests/common/mod.rs | 294 ++++++++++++++++++ aimdb-mqtt-connector/tests/tokio_broker.rs | 301 ++++++++++--------- 6 files changed, 656 insertions(+), 154 deletions(-) create mode 100644 aimdb-mqtt-connector/tests/backend_parity.rs create mode 100644 aimdb-mqtt-connector/tests/common/mod.rs diff --git a/Makefile b/Makefile index 9138efee..b218aed3 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker @printf "$(YELLOW) → Testing MQTT connector (embedded backend over TokioNet, reconnect)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker + @printf "$(YELLOW) → Testing MQTT connector (both backends, one broker, one process)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -370,6 +372,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embedded backend over TokioNet)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (backend parity)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 2ca60497..911e6a58 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -90,6 +90,10 @@ _test-tokio-broker = [ "critical-section-std-impl", ] +# Internal: both backends against one fake broker in one process +# (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. +_test-backend-parity = ["_test-tokio-broker", "tokio-runtime"] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 39f47394..aa00fa59 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -48,7 +48,6 @@ use core::str::FromStr; #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; -use embassy_sync::once_lock::OnceLock; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; @@ -527,25 +526,23 @@ fn parse_broker_url(broker_url: &str) -> Result }) } -/// Build the `ConnectionSettings<'static>` for MQTT CONNECT, parking the -/// identity strings in statics for the `'static` lifetime requirement. +/// Build the `ConnectionSettings<'static>` for MQTT CONNECT. +/// +/// The identity strings are leaked to reach `'static`: one small, bounded leak +/// per connector at build. A shared cell would be smaller but would hand every +/// connector after the first the identity of the first. fn static_connection_settings( client_id: &str, credentials: Option<&(String, String)>, ) -> ConnectionSettings<'static> { - static CLIENT_ID_STORAGE: OnceLock = OnceLock::new(); - static CREDENTIALS_STORAGE: OnceLock<(String, String)> = OnceLock::new(); + fn leak(s: &str) -> &'static str { + Box::leak(s.to_string().into_boxed_str()) + } - let client_id: &'static str = CLIENT_ID_STORAGE.get_or_init(|| client_id.to_string()); + let client_id = leak(client_id); match credentials { - Some(credentials) => { - let credentials: &'static (String, String) = - CREDENTIALS_STORAGE.get_or_init(|| credentials.clone()); - ConnectionSettings::authenticated( - client_id, - credentials.0.as_str(), - credentials.1.as_bytes(), - ) + Some((username, password)) => { + ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) } None => ConnectionSettings::unauthenticated(client_id), } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs new file mode 100644 index 00000000..e7fa2ec5 --- /dev/null +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -0,0 +1,182 @@ +//! Both backends against the same broker, in one process +//! (`_test-backend-parity`). +//! +//! `Native` is `rumqttc` over MQTT 3.1.1; `Embedded` is `mountain-mqtt` over +//! MQTT 5 and `TokioNet::tcp()`. The point is that the two are interchangeable +//! from a record's point of view: same link URLs, same payloads on the wire. +#![cfg(feature = "_test-backend-parity")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::AimDbBuilder; +use aimdb_mqtt_connector::connector::{Embedded, Native}; +use aimdb_mqtt_connector::MqttConnector; +use aimdb_tokio_adapter::net::TokioNet; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +mod common; +use common::{fake_broker_concurrent, Seen}; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} + +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +const INBOUND: &str = "mqtt://parity/inbound"; +const OUTBOUND: &str = "mqtt://parity/outbound"; + +/// One database with one inbound and one outbound record, so both backends are +/// exercised through identical registrations. +fn build_db( + connector: impl aimdb_core::ConnectorBuilder + 'static, + value: u64, +) -> impl std::future::Future { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("inbound", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(INBOUND) + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + builder.configure::("outbound", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + loop { + producer.produce(value); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .link_to(OUTBOUND) + .with_serializer(|_ctx, v: &u64| Ok(v.to_string().into_bytes())) + .finish(); + }); + + async move { builder.build().await.expect("build db") } +} + +/// Both backends complete a session against the same broker at the same time, +/// and a record round-trips through each. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn both_backends_round_trip_against_one_broker() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + // The turbofish is what disambiguates the two `new`s while both backends + // are compiled in. + let native = MqttConnector::::new(url.clone()).with_client_id("parity-native"); + let embedded = MqttConnector::::new(url) + .transport(TokioNet::tcp()) + .with_client_id("parity-embedded"); + + let (native_db, native_runner) = build_db(native, 1).await; + let (embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let mut native_in = native_db + .consumer::("inbound") + .expect("native consumer") + .subscribe(); + let mut embedded_in = embedded_db + .consumer::("inbound") + .expect("embedded consumer") + .subscribe(); + + let broker = fake_broker_concurrent(listener, seen.clone(), Some(("parity/inbound", b"7"))); + let seen_for_wait = seen.clone(); + + let (native_value, embedded_value) = tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + values = async { + let values = ( + native_in.recv().await.expect("native inbound"), + embedded_in.recv().await.expect("embedded inbound"), + ); + // Both outbound links must land before the assertions below. + while seen_for_wait.lock().unwrap().published.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + values + } => values, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {:?} subscribed, {} published", + seen.connects, + seen.subscribed_topics(), + seen.published.len() + ); + } + }; + + assert_eq!(native_value, 7, "the broker's PUBLISH must reach Native"); + assert_eq!( + embedded_value, 7, + "the broker's PUBLISH must reach Embedded" + ); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.connects, 2, "both backends must connect"); + assert_eq!( + seen.subscribed_topics() + .iter() + .filter(|t| **t == "parity/inbound") + .count(), + 2, + "both backends must subscribe the inbound topic" + ); + + // Same record, same serializer, same bytes — whichever backend carried it. + let mut payloads: Vec<&[u8]> = seen + .published + .iter() + .filter(|(topic, _)| topic == "parity/outbound") + .map(|(_, payload)| payload.as_slice()) + .collect(); + payloads.sort_unstable(); + payloads.dedup(); + assert_eq!( + payloads, + vec![b"1".as_slice(), b"2".as_slice()], + "each backend must publish its own record's bytes" + ); +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs new file mode 100644 index 00000000..f79dcab8 --- /dev/null +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -0,0 +1,294 @@ +//! A fake MQTT broker over a real TCP socket, speaking just enough of both +//! dialects to complete a session: 3.1.1 for `rumqttc`, 5 for `mountain-mqtt`. +//! +//! The version is read off the CONNECT packet, so one broker serves both +//! backends and a parity test needs only one listener. +//! +//! Compiled into each test binary, so not every item is used by all of them. +#![allow(dead_code)] + +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// What the broker saw, accumulated across every connection. +#[derive(Default)] +pub struct Seen { + pub connects: usize, + pub client_ids: Vec, + pub subscribes: Vec>, + pub published: Vec<(String, Vec)>, +} + +impl Seen { + /// Every topic subscribed on any connection. + pub fn subscribed_topics(&self) -> Vec<&str> { + self.subscribes + .iter() + .flatten() + .map(String::as_str) + .collect() + } +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +/// Step `i` past a varint. +fn skip_varint(body: &[u8], i: &mut usize) { + while *i < body.len() && body[*i] & 0x80 != 0 { + *i += 1; + } + *i += 1; +} + +/// The protocol level a CONNECT declares: 4 is 3.1.1, 5 is MQTT 5. +fn is_v5(body: &[u8]) -> bool { + body.get(6).is_some_and(|level| *level >= 5) +} + +/// The client id a CONNECT carries. It opens the payload, which follows the +/// 10-byte variable header plus, on MQTT 5, a property block. +fn connect_client_id(body: &[u8], v5: bool) -> Option { + let mut i = 10; + if v5 { + let start = i; + skip_varint(body, &mut i); + // The varint is the property block's length, which follows it. + i += *body.get(start)? as usize; + } + let len = u16::from_be_bytes([*body.get(i)?, *body.get(i + 1)?]) as usize; + Some(String::from_utf8_lossy(body.get(i + 2..i + 2 + len)?).into_owned()) +} + +/// Collect the topics from a SUBSCRIBE body and build the matching SUBACK. +fn suback(body: &[u8], v5: bool, topics: &mut Vec) -> Vec { + let packet_id = [body[0], body[1]]; + let mut i = 2; + if v5 { + skip_varint(body, &mut i); + } + + let mut granted = Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + + let mut rest = Vec::new(); + rest.extend_from_slice(&packet_id); + if v5 { + rest.push(0x00); // no properties + } + rest.extend_from_slice(&granted); + + let mut ack = vec![0x90]; + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + ack +} + +/// Encode a QoS-0 PUBLISH for the broker to push at the client. +fn publish(topic: &str, payload: &[u8], v5: bool) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + if v5 { + rest.push(0x00); // no properties + } + rest.extend_from_slice(payload); + + let mut packet = vec![0x30]; + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet +} + +/// Decode a PUBLISH the client sent: topic, payload, and the packet id that is +/// present only above QoS 0. +fn parse_publish(first: u8, body: &[u8], v5: bool) -> Option<(String, Vec, Option<[u8; 2]>)> { + let topic_len = u16::from_be_bytes([*body.first()?, *body.get(1)?]) as usize; + let topic = String::from_utf8_lossy(body.get(2..2 + topic_len)?).into_owned(); + let mut i = 2 + topic_len; + + let packet_id = if (first >> 1) & 0x03 > 0 { + let id = [*body.get(i)?, *body.get(i + 1)?]; + i += 2; + Some(id) + } else { + None + }; + + if v5 { + skip_varint(body, &mut i); + } + Some((topic, body.get(i..)?.to_vec(), packet_id)) +} + +/// How a connection should behave once it has acknowledged a subscribe. +#[derive(Clone, Copy, Default)] +pub struct AfterSuback<'a> { + /// Close the connection, forcing the client to reconnect. + pub hang_up: bool, + /// Push this message at the client. + pub push: Option<(&'a str, &'a [u8])>, +} + +/// Serve one connection until it closes. +async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_>) { + let mut buf = Vec::new(); + let mut v5 = true; + + loop { + let Some((first, body)) = read_packet(socket, &mut buf).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK. MQTT 5 carries a property length; 3.1.1 does not. + 1 => { + v5 = is_v5(&body); + { + let mut seen = seen.lock().unwrap(); + seen.connects += 1; + if let Some(id) = connect_client_id(&body, v5) { + seen.client_ids.push(id); + } + } + let ack: &[u8] = if v5 { + &[0x20, 0x03, 0x00, 0x00, 0x00] + } else { + &[0x20, 0x02, 0x00, 0x00] + }; + if socket.write_all(ack).await.is_err() { + return; + } + } + // SUBSCRIBE -> SUBACK granting QoS 1 for each requested topic. + 8 => { + let mut topics = Vec::new(); + let ack = suback(&body, v5, &mut topics); + seen.lock().unwrap().subscribes.push(topics); + if socket.write_all(&ack).await.is_err() || after.hang_up { + return; + } + if let Some((topic, payload)) = after.push { + if socket + .write_all(&publish(topic, payload, v5)) + .await + .is_err() + { + return; + } + } + } + // PUBLISH from the client: record it, and PUBACK above QoS 0. + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body, v5) else { + return; + }; + seen.lock().unwrap().published.push((topic, payload)); + if let Some(id) = packet_id { + if socket.write_all(&[0x40, 0x02, id[0], id[1]]).await.is_err() { + return; + } + } + } + // PINGREQ -> PINGRESP + 12 => { + if socket.write_all(&[0xD0, 0x00]).await.is_err() { + return; + } + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +/// Accept forever. `hang_ups` connections are dropped after their SUBACK; +/// every later one is served normally. +pub async fn fake_broker( + listener: TcpListener, + seen: Arc>, + hang_ups: usize, + push: Option<(&str, &[u8])>, +) { + let mut accepted = 0usize; + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + accepted += 1; + let after = AfterSuback { + hang_up: accepted <= hang_ups, + push, + }; + serve(&mut socket, &seen, after).await; + } +} + +/// Serve several clients at once, which a parity test needs: both backends +/// hold a connection simultaneously. +pub async fn fake_broker_concurrent( + listener: TcpListener, + seen: Arc>, + push: Option<(&'static str, &'static [u8])>, +) { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let seen = seen.clone(); + tokio::spawn(async move { + let after = AfterSuback { + hang_up: false, + push, + }; + serve(&mut socket, &seen, after).await; + }); + } +} diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 770b6412..62cde2ed 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -10,8 +10,10 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::net::TcpListener; + +mod common; +use common::{fake_broker, Seen}; // Each test binary defines these exactly once. #[defmt::global_logger] @@ -44,143 +46,6 @@ impl embassy_time_driver::Driver for HostClock { } embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); -// --------------------------------------------------------------------------- -// A fake broker: just enough MQTT 5 to complete a session. -// --------------------------------------------------------------------------- - -/// What the broker saw, one entry per accepted connection. -#[derive(Default)] -struct Seen { - connects: usize, - subscribes: Vec>, -} - -/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then -/// that many bytes. -async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { - let mut byte = [0u8; 1]; - socket.read_exact(&mut byte).await.ok()?; - let first = byte[0]; - - let mut remaining = 0usize; - let mut shift = 0; - loop { - socket.read_exact(&mut byte).await.ok()?; - remaining |= ((byte[0] & 0x7F) as usize) << shift; - if byte[0] & 0x80 == 0 { - break; - } - shift += 7; - } - - buf.clear(); - buf.resize(remaining, 0); - socket.read_exact(buf).await.ok()?; - Some((first, buf.clone())) -} - -/// Encode a remaining-length varint. -fn varint(mut n: usize, out: &mut Vec) { - loop { - let mut byte = (n % 128) as u8; - n /= 128; - if n > 0 { - byte |= 0x80; - } - out.push(byte); - if n == 0 { - break; - } - } -} - -/// Collect the topics out of a SUBSCRIBE body and answer with a SUBACK -/// granting QoS 1 for each. -fn suback(body: &[u8], topics: &mut Vec) -> Vec { - let packet_id = [body[0], body[1]]; - let mut i = 2; - // Skip the property length varint. - while i < body.len() && body[i] & 0x80 != 0 { - i += 1; - } - i += 1; - - let mut granted = Vec::new(); - while i + 2 <= body.len() { - let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; - i += 2; - if i + len > body.len() { - break; - } - topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); - i += len + 1; // topic + subscription options byte - granted.push(0x01); - } - - let mut rest = Vec::new(); - rest.extend_from_slice(&packet_id); - rest.push(0x00); // no properties - rest.extend_from_slice(&granted); - let mut ack = vec![0x90]; - varint(rest.len(), &mut ack); - ack.extend_from_slice(&rest); - ack -} - -/// Serve one connection. `hang_up_after_suback` closes it the moment the -/// subscribe is acknowledged, which is what forces the reconnect. -async fn serve(socket: &mut TcpStream, seen: &Mutex, hang_up_after_suback: bool) { - let mut buf = Vec::new(); - loop { - let Some((first, body)) = read_packet(socket, &mut buf).await else { - return; - }; - match first >> 4 { - // CONNECT -> CONNACK (session present = 0, reason = success, no props) - 1 => { - seen.lock().unwrap().connects += 1; - if socket - .write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]) - .await - .is_err() - { - return; - } - } - // SUBSCRIBE -> SUBACK - 8 => { - let mut topics = Vec::new(); - let ack = suback(&body, &mut topics); - seen.lock().unwrap().subscribes.push(topics); - if socket.write_all(&ack).await.is_err() || hang_up_after_suback { - return; - } - } - // PINGREQ -> PINGRESP - 12 => { - if socket.write_all(&[0xD0, 0x00]).await.is_err() { - return; - } - } - // DISCONNECT - 14 => return, - _ => {} - } - } -} - -/// Accept forever, hanging up on the first `hang_ups` connections. -async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: usize) { - let mut accepted = 0usize; - loop { - let Ok((mut socket, _)) = listener.accept().await else { - return; - }; - accepted += 1; - serve(&mut socket, &seen, accepted <= hang_ups).await; - } -} - // --------------------------------------------------------------------------- // The test. // --------------------------------------------------------------------------- @@ -221,7 +86,7 @@ async fn the_session_loop_reconnects_and_resubscribes() { }); let (_db, runner) = builder.build().await.expect("build db"); - let broker = fake_broker(listener, seen.clone(), 1); + let broker = fake_broker(listener, seen.clone(), 1, None); let until_resubscribed = async { while seen.lock().unwrap().subscribes.len() < 2 { tokio::time::sleep(Duration::from_millis(5)).await; @@ -255,3 +120,159 @@ async fn the_session_loop_reconnects_and_resubscribes() { ); } } + +/// The embedded backend carries records both ways over `TokioNet::tcp()`, on a +/// multi-thread runtime: an inbound PUBLISH reaches a record, and a record's +/// outbound link reaches the broker. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_embedded_backend_round_trips_records_on_a_multi_thread_runtime() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("round-trip"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + // Inbound: the broker's PUBLISH lands here. + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + // Outbound: this record's producer publishes to the broker. + builder.configure::("uptime", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(|_ctx, producer| async move { + loop { + producer.produce(42u64); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .link_to("mqtt://sensors/uptime") + .with_serializer(|_ctx, v: &u64| Ok(v.to_string().into_bytes())) + .finish(); + }); + + let (db, runner) = builder.build().await.expect("build db"); + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let broker = fake_broker( + listener, + seen.clone(), + 0, + Some(("sensors/temperature", b"23")), + ); + let seen_for_wait = seen.clone(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + received = async { + let value = inbound.recv().await.expect("inbound record"); + while seen_for_wait.lock().unwrap().published.is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + value + } => received, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {} subscribes, {} publishes", + seen.connects, + seen.subscribes.len(), + seen.published.len() + ); + } + }; + + assert_eq!(received, 23, "the broker's PUBLISH must reach the record"); + + let seen = seen.lock().unwrap(); + let (topic, payload) = seen + .published + .first() + .expect("the outbound link must reach the broker"); + assert_eq!(topic, "sensors/uptime"); + assert_eq!(payload, b"42", "the serializer's bytes must arrive intact"); +} + +/// Two connectors in one process keep their own identities. +/// +/// They shared a process-global cell before the channels moved to `Arc`, so the +/// second silently connected under the first's client id. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn two_connectors_in_one_process_keep_their_own_client_ids() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let mut runners = Vec::new(); + for id in ["first-node", "second-node"] { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector( + MqttConnector::new(url.clone()) + .transport(TokioNet::tcp()) + .with_client_id(id), + ); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, _data: &[u8]| Ok(0u64)) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + runners.push(runner); + } + + let broker = common::fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + let second = runners.pop().unwrap(); + let first = runners.pop().unwrap(); + + tokio::select! { + _ = first.run() => panic!("the first runner returned"), + _ = second.run() => panic!("the second runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().client_ids.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().client_ids); + } + } + + let mut ids = seen.lock().unwrap().client_ids.clone(); + ids.sort(); + assert_eq!(ids, vec!["first-node", "second-node"]); +} From 84d2b72f15b0bd060de8529481b04789779f1767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:44:44 +0000 Subject: [PATCH 13/20] Refactor MQTT Connector to unify backend handling and enhance credential support - Consolidated the MqttConnector structure to allow seamless switching between Native and Embedded backends without separate builders. - Introduced credential handling in the Native backend, allowing credentials to be set via the MqttConnector interface. - Updated the Embassy client to streamline the transport setup and improve TLS handling. - Enhanced tests to verify credential transmission for both backends, ensuring consistent behavior across different configurations. - Removed deprecated builder patterns and unnecessary complexity in the connector implementation. --- aimdb-mqtt-connector/src/connector.rs | 220 ++++++++----- aimdb-mqtt-connector/src/embassy_client.rs | 310 ++++++------------- aimdb-mqtt-connector/src/lib.rs | 8 +- aimdb-mqtt-connector/src/tokio_client.rs | 180 +++++------ aimdb-mqtt-connector/tests/backend_parity.rs | 63 +++- aimdb-mqtt-connector/tests/common/mod.rs | 37 ++- aimdb-mqtt-connector/tests/tokio_broker.rs | 3 + 7 files changed, 403 insertions(+), 418 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index cf466b13..a91a2eaa 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -4,15 +4,18 @@ //! implementation. `rumqttc` owns its socket, TLS and reconnect — its //! `Transport` is a closed enum, so no stream can be injected — while //! `mountain-mqtt` is generic over `embedded-io-async`. The two stay separate, -//! and this type is the seam between them: a backend can be swapped or removed -//! without touching the other. +//! and this type is the seam between them. +//! +//! Broker URL, client id and credentials live here rather than in a backend, so +//! there is one constructor and one set of setters whichever backend runs. //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | `Native` (feature `tokio-runtime`) | `rumqttc` (std) | 0–2 | rustls | -//! | `Embedded` (feature `embassy-runtime`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | [`Native`] (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | +//! | [`Embedded`] (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; +use alloc::string::String; use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; @@ -20,57 +23,65 @@ use core::pin::Pin; use aimdb_core::connector::ConnectorBuilder; use aimdb_core::{AimDb, DbResult}; -/// The `rumqttc` backend: a host client owning its own socket and TLS. -#[cfg(feature = "tokio-runtime")] -pub struct Native(crate::tokio_client::MqttConnectorBuilder); +/// The runner's collected future type. +type BoxFuture = Pin + Send + 'static>>; +/// What [`ConnectorBuilder::build`] returns. +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +/// The `rumqttc` backend: it owns its socket, TLS and reconnect, so there is +/// nothing here to configure. Selected by supplying no transport. +#[derive(Clone, Copy, Default)] +pub struct Native; -/// The `mountain-mqtt` backend: `no_std`, over a caller-supplied transport. +/// The `mountain-mqtt` backend over a caller-supplied transport. #[cfg(feature = "embassy-runtime")] -pub struct Embedded( - crate::embassy_client::MqttConnectorBuilder, -); +pub struct Embedded { + pub(crate) dialer: D, +} + +/// The `mountain-mqtt` backend over `embedded-tls`. +/// +/// TLS keeps the network stack rather than taking a dialer: it resolves DNS +/// itself and owns buffers across sessions, which a per-session dialer cannot +/// express. +#[cfg(feature = "embassy-tls")] +pub struct EmbeddedTls { + pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, + pub(crate) options: crate::embassy_client::TlsSlot, +} /// An MQTT connector over the backend `B`. -pub struct MqttConnector { - backend: B, +pub struct MqttConnector { + pub(crate) broker_url: String, + pub(crate) client_id: Option, + pub(crate) credentials: Option<(String, String)>, + pub(crate) backend: B, } -#[cfg(feature = "tokio-runtime")] impl MqttConnector { /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). /// - /// Without [`with_client_id`](Self::with_client_id) a random UUID-based - /// client id is generated at build. - pub fn new(broker_url: impl Into) -> Self { - Self { - backend: Native(crate::tokio_client::MqttConnectorBuilder::new(broker_url)), - } - } - - /// Set the MQTT client id. - pub fn with_client_id(self, client_id: impl Into) -> Self { - Self { - backend: Native(self.backend.0.with_client_id(client_id)), - } - } -} - -#[cfg(feature = "embassy-runtime")] -impl MqttConnector { - /// Connect to `broker_url`, then supply the transport with - /// [`transport`](Self::transport) for `mqtt://`, or `tls` (feature - /// `embassy-tls`) for `mqtts://`. - pub fn new(broker_url: impl Into) -> Self { + /// Without a transport this is the `rumqttc` backend, and without + /// [`with_client_id`](Self::with_client_id) it generates a UUID-based + /// client id at build. + pub fn new(broker_url: impl Into) -> Self { Self { - backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), + broker_url: broker_url.into(), + client_id: None, + credentials: None, + backend: Native, } } /// Dial plain sessions through an adapter's stream dialer — the same call /// on any runtime's adapter, with no change in this crate. + #[cfg(feature = "embassy-runtime")] pub fn transport(self, dialer: D) -> MqttConnector> { MqttConnector { - backend: Embedded(self.backend.0.transport(dialer)), + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: Embedded { dialer }, } } @@ -80,56 +91,88 @@ impl MqttConnector { self, stack: &'static embassy_net::Stack<'static>, options: crate::embassy_tls::TlsOptions, - ) -> Self { - Self { - backend: Embedded(self.backend.0.tls(stack, options)), + ) -> MqttConnector { + MqttConnector { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: EmbeddedTls { + // SAFETY: AimDB's Embassy integration requires a single-core + // cooperative executor (the adapter's module-level invariant); + // every future touching this stack is polled on that executor. + stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + options: crate::embassy_client::TlsSlot::new(options), + }, } } } -#[cfg(feature = "embassy-runtime")] -impl MqttConnector> { - /// Set the MQTT client id (defaults to `aimdb-client`). - pub fn with_client_id(self, client_id: impl Into) -> Self { - Self { - backend: Embedded(self.backend.0.with_client_id(client_id)), - } +impl MqttConnector { + /// Set the MQTT client id (should be unique per device). + pub fn with_client_id(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self } - /// Set the broker username and password. + /// Authenticate with the broker (MQTT CONNECT username/password). + /// + /// Over `mqtt://` the credential transits in cleartext — pair it with + /// `mqtts://` outside a trusted LAN. pub fn with_credentials( - self, - username: impl Into, - password: impl Into, + mut self, + username: impl Into, + password: impl Into, ) -> Self { - Self { - backend: Embedded(self.backend.0.with_credentials(username, password)), - } + self.credentials = Some((username.into(), password.into())); + self } } -#[cfg(feature = "tokio-runtime")] -impl ConnectorBuilder for MqttConnector { +mod sealed { + pub trait Sealed {} + impl Sealed for super::Native {} + #[cfg(feature = "embassy-runtime")] + impl Sealed for super::Embedded {} + #[cfg(feature = "embassy-tls")] + impl Sealed for super::EmbeddedTls {} +} + +/// A backend with a build path compiled in. +/// +/// Implemented for [`Native`] only under `tokio-runtime`, so a `no_std` build +/// that forgets `.transport(..)` fails here with a message naming the fix +/// rather than on core's `ConnectorBuilder`. +#[diagnostic::on_unimplemented( + message = "`MqttConnector<{Self}>` has no MQTT backend compiled in", + label = "no backend for this configuration", + note = "supply a transport — `.transport(dialer)` — for the mountain-mqtt backend, or enable this crate's `tokio-runtime` feature for the rumqttc one" +)] +pub trait Backend: sealed::Sealed + Send + Sync { + /// Connect and collect this backend's data-plane futures. fn build<'a>( &'a self, db: &'a AimDb, - ) -> Pin< - Box< - dyn Future + Send>>>>> - + Send - + 'a, - >, - > { - self.backend.0.build(db) - } + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a>; +} - fn scheme(&self) -> &str { - self.backend.0.scheme() +#[cfg(feature = "tokio-runtime")] +impl Backend for Native { + fn build<'a>( + &'a self, + db: &'a AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::tokio_client::build(db, broker_url, client_id, credentials) } } #[cfg(feature = "embassy-runtime")] -impl ConnectorBuilder for MqttConnector> +impl Backend for Embedded where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -142,17 +185,38 @@ where fn build<'a>( &'a self, db: &'a AimDb, - ) -> Pin< - Box< - dyn Future + Send>>>>> - + Send - + 'a, - >, - > { - self.backend.0.build(db) + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embassy_client::build_plain(db, broker_url, client_id, credentials, &self.dialer) + } +} + +#[cfg(feature = "embassy-tls")] +impl Backend for EmbeddedTls { + fn build<'a>( + &'a self, + db: &'a AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embassy_client::build_tls(db, broker_url, client_id, credentials, self) + } +} + +impl ConnectorBuilder for MqttConnector { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + self.backend.build( + db, + &self.broker_url, + self.client_id.as_deref(), + self.credentials.as_ref(), + ) } fn scheme(&self) -> &str { - self.backend.0.scheme() + "mqtt" } } diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index aa00fa59..d4322613 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -8,25 +8,16 @@ //! //! # Usage //! -//! Illustrative (not compiled: requires the `embassy-runtime` feature and a -//! device network stack): -//! //! ```rust,ignore -//! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; -//! use aimdb_core::AimDbBuilder; -//! -//! // `stack: &'static embassy_net::Stack<'static>` — the device's network stack. //! let db = AimDbBuilder::new() //! .runtime(embassy_adapter) //! .with_connector( -//! MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack) +//! MqttConnector::new("mqtt://192.168.1.100:1883") +//! .transport(EmbassyNet::tcp(stack, rx, tx)) //! .with_client_id("my-unique-device-id"), //! ) -//! .configure::("temperature", |reg| { -//! reg.link_to("mqtt://sensors/temperature").finish(); -//! reg.link_from("mqtt://commands/temperature").finish(); -//! }) -//! .build().await?; +//! .build() +//! .await?; //! ``` extern crate alloc; @@ -35,7 +26,6 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::RouterBuilder; use aimdb_core::session::{pump_sink, pump_source, Payload}; use aimdb_core::transport::{ConnectorConfig, PublishError}; -use aimdb_core::ConnectorBuilder; use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; @@ -274,129 +264,16 @@ impl aimdb_core::session::Source for MqttSource { /// Core's cell supplies both without `unsafe`: it is `Send + Sync` for any /// `T: Send`, which is what the `+ Send` on [`TlsOptions`]'s RNG buys. #[cfg(feature = "embassy-tls")] -type TlsSlot = aimdb_core::session::OneShot; - -/// MQTT connector builder for Embassy with router-based dispatch. -/// -/// Collects routes from the database during `build()` and wires the broker -/// manager + the outbound/inbound pumps. The broker URL scheme selects the -/// transport: `mqtt://` is plain TCP (default port 1883), `mqtts://` is TLS -/// (default port 8883) and requires both the `embassy-tls` feature and the -/// `with_tls` method it gates. -/// Where the broker connection comes from. -/// -/// Plain sessions dial through a caller-supplied [`StreamDialer`], so a new -/// runtime supplies MQTT by passing its own. TLS keeps the stack: it resolves -/// DNS itself and owns buffers across sessions, which a per-session dialer -/// cannot express. -pub(crate) enum Transport { - Plain(D), - #[cfg(feature = "embassy-tls")] - Tls(aimdb_embassy_adapter::connectors::NetStack, TlsSlot), -} - -/// A dialer placeholder for TLS-only connectors, which never dial through one. -#[derive(Clone, Copy, Default)] -pub struct NoTransport; - -impl aimdb_core::session::StreamDialer for NoTransport { - type Stream = aimdb_embassy_adapter::net::EmbassyTcpStream; - - async fn connect( - &self, - _host: &str, - _port: u16, - ) -> aimdb_core::session::TransportResult { - Err(aimdb_core::session::TransportError::Io) - } -} - -pub struct MqttConnectorBuilder { - broker_url: String, - client_id: String, - credentials: Option<(String, String)>, - pub(crate) transport: Transport, -} - -impl MqttConnectorBuilder { - /// Create a new MQTT connector builder for Embassy. - /// - /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, - /// or `tls` (feature `embassy-tls`) for `mqtts://`. - pub fn new(broker_url: impl Into) -> Self { - Self { - broker_url: broker_url.into(), - client_id: "aimdb-client".to_string(), - credentials: None, - transport: Transport::Plain(NoTransport), - } - } - - /// Dial plain `mqtt://` sessions through an adapter's stream dialer. - /// - /// `EmbassyNet::tcp(stack, rx, tx)` on Embassy; the same call on any other - /// runtime's adapter, with no change here. - pub fn transport(self, dialer: D) -> MqttConnectorBuilder { - MqttConnectorBuilder { - broker_url: self.broker_url, - client_id: self.client_id, - credentials: self.credentials, - transport: Transport::Plain(dialer), - } - } - - /// Provide the network stack and TLS materials for an `mqtts://` broker. - /// - /// TLS keeps the stack rather than taking a dialer: it resolves DNS itself - /// and owns buffers across sessions. - #[cfg(feature = "embassy-tls")] - pub fn tls( - self, - stack: &'static embassy_net::Stack<'static>, - options: TlsOptions, - ) -> MqttConnectorBuilder { - MqttConnectorBuilder { - broker_url: self.broker_url, - client_id: self.client_id, - credentials: self.credentials, - // SAFETY: AimDB's Embassy integration requires a single-core - // cooperative executor (the adapter's module-level invariant); - // every future touching this stack is polled on that executor. - transport: Transport::Tls( - unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, - TlsSlot::new(options), - ), - } - } -} - -impl MqttConnectorBuilder { - /// Set the MQTT client ID (should be unique per device). - pub fn with_client_id(mut self, client_id: impl Into) -> Self { - self.client_id = client_id.into(); - self - } - - /// Authenticate with the broker (MQTT CONNECT username/password). - /// - /// Works on both transports, but note that over `mqtt://` the credential - /// transits in cleartext — pair it with `mqtts://` outside a trusted LAN. - pub fn with_credentials( - mut self, - username: impl Into, - password: impl Into, - ) -> Self { - self.credentials = Some((username.into(), password.into())); - self - } -} - -/// Implement ConnectorBuilder trait for Embassy. -/// -/// The network stack is taken at construction (see -/// [`MqttConnectorBuilder::new`]), so the builder needs nothing from the -/// runtime beyond the dyn-safe capabilities the database already holds. -impl ConnectorBuilder for MqttConnectorBuilder +pub(crate) type TlsSlot = aimdb_core::session::OneShot; + +/// Connect and collect the data-plane futures for a plain `mqtt://` session. +pub(crate) fn build_plain<'a, D>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + dialer: &'a D, +) -> Pin>> + Send + 'a>> where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -406,89 +283,86 @@ where + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { - fn build<'a>( - &'a self, - db: &'a aimdb_core::builder::AimDb, - ) -> Pin>> + Send + 'a>> - { - Box::pin(async move { - // Inbound topics to subscribe to (the manager sends `Subscribe` for each). - let inbound_routes = db.collect_inbound_routes("mqtt"); - let topics: Vec = RouterBuilder::from_routes(inbound_routes) - .build() - .resource_ids() - .iter() - .map(|t| t.to_string()) - .collect(); + Box::pin(async move { + let topics = inbound_topics(db); + let broker = parse_broker_url(broker_url)?; + if broker.tls { + return Err(build_err("mqtts:// broker URLs require .tls(...)")); + } + let connection_settings = static_connection_settings(client_id, credentials); + + let (actions, events, manager_tasks) = setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )?; + Ok(collect_pumps(db, actions, events, manager_tasks)) + }) +} - #[cfg(feature = "defmt")] - defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); - - let broker = parse_broker_url(&self.broker_url)?; - let connection_settings = - static_connection_settings(&self.client_id, self.credentials.as_ref()); - - // Broker manager task(s) + the channel ends for the pumps. - // The URL scheme selects the transport. - #[cfg(feature = "embassy-tls")] - let (actions, events, manager_tasks) = match &self.transport { - Transport::Tls(stack, slot) if broker.tls => { - let options = slot.take().ok_or_else(|| { - build_err("TLS materials already taken; build() ran twice") - })?; - setup_tls_manager( - &broker, - options, - connection_settings, - *stack, - topics, - db.runtime_ops(), - )? - } - Transport::Tls(..) => { - return Err(build_err(".tls(...) requires an mqtts:// broker URL")) - } - Transport::Plain(_) if broker.tls => { - return Err(build_err("mqtts:// broker URLs require .tls(...)")) - } - Transport::Plain(dialer) => setup_manager( - &broker, - connection_settings, - dialer.clone(), - topics, - db.runtime_ops(), - )?, - }; - #[cfg(not(feature = "embassy-tls"))] - let (actions, events, manager_tasks) = { - if broker.tls { - return Err(build_err( - "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", - )); - } - let Transport::Plain(dialer) = &self.transport; - setup_manager( - &broker, - connection_settings, - dialer.clone(), - topics, - db.runtime_ops(), - )? - }; +/// Connect and collect the data-plane futures for an `mqtts://` session. +#[cfg(feature = "embassy-tls")] +pub(crate) fn build_tls<'a>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + backend: &'a crate::connector::EmbeddedTls, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { + let topics = inbound_topics(db); + let broker = parse_broker_url(broker_url)?; + if !broker.tls { + return Err(build_err(".tls(...) requires an mqtts:// broker URL")); + } + let options = backend + .options + .take() + .ok_or_else(|| build_err("TLS materials already taken; build() ran twice"))?; + let connection_settings = static_connection_settings(client_id, credentials); + + let (actions, events, manager_tasks) = setup_tls_manager( + &broker, + options, + connection_settings, + backend.stack, + topics, + db.runtime_ops(), + )?; + Ok(collect_pumps(db, actions, events, manager_tasks)) + }) +} - // Outbound publishes + inbound routing ride core's pumps. - let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); - futures.extend(pump_source(db, "mqtt", MqttSource { events })); - // The broker session loop, plus the SNTP time source on TLS. - futures.extend(manager_tasks); +/// The inbound topics the session must subscribe on every connection. +fn inbound_topics(db: &aimdb_core::builder::AimDb) -> Vec { + let inbound_routes = db.collect_inbound_routes("mqtt"); + let topics: Vec = RouterBuilder::from_routes(inbound_routes) + .build() + .resource_ids() + .iter() + .map(|t| t.to_string()) + .collect(); - Ok(futures) - }) - } + #[cfg(feature = "defmt")] + defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); - fn scheme(&self) -> &str { - "mqtt" - } + topics +} + +/// Outbound publishes and inbound routing ride core's pumps; the session tasks +/// join them. +fn collect_pumps( + db: &aimdb_core::builder::AimDb, + actions: Arc, + events: Arc, + manager_tasks: Vec, +) -> Vec { + let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); + futures.extend(pump_source(db, "mqtt", MqttSource { events })); + futures.extend(manager_tasks); + futures } /// Parsed broker endpoint: transport + authority. @@ -532,14 +406,14 @@ fn parse_broker_url(broker_url: &str) -> Result /// per connector at build. A shared cell would be smaller but would hand every /// connector after the first the identity of the first. fn static_connection_settings( - client_id: &str, + client_id: Option<&str>, credentials: Option<&(String, String)>, ) -> ConnectionSettings<'static> { fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } - let client_id = leak(client_id); + let client_id = leak(client_id.unwrap_or("aimdb-client")); match credentials { Some((username, password)) => { ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 77bea841..9a9ff0a8 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -96,7 +96,6 @@ extern crate alloc; // MQTT knobs over core's generic link builders (works on every feature leg) // One `MqttConnector` over the `Native` and `Embedded` protocol backends. -#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] pub mod connector; // The broker transport seam for the `Embedded` backend. @@ -130,7 +129,6 @@ pub mod sntp; #[cfg(feature = "embassy-runtime")] pub use connector::Embedded; -#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use connector::MqttConnector; -#[cfg(feature = "tokio-runtime")] -pub use connector::Native; +#[cfg(feature = "embassy-tls")] +pub use connector::EmbeddedTls; +pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 51a076c0..8ca4954b 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -10,116 +10,69 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use aimdb_core::{log_debug, log_error, log_info}; -use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; +use aimdb_core::{pump_sink, pump_source, BoxFut, Payload, Source}; use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; -/// MQTT connector for a single broker connection with router-based dispatch -/// -/// Each connector manages ONE MQTT broker connection. The router determines -/// how incoming messages are dispatched to AimDB producers. -/// -/// # Usage Pattern -/// -/// The connector collects routes from the database during build() and -/// automatically subscribes to all required MQTT topics. -pub struct MqttConnectorBuilder { - broker_url: String, - client_id: Option, -} +type BoxFuture = Pin + Send + 'static>>; -impl MqttConnectorBuilder { - /// Create a new MQTT connector builder - /// - /// If no client ID is explicitly set via `with_client_id()`, a random - /// UUID-based client ID will be generated automatically when the connector - /// is built. - /// - /// # Arguments - /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) - pub fn new(broker_url: impl Into) -> Self { - Self { - broker_url: broker_url.into(), - client_id: None, - } - } +/// Connect, subscribe, and collect the data-plane futures for the `rumqttc` +/// backend. +pub(crate) fn build<'a>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { + // Build a router from the inbound routes purely to drive the MQTT + // subscriptions + channel-capacity sizing in `build_internal`. The + // routing `Router` that fans incoming frames out to producers is + // (re)built by `pump_source` from the same `collect_inbound_routes`. + let inbound_routes = db.collect_inbound_routes("mqtt"); + let router = RouterBuilder::from_routes(inbound_routes).build(); + + log_info!("MQTT subscribing to {} topics", router.resource_ids().len()); + + // Connect, subscribe, and hand back the raw event loop. + let (client, event_loop) = + MqttConnectorImpl::build_internal(broker_url, client_id, credentials, router) + .await + .map_err(|e| { + aimdb_core::DbError::runtime_error(format!( + "Failed to build MQTT connector: {}", + e + )) + })?; - /// Set the MQTT client ID - /// - /// The client ID should be unique for each client connecting to the broker. - /// It's used for session persistence and message delivery guarantees. - /// - /// If not set, a random UUID-based client ID will be generated automatically. - /// - /// # Arguments - /// * `client_id` - Unique identifier for this client - pub fn with_client_id(mut self, client_id: impl Into) -> Self { - self.client_id = Some(client_id.into()); - self - } -} + let mut futures: Vec = Vec::new(); -type BoxFuture = Pin + Send + 'static>>; + // Inbound: one multiplexed reader future fanning publishes out to producers. + futures.extend(pump_source( + db, + "mqtt", + MqttEventLoopSource { + event_loop, + broker_key: broker_url.to_string(), + }, + )); -impl ConnectorBuilder for MqttConnectorBuilder { - fn build<'a>( - &'a self, - db: &'a aimdb_core::builder::AimDb, - ) -> Pin>> + Send + 'a>> { - Box::pin(async move { - // Build a router from the inbound routes purely to drive the MQTT - // subscriptions + channel-capacity sizing in `build_internal`. The - // routing `Router` that fans incoming frames out to producers is - // (re)built by `pump_source` from the same `collect_inbound_routes`. - let inbound_routes = db.collect_inbound_routes("mqtt"); - let router = RouterBuilder::from_routes(inbound_routes).build(); - - log_info!("MQTT subscribing to {} topics", router.resource_ids().len()); - - // Connect, subscribe, and hand back the raw event loop. - let (client, event_loop) = - MqttConnectorImpl::build_internal(&self.broker_url, self.client_id.clone(), router) - .await - .map_err(|e| { - aimdb_core::DbError::runtime_error(format!( - "Failed to build MQTT connector: {}", - e - )) - })?; - - let mut futures: Vec = Vec::new(); - - // Inbound: one multiplexed reader future fanning publishes out to producers. - futures.extend(pump_source( - db, - "mqtt", - MqttEventLoopSource { - event_loop, - broker_key: self.broker_url.clone(), - }, - )); - - // Outbound: one publisher future per outbound route. - futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); - - Ok(futures) - }) - } + // Outbound: one publisher future per outbound route. + futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); - fn scheme(&self) -> &str { - "mqtt" - } + Ok(futures) + }) } /// Internal MQTT connector build helpers. /// -/// A namespace for the broker-connection setup invoked from -/// [`MqttConnectorBuilder::build`]; the data-plane loops themselves live in the -/// reusable `pump_sink` / `pump_source` helpers + the `MqttSink` / -/// `MqttEventLoopSource` adapters below. +/// A namespace for the broker-connection setup invoked from [`build`]; the +/// data-plane loops themselves live in the reusable `pump_sink` / +/// `pump_source` helpers + the `MqttSink` / `MqttEventLoopSource` adapters +/// below. pub struct MqttConnectorImpl; impl MqttConnectorImpl { @@ -136,7 +89,8 @@ impl MqttConnectorImpl { /// * `router` - Routes used only for the subscription list + capacity sizing async fn build_internal( broker_url: &str, - client_id: Option, + client_id: Option<&str>, + credentials: Option<&(String, String)>, router: Router, ) -> Result<(Arc, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic @@ -162,17 +116,28 @@ impl MqttConnectorImpl { log_info!("Creating MQTT client for {}:{}", host, port); // Use provided client_id or generate a UUID-based one - let client_id = client_id.unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); + let client_id = client_id + .map(ToString::to_string) + .unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); let mut mqtt_opts = MqttOptions::new(client_id, host, port); mqtt_opts.set_keep_alive(Duration::from_secs(30)); - // Add credentials if provided - if let (Some(ref username), Some(ref password)) = - (&connector_url.username, &connector_url.password) - { - mqtt_opts.set_credentials(username, password); + // `with_credentials` wins over anything in the URL's authority, which + // is the only way to name a password that is not URL-safe. + match ( + credentials, + &connector_url.username, + &connector_url.password, + ) { + (Some((username, password)), _, _) => { + mqtt_opts.set_credentials(username, password); + } + (None, Some(username), Some(password)) => { + mqtt_opts.set_credentials(username, password); + } + _ => {} } // mqtts:// selects the TLS transport; rumqttc otherwise speaks plain TCP @@ -396,7 +361,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, None, router).await; assert!(connector.is_ok()); } @@ -404,14 +369,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, None, router).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, None, router).await; assert!(connector.is_err()); } @@ -423,6 +389,7 @@ mod tests { let connector = MqttConnectorImpl::build_internal( "mqtts://hub-sub:secret@broker.example.com:8883", None, + None, router, ) .await; @@ -451,7 +418,8 @@ mod tests { async fn test_connector_mqtt_url_needs_no_tls_backend() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.example.com:1883", None, router).await; + MqttConnectorImpl::build_internal("mqtt://broker.example.com:1883", None, None, router) + .await; assert!(connector.is_ok()); } } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index e7fa2ec5..5d20c437 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -13,7 +13,6 @@ use tokio::net::TcpListener; use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; -use aimdb_mqtt_connector::connector::{Embedded, Native}; use aimdb_mqtt_connector::MqttConnector; use aimdb_tokio_adapter::net::TokioNet; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -34,6 +33,9 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } +// Nothing else defines `_defmt_timestamp` now that the connector pulls no +// crate enabling `embassy-time/defmt-timestamp-uptime`. +defmt::timestamp!("{=u64:us}", 0); struct HostClock; impl embassy_time_driver::Driver for HostClock { @@ -100,10 +102,10 @@ async fn both_backends_round_trip_against_one_broker() { let url = format!("mqtt://127.0.0.1:{port}"); let seen = Arc::new(Mutex::new(Seen::default())); - // The turbofish is what disambiguates the two `new`s while both backends - // are compiled in. - let native = MqttConnector::::new(url.clone()).with_client_id("parity-native"); - let embedded = MqttConnector::::new(url) + // One `new` whichever backends are compiled in: the transport, or its + // absence, picks the backend. + let native = MqttConnector::new(url.clone()).with_client_id("parity-native"); + let embedded = MqttConnector::new(url) .transport(TokioNet::tcp()) .with_client_id("parity-embedded"); @@ -180,3 +182,54 @@ async fn both_backends_round_trip_against_one_broker() { "each backend must publish its own record's bytes" ); } + +/// `with_credentials` reaches the wire on both backends. +/// +/// It is new plumbing on `Native` — `rumqttc` previously took credentials only +/// from the URL authority — so a setter that was accepted and dropped would +/// look exactly like success. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn with_credentials_reaches_the_wire_on_both_backends() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()) + .with_client_id("creds-native") + .with_credentials("hub", "s3cret"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("creds-embedded") + .with_credentials("hub", "s3cret"); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().credentials.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().credentials); + } + } + + let seen = seen.lock().unwrap(); + let expected = Some((String::from("hub"), String::from("s3cret"))); + for (n, credentials) in seen.credentials.iter().enumerate() { + assert_eq!( + *credentials, expected, + "connection {n} ({}) dropped the credentials", + seen.client_ids[n] + ); + } +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index f79dcab8..a19af195 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -17,6 +17,8 @@ use tokio::net::{TcpListener, TcpStream}; pub struct Seen { pub connects: usize, pub client_ids: Vec, + /// The username/password each CONNECT carried, when it carried any. + pub credentials: Vec>, pub subscribes: Vec>, pub published: Vec<(String, Vec)>, } @@ -84,9 +86,19 @@ fn is_v5(body: &[u8]) -> bool { body.get(6).is_some_and(|level| *level >= 5) } -/// The client id a CONNECT carries. It opens the payload, which follows the -/// 10-byte variable header plus, on MQTT 5, a property block. -fn connect_client_id(body: &[u8], v5: bool) -> Option { +/// Read a length-prefixed field and step past it. +fn take_field(body: &[u8], i: &mut usize) -> Option { + let len = u16::from_be_bytes([*body.get(*i)?, *body.get(*i + 1)?]) as usize; + let field = String::from_utf8_lossy(body.get(*i + 2..*i + 2 + len)?).into_owned(); + *i += 2 + len; + Some(field) +} + +/// The identity a CONNECT carries: client id, then the credentials its flags +/// advertise. The payload follows the 10-byte variable header plus, on MQTT 5, +/// a property block. Nothing here sets a will, so the fields are contiguous. +fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, String)>)> { + let flags = *body.get(7)?; let mut i = 10; if v5 { let start = i; @@ -94,8 +106,20 @@ fn connect_client_id(body: &[u8], v5: bool) -> Option { // The varint is the property block's length, which follows it. i += *body.get(start)? as usize; } - let len = u16::from_be_bytes([*body.get(i)?, *body.get(i + 1)?]) as usize; - Some(String::from_utf8_lossy(body.get(i + 2..i + 2 + len)?).into_owned()) + + let client_id = take_field(body, &mut i)?; + let credentials = if flags & 0x80 != 0 { + let username = take_field(body, &mut i)?; + let password = if flags & 0x40 != 0 { + take_field(body, &mut i)? + } else { + String::new() + }; + Some((username, password)) + } else { + None + }; + Some((client_id, credentials)) } /// Collect the topics from a SUBSCRIBE body and build the matching SUBACK. @@ -193,8 +217,9 @@ async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_ { let mut seen = seen.lock().unwrap(); seen.connects += 1; - if let Some(id) = connect_client_id(&body, v5) { + if let Some((id, credentials)) = connect_identity(&body, v5) { seen.client_ids.push(id); + seen.credentials.push(credentials); } } let ack: &[u8] = if v5 { diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 62cde2ed..f2a9fd37 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -28,6 +28,9 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } +// Nothing else defines `_defmt_timestamp` now that the connector pulls no +// crate enabling `embassy-time/defmt-timestamp-uptime`. +defmt::timestamp!("{=u64:us}", 0); /// Real wall-clock time; the session loop's delays are `embassy_time`'s until /// it takes core's `Delay`. From e20e360095d2259c365ccdcc7b001736ea9dcb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:55:12 +0000 Subject: [PATCH 14/20] feat: add SNTP codec and TLS transport for MQTT client - Implement SNTP codec for encoding and parsing SNTP packets, including request and reply handling. - Introduce TLS transport for the Embassy MQTT client, supporting secure connections with certificate verification. - Refactor the library structure to separate native and embedded implementations, enhancing modularity. - Update the MQTT connector to support both plain and secure MQTT connections, with appropriate error handling and logging. --- Cargo.lock | 1 - Makefile | 33 ++++++++--- aimdb-mqtt-connector/Cargo.toml | 59 +++++++++++-------- aimdb-mqtt-connector/src/connector.rs | 22 +++---- .../src/{ => embedded}/manager.rs | 0 .../{embassy_client.rs => embedded/mod.rs} | 44 +++++++++----- .../src/{transport.rs => embedded/session.rs} | 12 ++-- .../src/{ => embedded}/sntp.rs | 2 +- .../src/{ => embedded}/sntp_codec.rs | 2 +- .../src/{embassy_tls.rs => embedded/tls.rs} | 10 ++-- aimdb-mqtt-connector/src/lib.rs | 43 ++++++-------- .../src/{tokio_client.rs => native.rs} | 0 12 files changed, 133 insertions(+), 95 deletions(-) rename aimdb-mqtt-connector/src/{ => embedded}/manager.rs (100%) rename aimdb-mqtt-connector/src/{embassy_client.rs => embedded/mod.rs} (93%) rename aimdb-mqtt-connector/src/{transport.rs => embedded/session.rs} (94%) rename aimdb-mqtt-connector/src/{ => embedded}/sntp.rs (99%) rename aimdb-mqtt-connector/src/{ => embedded}/sntp_codec.rs (98%) rename aimdb-mqtt-connector/src/{embassy_tls.rs => embedded/tls.rs} (98%) rename aimdb-mqtt-connector/src/{tokio_client.rs => native.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index bf9f7bda..d3b1d209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,7 +283,6 @@ dependencies = [ "async-stream", "critical-section", "defmt 1.1.1", - "embassy-executor", "embassy-net", "embassy-net-driver-channel", "embassy-sync", diff --git a/Makefile b/Makefile index b218aed3..4e809a7f 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,9 @@ RED := \033[0;31m # pthread_atfork fork detector) silently un-no_std's the crate if it is not # marked optional and gated behind `std`. SYNC_NO_STD_FORBIDDEN := tokio|libc +# The embedded MQTT backend runs on any target with a `StreamDialer`, so no +# executor, network stack, adapter or logger may reach its graph. +MQTT_EMBEDDED_FORBIDDEN := embassy-net|embassy-executor|embassy-time|static_cell|aimdb-embassy-adapter|defmt NC := \033[0m # No Color ## Show available commands @@ -208,11 +211,11 @@ test: @printf "$(YELLOW) → Testing persistence SQLite backend$(NC)\n" cargo test --package aimdb-persistence-sqlite @printf "$(YELLOW) → Testing MQTT connector (tokio, no TLS backend)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime" + cargo test --package aimdb-mqtt-connector --features "std" @printf "$(YELLOW) → Testing MQTT connector (tokio + native-tls)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-native-tls" + cargo test --package aimdb-mqtt-connector --features "std,tokio-native-tls" @printf "$(YELLOW) → Testing MQTT connector (tokio + rustls)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-rustls" + cargo test --package aimdb-mqtt-connector --features "std,tokio-rustls" @printf "$(YELLOW) → Testing KNX connector$(NC)\n" cargo test --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Testing WebSocket connector (server + client: unit, real-socket e2e, AimDB round-trip)$(NC)\n" @@ -337,12 +340,14 @@ clippy: @printf "$(YELLOW) → Clippy on KNX connector (embassy)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio, no TLS backend)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio + native-tls)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-native-tls" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std,tokio-native-tls" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio + rustls)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-rustls" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std,tokio-rustls" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded" -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (Embassy bundle + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings @@ -392,7 +397,7 @@ doc: cargo doc --package aimdb-core --features "std,tracing,observability" --no-deps cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net,embedded-io" --no-deps cargo doc --package aimdb-sync --no-deps - cargo doc --package aimdb-mqtt-connector --features "std,tokio-runtime" --no-deps + cargo doc --package aimdb-mqtt-connector --features "std" --no-deps cargo doc --package aimdb-knx-connector --features "std,tokio-runtime" --no-deps cargo doc --package aimdb-codegen --no-deps cargo doc --package aimdb-cli --no-deps @@ -465,7 +470,19 @@ test-embedded: @printf "$(YELLOW) → Checking aimdb-embassy-adapter runtime-neutral transports, with and without the clock, on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,net,embassy-runtime" cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,net" - @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (runtime-neutral embedded backend) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embedded" + @printf "$(YELLOW) → Asserting no runtime crates in the embedded MQTT backend$(NC)\n" + @out=$$(cargo tree -p aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded" -e features,no-dev 2>&1) || { \ + printf "$(RED)✗ cargo tree failed — refusing to pass vacuously:$(NC)\n"; \ + printf '%s\n' "$$out"; exit 1; \ + }; \ + if printf '%s\n' "$$out" | grep -qiE '$(MQTT_EMBEDDED_FORBIDDEN)'; then \ + printf "$(RED)✗ a runtime crate leaked into the embedded MQTT graph$(NC)\n"; \ + printf '%s\n' "$$out" | grep -iE '$(MQTT_EMBEDDED_FORBIDDEN)'; exit 1; \ + fi + @printf "$(BLUE)✓ embedded MQTT graph is free of $(MQTT_EMBEDDED_FORBIDDEN)$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy bundle) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 911e6a58..1a9c5684 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -15,15 +15,20 @@ categories = ["network-programming", "embedded", "asynchronous"] default = ["aimdb-core/alloc"] # `aimdb-core/connector-session` provides the data-plane `pump_sink`/`pump_source` # helpers the tokio client builds on (re-exported there; `std` implies it too). -std = ["aimdb-core/std", "aimdb-core/alloc", "aimdb-core/connector-session", "thiserror"] -tokio-runtime = [ - "std", +# The `rumqttc` backend, which owns its socket, TLS and reconnect. +std = [ + "aimdb-core/std", + "aimdb-core/alloc", + "aimdb-core/connector-session", + "thiserror", "tokio", "rumqttc", "uuid", "async-stream", "futures-util", ] +# Deprecated alias for `std`, kept so existing manifests keep working. +tokio-runtime = ["std"] # TLS backend for the tokio client (`mqtts://`). Pick one, or neither. # # Neither is a real choice rather than an oversight: a deployment that speaks @@ -31,27 +36,38 @@ tokio-runtime = [ # library is the difference between inheriting a system OpenSSL ABI and # inheriting nothing. `mqtts://` then fails at connect time with a message # naming the missing feature, rather than at the linker. -tokio-native-tls = ["tokio-runtime", "rumqttc/use-native-tls"] -tokio-rustls = ["tokio-runtime", "rumqttc/use-rustls", "dep:rustls-native-certs"] +tokio-native-tls = ["std", "rumqttc/use-native-tls"] +tokio-rustls = ["std", "rumqttc/use-rustls", "dep:rustls-native-certs"] + +# The `mountain-mqtt` backend over a caller-supplied transport. `alloc` only: +# no executor, no network stack, no adapter — any target with a `StreamDialer` +# that also offers `embedded-io-async` can run it. +embedded = [ + "aimdb-core/alloc", + "aimdb-core/connector-session", + "mountain-mqtt", + # The transport bridge names these traits in its bounds, and the session + # loop bridges core's `Delay` to the client's `DelayNs`. + "dep:embedded-io-async", + "dep:embedded-hal-async", + # Executor-independent: channels only. + "embassy-sync", +] +# Convenience bundle: `embedded` plus the Embassy transport and clock. The +# connector itself no longer knows what a runtime is. embassy-runtime = [ - "aimdb-core/alloc", # Need alloc for collect_inbound_routes - "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` - "dep:aimdb-embassy-adapter", # Enable the optional dependency - "aimdb-embassy-adapter/embassy-net-support", # Enable EmbassyNetwork trait for network stack access - "aimdb-embassy-adapter/connectors", # `EmbassySink`/`EmbassySource`/`into_box_future` spine - "aimdb-embassy-adapter/net", # `EmbassyNet::tcp` — the adapter owns the socket - "embassy-executor", + "embedded", + "dep:aimdb-embassy-adapter", + "aimdb-embassy-adapter/embassy-net-support", + "aimdb-embassy-adapter/connectors", + "aimdb-embassy-adapter/net", + # `EmbassyTcpDialer` supplies the session clock, which needs this. + "aimdb-embassy-adapter/embassy-time", "embassy-time", - "embassy-sync", "embassy-net", - "mountain-mqtt", - # The `SocketTransport` bridge names these traits in its bounds, and the - # session loop bridges core's `Delay` to the client's `DelayNs`. - "dep:embedded-io-async", - "dep:embedded-hal-async", - "heapless", ] + # TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 # session over the Embassy TCP socket, pure-Rust certificate verification # (`rustpki`; `rsa`/`p384` so public CA chains verify out of the box), broker @@ -92,7 +108,7 @@ _test-tokio-broker = [ # Internal: both backends against one fake broker in one process # (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. -_test-backend-parity = ["_test-tokio-broker", "tokio-runtime"] +_test-backend-parity = ["_test-tokio-broker", "std"] # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an @@ -140,7 +156,6 @@ futures-core = { version = "0.3", default-features = false } # Embassy runtime dependencies (no_std). Only embassy-sync still comes from the # local checkout — see the workspace `[patch.crates-io]` for why. -embassy-executor = { version = "0.10.0", optional = true } embassy-time = { version = "0.5.1", optional = true } embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } embassy-net = { version = "0.9.0", optional = true, features = [ @@ -168,8 +183,6 @@ embedded-io-async = { workspace = true, optional = true } embedded-hal-async = { workspace = true, optional = true } rand_core = { version = "0.6", default-features = false, optional = true } -# Embedded utilities -heapless = { workspace = true, optional = true } # Optional observability defmt = { workspace = true, optional = true } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index a91a2eaa..062f69bd 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -34,7 +34,7 @@ type BuildFuture<'a> = Pin>> + S pub struct Native; /// The `mountain-mqtt` backend over a caller-supplied transport. -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] pub struct Embedded { pub(crate) dialer: D, } @@ -47,7 +47,7 @@ pub struct Embedded { #[cfg(feature = "embassy-tls")] pub struct EmbeddedTls { pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, - pub(crate) options: crate::embassy_client::TlsSlot, + pub(crate) options: crate::embedded::TlsSlot, } /// An MQTT connector over the backend `B`. @@ -75,7 +75,7 @@ impl MqttConnector { /// Dial plain sessions through an adapter's stream dialer — the same call /// on any runtime's adapter, with no change in this crate. - #[cfg(feature = "embassy-runtime")] + #[cfg(feature = "embedded")] pub fn transport(self, dialer: D) -> MqttConnector> { MqttConnector { broker_url: self.broker_url, @@ -90,7 +90,7 @@ impl MqttConnector { pub fn tls( self, stack: &'static embassy_net::Stack<'static>, - options: crate::embassy_tls::TlsOptions, + options: crate::embedded::tls::TlsOptions, ) -> MqttConnector { MqttConnector { broker_url: self.broker_url, @@ -101,7 +101,7 @@ impl MqttConnector { // cooperative executor (the adapter's module-level invariant); // every future touching this stack is polled on that executor. stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, - options: crate::embassy_client::TlsSlot::new(options), + options: crate::embedded::TlsSlot::new(options), }, } } @@ -131,7 +131,7 @@ impl MqttConnector { mod sealed { pub trait Sealed {} impl Sealed for super::Native {} - #[cfg(feature = "embassy-runtime")] + #[cfg(feature = "embedded")] impl Sealed for super::Embedded {} #[cfg(feature = "embassy-tls")] impl Sealed for super::EmbeddedTls {} @@ -158,7 +158,7 @@ pub trait Backend: sealed::Sealed + Send + Sync { ) -> BuildFuture<'a>; } -#[cfg(feature = "tokio-runtime")] +#[cfg(feature = "std")] impl Backend for Native { fn build<'a>( &'a self, @@ -167,11 +167,11 @@ impl Backend for Native { client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::tokio_client::build(db, broker_url, client_id, credentials) + crate::native::build(db, broker_url, client_id, credentials) } } -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] impl Backend for Embedded where D: aimdb_core::session::StreamDialer @@ -189,7 +189,7 @@ where client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::embassy_client::build_plain(db, broker_url, client_id, credentials, &self.dialer) + crate::embedded::build_plain(db, broker_url, client_id, credentials, &self.dialer) } } @@ -202,7 +202,7 @@ impl Backend for EmbeddedTls { client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::embassy_client::build_tls(db, broker_url, client_id, credentials, self) + crate::embedded::build_tls(db, broker_url, client_id, credentials, self) } } diff --git a/aimdb-mqtt-connector/src/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs similarity index 100% rename from aimdb-mqtt-connector/src/manager.rs rename to aimdb-mqtt-connector/src/embedded/manager.rs diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embedded/mod.rs similarity index 93% rename from aimdb-mqtt-connector/src/embassy_client.rs rename to aimdb-mqtt-connector/src/embedded/mod.rs index d4322613..27b942d1 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -20,6 +20,20 @@ //! .await?; //! ``` +pub mod manager; +pub mod session; + +// SNTP wire codec — pure and feature-independent so it is unit-tested on the +// host; only the TLS I/O task consumes it. +#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] +pub(crate) mod sntp_codec; + +// TLS transport + SNTP time source. +#[cfg(feature = "embassy-tls")] +pub mod sntp; +#[cfg(feature = "embassy-tls")] +pub mod tls; + extern crate alloc; use aimdb_core::connector::ConnectorUrl; @@ -43,12 +57,12 @@ use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use crate::manager::{MqttEvent, Settings}; +use crate::embedded::manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] -pub use crate::embassy_tls::TlsOptions; +pub use crate::embedded::tls::TlsOptions; #[cfg(feature = "embassy-tls")] -use crate::embassy_tls::{host_ip_literal, run_tls, READ_BUF_MIN}; +use crate::embedded::tls::{host_ip_literal, run_tls, READ_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -67,9 +81,9 @@ type EmbassyBoxFuture = Pin + Send + 'static>>; type ManagerSetup = (Arc, Arc, Vec); /// Outbound publishes and subscriptions: pumps to broker session. -pub(crate) type ActionChannel = crate::manager::ActionChannel; +pub(crate) type ActionChannel = crate::embedded::manager::ActionChannel; /// Inbound messages: broker session to pumps. -pub(crate) type EventChannel = crate::manager::EventChannel; +pub(crate) type EventChannel = crate::embedded::manager::EventChannel; /// MQTT actions that can be performed /// @@ -167,12 +181,12 @@ pub enum AimdbMqttEvent { MessageReceived { /// The topic the message was received on topic: String, - /// The message payload - payload: Vec, + /// The message payload, built once from the wire bytes. + payload: Payload, }, } -impl crate::manager::FromApplicationMessage for AimdbMqttEvent { +impl crate::embedded::manager::FromApplicationMessage for AimdbMqttEvent { fn from_application_message( message: &mountain_mqtt::packets::publish::ApplicationMessage, ) -> Result { @@ -185,7 +199,9 @@ impl crate::manager::FromApplicationMessage for AimdbMqttEvent { Ok(Self::MessageReceived { topic: message.topic_name.to_string(), - payload: message.payload.to_vec(), + // Straight to `Payload` — one allocation and one copy, where a + // `Vec` here would be converted again on the way out. + payload: Payload::from(message.payload), }) } } @@ -246,7 +262,7 @@ impl aimdb_core::session::Source for MqttSource { MqttEvent::ApplicationEvent { event: AimdbMqttEvent::MessageReceived { topic, payload }, .. - } => return Some((topic, Payload::from(payload))), + } => return Some((topic, payload)), // Connection lifecycle events carry no record data; skip // and keep draining. _ => continue, @@ -454,20 +470,20 @@ where // on — both come from the caller-supplied dialer. let delay = dialer.clone(); let transport = - crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); + crate::embedded::session::SocketTransport::new(dialer, broker.host.clone(), broker.port); // SAFETY: every value the session holds is `Send` — `StreamDialer` // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` // and the state cell is a blocking mutex. See `SendSession`. let manager_task: EmbassyBoxFuture = Box::pin(unsafe { - crate::transport::SendSession::new({ + crate::embedded::session::SendSession::new({ let actions = actions.clone(); let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); - crate::transport::run_sessions( + crate::embedded::session::run_sessions( transport, topics, connection_settings, @@ -555,7 +571,7 @@ fn setup_tls_manager( let sntp_task = into_box_future(async move { #[allow(unreachable_code)] { - let _: () = crate::sntp::run(*network, sntp_server).await; + let _: () = crate::embedded::sntp::run(*network, sntp_server).await; } }); diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/embedded/session.rs similarity index 94% rename from aimdb-mqtt-connector/src/transport.rs rename to aimdb-mqtt-connector/src/embedded/session.rs index 47467117..e9e2af0b 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -132,9 +132,9 @@ pub(crate) async fn run_sessions( transport: T, topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, - settings: crate::manager::Settings, - events: alloc::sync::Arc, - actions: alloc::sync::Arc, + settings: crate::embedded::manager::Settings, + events: alloc::sync::Arc, + actions: alloc::sync::Arc, delay: D, runtime: alloc::sync::Arc, ) -> ! @@ -146,7 +146,7 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; + use crate::embedded::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -154,7 +154,7 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); - let mut mqtt_buffer = [0u8; crate::embassy_client::BUFFER_SIZE]; + let mut mqtt_buffer = [0u8; crate::embedded::BUFFER_SIZE]; let mut connection_index = 0u32; loop { @@ -168,7 +168,7 @@ where } }; - let state: SessionState = + let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs similarity index 99% rename from aimdb-mqtt-connector/src/sntp.rs rename to aimdb-mqtt-connector/src/embedded/sntp.rs index 8c6e97a1..d887cc8b 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -14,7 +14,7 @@ use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, Stack}; use embassy_time::{with_timeout, Duration, Instant, Timer}; -use crate::sntp_codec; +use crate::embedded::sntp_codec; /// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is /// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit diff --git a/aimdb-mqtt-connector/src/sntp_codec.rs b/aimdb-mqtt-connector/src/embedded/sntp_codec.rs similarity index 98% rename from aimdb-mqtt-connector/src/sntp_codec.rs rename to aimdb-mqtt-connector/src/embedded/sntp_codec.rs index 6b37a40a..1f555c2b 100644 --- a/aimdb-mqtt-connector/src/sntp_codec.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp_codec.rs @@ -1,7 +1,7 @@ //! SNTPv4 wire format (RFC 4330 subset) — pure encode/parse, no I/O. //! //! Feature-independent so the codec is unit-tested on the host; the Embassy -//! I/O task around it lives in [`sntp`](crate::sntp) (`embassy-tls` only). +//! I/O task around it lives in [`sntp`](crate::embedded::sntp) (`embassy-tls` only). /// Seconds between the NTP epoch (1900-01-01) and the Unix epoch (1970-01-01). const NTP_UNIX_OFFSET: u64 = 2_208_988_800; diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs similarity index 98% rename from aimdb-mqtt-connector/src/embassy_tls.rs rename to aimdb-mqtt-connector/src/embedded/tls.rs index 58cdaa6b..42b123bc 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -33,7 +33,7 @@ use embedded_tls::{ use embedded_io_async::Write as _; -use crate::manager::{ +use crate::embedded::manager::{ handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, Settings, }; use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; @@ -43,10 +43,10 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embassy_client::{ +use crate::embedded::{ AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, }; -use crate::sntp::{self, SntpClock}; +use crate::embedded::sntp::{self, SntpClock}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. @@ -244,8 +244,8 @@ pub(crate) async fn run_tls( topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - events: Arc, - actions: Arc, + events: Arc, + actions: Arc, runtime: Arc, ) -> ! { let TlsOptions { diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 9a9ff0a8..9537c0e2 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -94,41 +94,34 @@ extern crate alloc; -// MQTT knobs over core's generic link builders (works on every feature leg) // One `MqttConnector` over the `Native` and `Embedded` protocol backends. pub mod connector; -// The broker transport seam for the `Embedded` backend. -#[cfg(feature = "embassy-runtime")] -pub mod transport; - -// Session state, event handler and message pump for the `Embedded` backend. -#[cfg(feature = "embassy-runtime")] -pub mod manager; - +// MQTT knobs over core's generic link builders (works on every feature leg). pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; -// Platform-specific implementations -#[cfg(feature = "tokio-runtime")] -pub mod tokio_client; - -#[cfg(feature = "embassy-runtime")] -pub mod embassy_client; +// The `rumqttc` backend. +#[cfg(feature = "std")] +pub mod native; -// SNTP wire codec — pure and feature-independent so it is unit-tested on the -// host; only the `embassy-tls` I/O task consumes it. -#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] -pub(crate) mod sntp_codec; +// The `mountain-mqtt` backend: session loop, manager, and the TLS transport. +#[cfg(feature = "embedded")] +pub mod embedded; -// TLS transport + SNTP time source for the Embassy client -#[cfg(feature = "embassy-tls")] -pub mod embassy_tls; -#[cfg(feature = "embassy-tls")] -pub mod sntp; +// Deprecated module names, kept for one release so existing imports keep +// working. The modules no longer name a runtime. +#[cfg(feature = "std")] +#[deprecated(since = "0.7.0", note = "renamed to `native`")] +pub use crate::native as tokio_client; +#[cfg(feature = "embedded")] +#[deprecated(since = "0.7.0", note = "renamed to `embedded`")] +pub use crate::embedded as embassy_client; -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] pub use connector::Embedded; #[cfg(feature = "embassy-tls")] pub use connector::EmbeddedTls; +#[cfg(feature = "embassy-tls")] +pub use embedded::tls::TlsOptions; pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/native.rs similarity index 100% rename from aimdb-mqtt-connector/src/tokio_client.rs rename to aimdb-mqtt-connector/src/native.rs From e770173fd86c6e53a5a0e493b87ecce44bdfb8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:03:39 +0000 Subject: [PATCH 15/20] feat: reorganize embedded module structure and add SNTP codec implementation --- aimdb-mqtt-connector/src/embedded/mod.rs | 8 ++------ aimdb-mqtt-connector/src/embedded/session.rs | 4 +++- aimdb-mqtt-connector/src/embedded/sntp.rs | 2 +- aimdb-mqtt-connector/src/embedded/tls.rs | 8 +++----- aimdb-mqtt-connector/src/lib.rs | 13 +++++++++---- aimdb-mqtt-connector/src/native.rs | 10 ++++------ .../src/{embedded => }/sntp_codec.rs | 0 aimdb-mqtt-connector/tests/link_ext_tests.rs | 2 +- aimdb-mqtt-connector/tests/topic_provider_tests.rs | 2 +- 9 files changed, 24 insertions(+), 25 deletions(-) rename aimdb-mqtt-connector/src/{embedded => }/sntp_codec.rs (100%) diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 27b942d1..ce8a5b79 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -23,11 +23,6 @@ pub mod manager; pub mod session; -// SNTP wire codec — pure and feature-independent so it is unit-tested on the -// host; only the TLS I/O task consumes it. -#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] -pub(crate) mod sntp_codec; - // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] pub mod sntp; @@ -81,7 +76,8 @@ type EmbassyBoxFuture = Pin + Send + 'static>>; type ManagerSetup = (Arc, Arc, Vec); /// Outbound publishes and subscriptions: pumps to broker session. -pub(crate) type ActionChannel = crate::embedded::manager::ActionChannel; +pub(crate) type ActionChannel = + crate::embedded::manager::ActionChannel; /// Inbound messages: broker session to pumps. pub(crate) type EventChannel = crate::embedded::manager::EventChannel; diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index e9e2af0b..dd431881 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -146,7 +146,9 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::embedded::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; + use crate::embedded::manager::{ + handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, + }; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index d887cc8b..8c6e97a1 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -14,7 +14,7 @@ use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, Stack}; use embassy_time::{with_timeout, Duration, Instant, Timer}; -use crate::embedded::sntp_codec; +use crate::sntp_codec; /// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is /// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 42b123bc..39c20b45 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,6 +1,6 @@ -//! TLS transport for the Embassy MQTT client. +//! The TLS transport for the embedded backend. //! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy +//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over an Embassy //! TCP socket, presented to the MQTT layer as its own `Connection` — not //! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give //! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) @@ -43,10 +43,8 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embedded::{ - AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, -}; use crate::embedded::sntp::{self, SntpClock}; +use crate::embedded::{AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 9537c0e2..d4a3da72 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -109,19 +109,24 @@ pub mod native; #[cfg(feature = "embedded")] pub mod embedded; +// SNTP wire codec — pure and feature-independent so it is unit-tested on the +// host; only the TLS I/O task consumes it. +#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] +pub(crate) mod sntp_codec; + // Deprecated module names, kept for one release so existing imports keep // working. The modules no longer name a runtime. -#[cfg(feature = "std")] -#[deprecated(since = "0.7.0", note = "renamed to `native`")] -pub use crate::native as tokio_client; #[cfg(feature = "embedded")] #[deprecated(since = "0.7.0", note = "renamed to `embedded`")] pub use crate::embedded as embassy_client; +#[cfg(feature = "std")] +#[deprecated(since = "0.7.0", note = "renamed to `native`")] +pub use crate::native as tokio_client; #[cfg(feature = "embedded")] pub use connector::Embedded; #[cfg(feature = "embassy-tls")] pub use connector::EmbeddedTls; +pub use connector::{MqttConnector, Native}; #[cfg(feature = "embassy-tls")] pub use embedded::tls::TlsOptions; -pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index 8ca4954b..72a19307 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -1,10 +1,8 @@ -//! MQTT client management and lifecycle +//! The `rumqttc` backend: one broker connection, QoS 0–2, platform trust roots. //! -//! This module provides a client pool that: -//! - Manages a single MQTT broker connection -//! - Automatic event loop spawning -//! - Thread-safe access from multiple consumers -//! - Explicit lifecycle management (user controls when clients are created) +//! `rumqttc` owns its socket, TLS and reconnect, so this module contributes +//! only the connect-and-subscribe step and the `MqttSink`/`MqttEventLoopSource` +//! adapters that core's pumps drive. use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; diff --git a/aimdb-mqtt-connector/src/embedded/sntp_codec.rs b/aimdb-mqtt-connector/src/sntp_codec.rs similarity index 100% rename from aimdb-mqtt-connector/src/embedded/sntp_codec.rs rename to aimdb-mqtt-connector/src/sntp_codec.rs diff --git a/aimdb-mqtt-connector/tests/link_ext_tests.rs b/aimdb-mqtt-connector/tests/link_ext_tests.rs index 60a78cef..686efd4a 100644 --- a/aimdb-mqtt-connector/tests/link_ext_tests.rs +++ b/aimdb-mqtt-connector/tests/link_ext_tests.rs @@ -4,7 +4,7 @@ //! the extension methods push exactly the `("qos", …)` / `("retain", …)` //! option keys the MQTT clients read from `protocol_options`. -#![cfg(feature = "tokio-runtime")] +#![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index aa271903..14b04680 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -6,7 +6,7 @@ //! //! The tests use mock data and don't require a running MQTT broker. -#![cfg(feature = "tokio-runtime")] +#![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; use aimdb_core::connector::TopicProvider; From 7e95b31e3741039b34db0925128cb77472881d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:04:25 +0000 Subject: [PATCH 16/20] fix(tls): correct variable name for port in connection log --- aimdb-mqtt-connector/src/embedded/tls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 39c20b45..992d9aee 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -300,7 +300,7 @@ pub(crate) async fn run_tls( "MQTT-TLS: connecting to {} ({}) port {}...", host.as_str(), address, - settings.port + port ); if let Err(e) = socket.connect((address, port)).await { #[cfg(feature = "defmt")] From b234e85acb685793672f8972de4c843c8975b5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:09:13 +0000 Subject: [PATCH 17/20] docs: update feature descriptions and clarify backend distinctions in lib.rs --- aimdb-mqtt-connector/src/lib.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index d4a3da72..414ff788 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -6,14 +6,20 @@ //! //! ## Features //! -//! - `tokio-runtime`: Tokio-based connector using `rumqttc` -//! - `embassy-runtime`: Embassy connector for embedded systems using `mountain-mqtt` -//! - `embassy-tls`: TLS (`mqtts://`), broker authentication, DNS, and the -//! SNTP time source for the Embassy connector -//! - `tracing`: Debug logging support (std) -//! - `defmt`: Debug logging support (no_std) -//! -//! ## Tokio Usage (Standard Library) +//! The split is std vs `no_std`, not Tokio vs Embassy: the embedded backend +//! runs on any target that can supply a `StreamDialer`. +//! +//! - `std`: the `rumqttc` backend (QoS 0–2, platform trust roots) +//! - `embedded`: the `mountain-mqtt` backend over a caller-supplied transport; +//! `alloc` only, with no executor, network stack or adapter +//! - `embassy-runtime`: `embedded` plus the Embassy transport and clock +//! - `embassy-tls`: TLS (`mqtts://`), DNS and the SNTP time source, on Embassy +//! - `critical-section-std-impl`: links a `critical-section` impl for std +//! binaries, which the session channels need +//! - `tokio-runtime`: deprecated alias for `std` +//! - `tracing` / `defmt`: logging destinations +//! +//! ## Std Usage //! //! ```no_run //! use aimdb_core::AimDbBuilder; From c5a2db0579b0a59bf0532bfe70a4b33fc0654f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:56:15 +0000 Subject: [PATCH 18/20] feat: add embedded TLS support for MQTT connector - Introduced a new feature `_test-tls-broker` for testing MQTT over TLS with a pinned self-signed root CA. - Updated the Makefile to include new test commands for the MQTT connector with TLS. - Modified `Cargo.toml` to add dependencies for embedded TLS and SNTP. - Refactored the `EmbeddedTls` struct to use a caller-supplied transport instead of owning the network stack. - Implemented a new `WallClock` for certificate validity checks in the absence of an RTC. - Created a new test `tls_broker.rs` to validate the MQTT handshake over TLS. - Updated the example to demonstrate the use of MQTT over TLS with SNTP for time synchronization. --- Cargo.lock | 40 ++++ Makefile | 8 + aimdb-mqtt-connector/Cargo.toml | 26 ++- aimdb-mqtt-connector/src/connector.rs | 48 ++-- aimdb-mqtt-connector/src/embedded/mod.rs | 120 ++++++---- aimdb-mqtt-connector/src/embedded/sntp.rs | 14 +- aimdb-mqtt-connector/src/embedded/tls.rs | 215 ++++++++++-------- aimdb-mqtt-connector/src/lib.rs | 4 +- aimdb-mqtt-connector/tests/common/mod.rs | 13 +- aimdb-mqtt-connector/tests/tls_broker.rs | 176 ++++++++++++++ .../embassy-mqtt-connector-demo/src/main.rs | 12 +- 11 files changed, 489 insertions(+), 187 deletions(-) create mode 100644 aimdb-mqtt-connector/tests/tls_broker.rs diff --git a/Cargo.lock b/Cargo.lock index d3b1d209..4a64a5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,12 +295,15 @@ dependencies = [ "futures-core", "futures-util", "heapless 0.8.0", + "rand 0.8.6", "rand_core 0.6.4", + "rcgen", "rumqttc", "rustls-native-certs", "serde", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tokio-test", "uuid", ] @@ -3191,6 +3194,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3370,6 +3383,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -3420,6 +3434,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "rand_core" @@ -3436,6 +3453,19 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "readme-quickstart" version = "1.1.0" @@ -4360,6 +4390,7 @@ dependencies = [ "deranged", "num-conv", "powerfmt", + "serde_core", "time-core", ] @@ -5680,6 +5711,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Makefile b/Makefile index 4e809a7f..79b962da 100644 --- a/Makefile +++ b/Makefile @@ -240,6 +240,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker @printf "$(YELLOW) → Testing MQTT connector (both backends, one broker, one process)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity + @printf "$(YELLOW) → Testing MQTT connector (mqtts:// against a pinned self-signed root)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -350,6 +352,8 @@ clippy: @printf "$(YELLOW) → Clippy on MQTT connector (Embassy bundle + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded-tls" -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (Embassy + TLS + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on KNX connector (embassy + defmt)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @@ -379,6 +383,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (backend parity)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (mqtts:// host smoke)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" @@ -500,6 +506,8 @@ test-embedded: cargo check --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" @printf "$(YELLOW) → Checking aimdb-sync (no_std) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-sync --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (runtime-neutral TLS) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embedded-tls" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy + TLS) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,embassy-tls" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 1a9c5684..5d63844d 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -68,15 +68,18 @@ embassy-runtime = [ "embassy-net", ] -# TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 -# session over the Embassy TCP socket, pure-Rust certificate verification -# (`rustpki`; `rsa`/`p384` so public CA chains verify out of the box), broker -# hostname resolution (embassy-net DNS), and the SNTP time source (UDP). +# TLS (`mqtts://`) for the embedded backend — design 044. An `embedded-tls` +# 1.3 session over the caller's transport, with pure-Rust certificate +# verification (`rustpki`; `rsa`/`p384` so public CA chains verify out of the +# box). Runtime-neutral: the dialer resolves the host and the runtime's wall +# clock dates the certificate. +embedded-tls = ["embedded", "dep:embedded-tls", "dep:rand_core"] + +# `embedded-tls` plus the SNTP time source, for a board with no RTC. Needs a +# network stack of its own, which is why it is the Embassy half. embassy-tls = [ + "embedded-tls", "embassy-runtime", - "dep:embedded-tls", - "dep:embedded-io-async", - "dep:rand_core", "embassy-net/dns", "embassy-net/udp", ] @@ -110,6 +113,11 @@ _test-tokio-broker = [ # (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. _test-backend-parity = ["_test-tokio-broker", "std"] +# Internal: the embedded backend's `mqtts://` host smoke against a local broker +# with a self-signed certificate pinned as the root CA +# (`tests/tls_broker.rs`). Run with `--features _test-tls-broker`. +_test-tls-broker = ["_test-tokio-broker", "embedded-tls"] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off @@ -191,6 +199,10 @@ embassy-net-driver-channel = { version = "0.4.0", optional = true } critical-section = { version = "1.1", optional = true } [dev-dependencies] +# The `mqtts://` host smoke: a self-signed certificate and a real TLS server. +rand = "0.8" +rcgen = "0.13" +tokio-rustls = "0.26" tokio = { workspace = true, features = ["full"] } heapless = { workspace = true } futures = "0.3" diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 062f69bd..32511190 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -39,14 +39,11 @@ pub struct Embedded { pub(crate) dialer: D, } -/// The `mountain-mqtt` backend over `embedded-tls`. -/// -/// TLS keeps the network stack rather than taking a dialer: it resolves DNS -/// itself and owns buffers across sessions, which a per-session dialer cannot -/// express. -#[cfg(feature = "embassy-tls")] -pub struct EmbeddedTls { - pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, +/// The `mountain-mqtt` backend over `embedded-tls`, on the same +/// caller-supplied transport as the plain path. +#[cfg(feature = "embedded-tls")] +pub struct EmbeddedTls { + pub(crate) dialer: D, pub(crate) options: crate::embedded::TlsSlot, } @@ -85,22 +82,22 @@ impl MqttConnector { } } - /// Provide the network stack and TLS materials for an `mqtts://` broker. - #[cfg(feature = "embassy-tls")] - pub fn tls( + /// Dial `mqtts://` sessions through an adapter's stream dialer, with + /// `options` supplying the trust root, buffers and entropy. + /// + /// The dialer resolves the host, so TLS needs no network stack of its own. + #[cfg(feature = "embedded-tls")] + pub fn tls( self, - stack: &'static embassy_net::Stack<'static>, + dialer: D, options: crate::embedded::tls::TlsOptions, - ) -> MqttConnector { + ) -> MqttConnector> { MqttConnector { broker_url: self.broker_url, client_id: self.client_id, credentials: self.credentials, backend: EmbeddedTls { - // SAFETY: AimDB's Embassy integration requires a single-core - // cooperative executor (the adapter's module-level invariant); - // every future touching this stack is polled on that executor. - stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + dialer, options: crate::embedded::TlsSlot::new(options), }, } @@ -133,8 +130,8 @@ mod sealed { impl Sealed for super::Native {} #[cfg(feature = "embedded")] impl Sealed for super::Embedded {} - #[cfg(feature = "embassy-tls")] - impl Sealed for super::EmbeddedTls {} + #[cfg(feature = "embedded-tls")] + impl Sealed for super::EmbeddedTls {} } /// A backend with a build path compiled in. @@ -193,8 +190,17 @@ where } } -#[cfg(feature = "embassy-tls")] -impl Backend for EmbeddedTls { +#[cfg(feature = "embedded-tls")] +impl Backend for EmbeddedTls +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a AimDb, diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index ce8a5b79..8e080261 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -26,7 +26,7 @@ pub mod session; // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] pub mod sntp; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub mod tls; extern crate alloc; @@ -45,6 +45,7 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; +#[cfg(feature = "embedded-tls")] #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; @@ -54,10 +55,10 @@ use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; use crate::embedded::manager::{MqttEvent, Settings}; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use crate::embedded::tls::TlsOptions; -#[cfg(feature = "embassy-tls")] -use crate::embedded::tls::{host_ip_literal, run_tls, READ_BUF_MIN}; +#[cfg(feature = "embedded-tls")] +use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -275,7 +276,7 @@ impl aimdb_core::session::Source for MqttSource { /// /// Core's cell supplies both without `unsafe`: it is `Send + Sync` for any /// `T: Send`, which is what the `+ Send` on [`TlsOptions`]'s RNG buys. -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub(crate) type TlsSlot = aimdb_core::session::OneShot; /// Connect and collect the data-plane futures for a plain `mqtt://` session. @@ -315,14 +316,23 @@ where } /// Connect and collect the data-plane futures for an `mqtts://` session. -#[cfg(feature = "embassy-tls")] -pub(crate) fn build_tls<'a>( +#[cfg(feature = "embedded-tls")] +pub(crate) fn build_tls<'a, D>( db: &'a aimdb_core::builder::AimDb, broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, - backend: &'a crate::connector::EmbeddedTls, -) -> Pin>> + Send + 'a>> { + backend: &'a crate::connector::EmbeddedTls, +) -> Pin>> + Send + 'a>> +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ Box::pin(async move { let topics = inbound_topics(db); let broker = parse_broker_url(broker_url)?; @@ -339,7 +349,7 @@ pub(crate) fn build_tls<'a>( &broker, options, connection_settings, - backend.stack, + backend.dialer.clone(), topics, db.runtime_ops(), )?; @@ -500,15 +510,24 @@ where /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source /// task. Synchronous — no `.await` — so the caller's `build` future stays /// `Send`. -#[cfg(feature = "embassy-tls")] -fn setup_tls_manager( +#[cfg(feature = "embedded-tls")] +fn setup_tls_manager( broker: &BrokerUrl, options: TlsOptions, connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, + dialer: D, topics: Vec, runtime: Arc, -) -> Result { +) -> Result +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ match host_ip_literal(&broker.host) { Some(core::net::IpAddr::V6(_)) => { return Err(build_err( @@ -534,44 +553,57 @@ fn setup_tls_manager( let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); - let network = stack.get(); let host = broker.host.clone(); let port = broker.port; - let sntp_server = options.sntp_server; + #[cfg(feature = "embassy-tls")] + let sntp = options.sntp; - let manager_task = into_box_future({ - let actions = actions.clone(); - let events = events.clone(); - async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS background task starting"); + let delay = dialer.clone(); + // SAFETY: as for the plain path — `StreamDialer` guarantees `Stream: Send`, + // the channels are `CriticalSectionRawMutex`, and `TlsOptions` is `Send` + // (its RNG carries the bound). See `session::SendSession`. + #[cfg_attr(not(feature = "embassy-tls"), allow(unused_mut))] + let mut tasks: Vec = alloc::vec![Box::pin(unsafe { + crate::embedded::session::SendSession::new({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS background task starting"); + + #[allow(unreachable_code)] + { + let _: () = crate::embedded::tls::run_tls( + dialer, + options, + host, + port, + topics, + connection_settings, + Settings::default(), + events, + actions, + delay, + runtime, + ) + .await; + } + } + }) + }) as EmbassyBoxFuture]; + // Only a runtime with no wall clock of its own needs this. + #[cfg(feature = "embassy-tls")] + if let Some((stack, server)) = sntp { + tasks.push(into_box_future(async move { #[allow(unreachable_code)] { - let _: () = run_tls( - *network, - options, - host, - port, - topics, - connection_settings, - Settings::default(), - events, - actions, - runtime, - ) - .await; + let _: () = crate::embedded::sntp::run(*stack.get(), server).await; } - } - }); - let sntp_task = into_box_future(async move { - #[allow(unreachable_code)] - { - let _: () = crate::embedded::sntp::run(*network, sntp_server).await; - } - }); + })); + } - Ok((actions, events, alloc::vec![manager_task, sntp_task])) + Ok((actions, events, tasks)) } /// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index 8c6e97a1..7414d7c5 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -46,17 +46,6 @@ pub fn unix_now() -> Option { } } -/// `embedded-tls` clock over the SNTP-synced time; `None` before the first -/// sync (the TLS manager never handshakes in that state, so certificate -/// validity is always actually checked). -pub struct SntpClock; - -impl embedded_tls::TlsClock for SntpClock { - fn now() -> Option { - unix_now() - } -} - /// Keep the clock synced: query `server` until the first success, then /// re-sync hourly. Runs forever; spawned by the TLS connector build. pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { @@ -69,6 +58,9 @@ pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { match u32::try_from(unix_secs.saturating_sub(Instant::now().as_secs())) { Ok(boot @ 1..) => { BOOT_UNIX_SECS.store(boot, Ordering::Relaxed); + // The TLS handshake reads the certificate-validity + // clock, which a board with no RTC has only from here. + crate::embedded::tls::WallClock::set_unix_secs(unix_secs as u32); #[cfg(feature = "defmt")] defmt::info!("SNTP: synced, unix time {}", unix_secs); Timer::after(RESYNC_INTERVAL).await; diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 992d9aee..3c9090bb 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -20,10 +20,6 @@ use core::cell::RefCell; use core::net::IpAddr; use alloc::sync::Arc; -use embassy_net::dns::DnsQueryType; -use embassy_net::tcp::TcpSocket; -use embassy_net::{IpAddress, Stack}; -use embassy_time::{Delay, Timer}; use embedded_tls::pki::CertVerifier; use embedded_tls::{ @@ -43,7 +39,6 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embedded::sntp::{self, SntpClock}; use crate::embedded::{AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB @@ -67,7 +62,10 @@ pub struct TlsOptions { pub(crate) ca_der: &'static [u8], pub(crate) read_buf: &'static mut [u8], pub(crate) write_buf: &'static mut [u8], - pub(crate) sntp_server: &'static str, + /// Where the certificate-validity clock comes from on a board with no RTC. + /// `None` means the runtime's own wall clock answers. + #[cfg(feature = "embassy-tls")] + pub(crate) sntp: Option<(aimdb_embassy_adapter::connectors::NetStack, &'static str)>, } impl TlsOptions { @@ -92,56 +90,70 @@ impl TlsOptions { ca_der, read_buf, write_buf, - sntp_server: "pool.ntp.org", + #[cfg(feature = "embassy-tls")] + sntp: None, } } - /// Override the SNTP server used as the certificate-validation time - /// source (default `pool.ntp.org`). - pub fn with_sntp_server(mut self, server: &'static str) -> Self { - self.sntp_server = server; + /// Take the certificate-validity clock from SNTP over `stack`. + /// + /// Needed only where the runtime has no wall clock of its own — an MCU + /// with no RTC. A host runtime answers `unix_time()` and needs no task. + #[cfg(feature = "embassy-tls")] + pub fn with_sntp( + mut self, + stack: &'static embassy_net::Stack<'static>, + server: &'static str, + ) -> Self { + // SAFETY: AimDB's Embassy integration requires a single-core + // cooperative executor (the adapter's module-level invariant); the + // SNTP task touching this stack is polled on that executor. + self.sntp = Some(( + unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + server, + )); self } } -/// The TCP socket shared between the TLS session (its transport) and the +/// The stream shared between the TLS session (its transport) and the /// MQTT-level readiness probe ([`TlsSession::receive_if_ready`]), which needs -/// `can_recv()` after the socket has been handed to `embedded-tls`. +/// to ask the wire after the stream has been handed to `embedded-tls`. /// /// Borrow discipline: the session task drives exactly one client operation at /// a time, so a `borrow_mut` held across an I/O `.await` can never overlap /// the probe's short `borrow` — both are called sequentially from the same /// loop. -struct SharedTcp<'r, 'a>(&'r RefCell>); +struct SharedStream<'r, S>(&'r RefCell); -impl Clone for SharedTcp<'_, '_> { +impl Clone for SharedStream<'_, S> { fn clone(&self) -> Self { Self(self.0) } } -impl SharedTcp<'_, '_> { +impl SharedStream<'_, S> { fn can_recv(&self) -> bool { - self.0.borrow().can_recv() + self.0.borrow_mut().read_ready().unwrap_or(false) } } -impl embedded_io_async::ErrorType for SharedTcp<'_, '_> { - type Error = embassy_net::tcp::Error; +impl embedded_io_async::ErrorType for SharedStream<'_, S> { + type Error = S::Error; } // The held-across-await borrows below are safe by the struct-level borrow // discipline (sequential single-task use); a panic would mean a second client // operation ran concurrently, which the session loop cannot do. #[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Read for SharedTcp<'_, '_> { +impl embedded_io_async::Read for SharedStream<'_, S> { async fn read(&mut self, buf: &mut [u8]) -> Result { self.0.borrow_mut().read(buf).await } } #[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Write for SharedTcp<'_, '_> { +impl embedded_io_async::Write for SharedStream<'_, S> { async fn write(&mut self, buf: &[u8]) -> Result { self.0.borrow_mut().write(buf).await } @@ -164,14 +176,20 @@ impl embedded_io_async::Write for SharedTcp<'_, '_> { /// (unsolicited session tickets, KeyUpdate) make `receive` wait for the next /// real record; if the broker stays silent, the keep-alive lapse tears the /// session down and the manager reconnects. -struct TlsSession<'r, 'a, 'b> { - tls: TlsConnection<'b, SharedTcp<'r, 'a>, Aes128GcmSha256>, - socket: SharedTcp<'r, 'a>, +struct TlsSession<'r, 'b, S> +where + S: embedded_io_async::Read + embedded_io_async::Write, +{ + tls: TlsConnection<'b, SharedStream<'r, S>, Aes128GcmSha256>, + socket: SharedStream<'r, S>, /// Decrypted-but-unread plaintext left in the TLS record buffer. plaintext_remaining: usize, } -impl Connection for TlsSession<'_, '_, '_> { +impl Connection for TlsSession<'_, '_, S> +where + S: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ async fn send(&mut self, buf: &[u8]) -> Result<(), PacketWriteError> { self.tls .write_all(buf) @@ -206,13 +224,47 @@ impl Connection for TlsSession<'_, '_, '_> { } } +/// Unix seconds for certificate validity, refreshed before each handshake. +/// +/// `embedded_tls::TlsClock::now` is a static method, so the reading has to +/// reach it through a global. The source is whatever the runtime's wall clock +/// reports; a runtime with no clock of its own (an MCU without an RTC) gets +/// one from the SNTP task instead. +static UNIX_SECS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); + +/// The certificate-validity clock. `u32` is unambiguous until 2106 and stays a +/// single atomic on Cortex-M, which has no 64-bit atomics. +pub(crate) struct WallClock; + +impl WallClock { + /// Record a wall-clock reading. Ignores a zero, which means "unknown". + pub(crate) fn set_unix_secs(secs: u32) { + if secs != 0 { + UNIX_SECS.store(secs, core::sync::atomic::Ordering::Relaxed); + } + } + + fn unix_secs() -> Option { + match UNIX_SECS.load(core::sync::atomic::Ordering::Relaxed) { + 0 => None, + secs => Some(u64::from(secs)), + } + } +} + +impl embedded_tls::TlsClock for WallClock { + fn now() -> Option { + Self::unix_secs() + } +} + /// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate -/// verification (time from [`SntpClock`]). Client-certificate signing is +/// verification (time from [`WallClock`]). Client-certificate signing is /// deliberately absent — the mesh authenticates with MQTT credentials /// instead. struct TrngProvider<'a> { - rng: &'a mut dyn CryptoRngCore, - verifier: CertVerifier<'static, Aes128GcmSha256, SntpClock, CERT_BUFFER_SIZE>, + rng: &'a mut (dyn CryptoRngCore + Send), + verifier: CertVerifier<'static, Aes128GcmSha256, WallClock, CERT_BUFFER_SIZE>, } impl CryptoProvider for TrngProvider<'_> { @@ -229,13 +281,14 @@ impl CryptoProvider for TrngProvider<'_> { } } -/// The TLS broker manager: resolve → TCP → TLS handshake → MQTT session, -/// reconnecting forever with the same [`Settings`] cadence as the plain -/// path's `mqtt_manager::run` (`settings.address` is unused — the TLS path -/// resolves `host` per attempt instead). +/// The TLS broker manager: dial → TLS handshake → MQTT session, reconnecting +/// forever with the same [`Settings`] cadence as the plain path. +/// +/// The dialer resolves the host, so there is no DNS here and no network stack: +/// any runtime whose streams offer the `embedded-io-async` trio can run this. #[allow(clippy::too_many_arguments)] -pub(crate) async fn run_tls( - stack: Stack<'static>, +pub(crate) async fn run_tls( + dialer: D, options: TlsOptions, host: String, port: u16, @@ -244,8 +297,13 @@ pub(crate) async fn run_tls( settings: Settings, events: Arc, actions: Arc, + delay: D, runtime: Arc, -) -> ! { +) -> ! +where + D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ let TlsOptions { rng, ca_der, @@ -254,8 +312,6 @@ pub(crate) async fn run_tls( .. } = options; - let mut rx_buffer = [0u8; BUFFER_SIZE]; - let mut tx_buffer = [0u8; BUFFER_SIZE]; let mut mqtt_buffer = [0u8; BUFFER_SIZE]; // Re-subscribed by `handle_messages` on every (re)connection, so inbound @@ -268,51 +324,36 @@ pub(crate) async fn run_tls( let mut connection_index = 0u32; loop { - // Certificate validity needs real time — hold the first handshake - // until SNTP has synced. - if sntp::unix_now().is_none() { + // Certificate validity needs real time. Take it from the runtime when + // it has a wall clock; otherwise wait for whatever feeds `WallClock` + // (the SNTP task, on a board with no RTC). + if let Some((secs, _)) = runtime.unix_time() { + WallClock::set_unix_secs(secs as u32); + } + if WallClock::unix_secs().is_none() { #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS: waiting for SNTP time sync..."); - while sntp::unix_now().is_none() { - Timer::after_millis(500).await; + defmt::info!("MQTT-TLS: waiting for a wall-clock reading..."); + while WallClock::unix_secs().is_none() { + if let Some((secs, _)) = runtime.unix_time() { + WallClock::set_unix_secs(secs as u32); + } + aimdb_core::session::Delay::sleep(&delay, core::time::Duration::from_millis(500)) + .await; } } - let address = match resolve(stack, &host).await { - Some(address) => address, - None => { + let stream = match dialer.connect(&host, port).await { + Ok(stream) => stream, + Err(_e) => { #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT-TLS: DNS lookup for {} failed, will retry", - host.as_str() - ); - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay) - .await; + defmt::warn!("MQTT-TLS: connect failed, will retry"); + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; continue; } }; - let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer); - socket.set_timeout(None); - - #[cfg(feature = "defmt")] - defmt::info!( - "MQTT-TLS: connecting to {} ({}) port {}...", - host.as_str(), - address, - port - ); - if let Err(e) = socket.connect((address, port)).await { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT-TLS: socket connect error, will retry: {:?}", e); - #[cfg(not(feature = "defmt"))] - let _ = e; - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; - continue; - } - - let socket = RefCell::new(socket); - let shared = SharedTcp(&socket); + let stream = RefCell::new(stream); + let shared = SharedStream(&stream); let tls_config = TlsConfig::new().with_server_name(&host); let mut tls = TlsConnection::new(shared.clone(), &mut *read_buf, &mut *write_buf); @@ -328,7 +369,7 @@ pub(crate) async fn run_tls( ); #[cfg(not(feature = "defmt"))] let _ = e; - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; continue; } #[cfg(feature = "defmt")] @@ -339,7 +380,6 @@ pub(crate) async fn run_tls( socket: shared, plaintext_remaining: 0, }; - let delay = DelayEmbedded::new(Delay); let timeout_millis = settings.response_timeout.as_millis() as u32; let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); @@ -358,7 +398,7 @@ pub(crate) async fn run_tls( let mut client = ClientNoQueue::new( connection, &mut mqtt_buffer, - delay, + DelayEmbedded::new(crate::embedded::session::ClientDelay(&delay)), timeout_millis, event_handler, ); @@ -372,7 +412,7 @@ pub(crate) async fn run_tls( &events, &actions, &settings, - &EmbassyCoreDelay, + &delay, runtime.as_ref(), ) .await @@ -387,26 +427,7 @@ pub(crate) async fn run_tls( .await; } - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; - } -} - -/// The TLS path keeps `embassy_time` for its own waits, so it supplies core's -/// [`Delay`](aimdb_core::session::Delay) to the shared message pump. -struct EmbassyCoreDelay; - -impl aimdb_core::session::Delay for EmbassyCoreDelay { - fn sleep(&self, d: core::time::Duration) -> impl core::future::Future + Send { - Timer::after(embassy_time::Duration::from_micros(d.as_micros() as u64)) - } -} - -/// Resolve the broker host to its first A record (IP literals short-circuit -/// inside `dns_query` without a network round trip). -async fn resolve(stack: Stack<'static>, host: &str) -> Option { - match stack.dns_query(host, DnsQueryType::A).await { - Ok(addresses) => addresses.first().copied(), - Err(_) => None, + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; } } diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 414ff788..92ded46f 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -131,8 +131,8 @@ pub use crate::native as tokio_client; #[cfg(feature = "embedded")] pub use connector::Embedded; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use connector::EmbeddedTls; pub use connector::{MqttConnector, Native}; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use embedded::tls::TlsOptions; diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index a19af195..7bf2b61b 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -36,7 +36,10 @@ impl Seen { /// Read one MQTT packet: a fixed header byte, a varint remaining-length, then /// that many bytes. -async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { +async fn read_packet(socket: &mut S, buf: &mut Vec) -> Option<(u8, Vec)> +where + S: tokio::io::AsyncRead + Unpin, +{ let mut byte = [0u8; 1]; socket.read_exact(&mut byte).await.ok()?; let first = byte[0]; @@ -203,6 +206,14 @@ pub struct AfterSuback<'a> { /// Serve one connection until it closes. async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_>) { + serve_stream(socket, seen, after).await +} + +/// The broker loop over any stream, so a TLS session drives the same code. +pub async fn serve_stream(socket: &mut S, seen: &Mutex, after: AfterSuback<'_>) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ let mut buf = Vec::new(); let mut v5 = true; diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs new file mode 100644 index 00000000..541b26e7 --- /dev/null +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -0,0 +1,176 @@ +//! `mqtts://` on the host: the embedded backend against a local broker whose +//! self-signed certificate is pinned as the root CA (`_test-tls-broker`). +//! +//! The first host coverage the TLS path has had. It runs the same +//! `embedded-tls` session an MCU runs, over `TokioNet::tcp()`, with the clock +//! from the runtime's wall clock and no SNTP task. +#![cfg(feature = "_test-tls-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; + +mod common; +use common::{serve_stream, AfterSuback, Seen}; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +defmt::timestamp!("{=u64:us}", 0); + +/// The name the certificate is issued for, and the name the client verifies. +/// A hostname rather than an IP literal: `rustpki` matches an IP only through +/// the CN fallback, which is a narrower path than this test should depend on. +const BROKER_HOST: &str = "localhost"; + +/// A self-signed certificate for `localhost`, returned as (server chain, +/// server key, root CA in DER) — the same bytes on both sides, which is what +/// "pinned root" means. +fn self_signed() -> ( + CertificateDer<'static>, + PrivateKeyDer<'static>, + &'static [u8], +) { + let cert = rcgen::generate_simple_self_signed(vec![BROKER_HOST.to_string()]) + .expect("generate self-signed certificate"); + let der = cert.cert.der().to_vec(); + let key = PrivateKeyDer::try_from(cert.key_pair.serialize_der()).expect("server key"); + // `&'static` because `TlsOptions` holds the trust root for the session's + // whole life; one leak per test process. + let ca: &'static [u8] = Box::leak(der.clone().into_boxed_slice()); + (CertificateDer::from(der), key, ca) +} + +/// Accept TLS connections and serve the same fake MQTT broker over them. +async fn tls_broker( + listener: TcpListener, + acceptor: TlsAcceptor, + seen: Arc>, + push: Option<(&'static str, &'static [u8])>, +) { + loop { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let acceptor = acceptor.clone(); + let seen = seen.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(socket).await else { + return; + }; + let after = AfterSuback { + hang_up: false, + push, + }; + serve_stream(&mut stream, &seen, after).await; + }); + } +} + +/// A `mqtts://` session completes and round-trips a record, with the +/// certificate verified against the pinned root and the clock from +/// `SystemTime` — no SNTP anywhere. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_embedded_backend_completes_an_mqtts_handshake_against_a_pinned_root() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, TlsOptions}; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let (chain, key, ca_der) = self_signed(); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![chain], key) + .expect("server config"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + // `TlsOptions` holds `&'static mut` buffers and RNG: on a board these are + // `StaticCell`s, here one leak apiece. + let rng: &'static mut (dyn embedded_tls::CryptoRngCore + Send) = + Box::leak(Box::new(rand::rngs::StdRng::from_entropy())); + let read_buf: &'static mut [u8] = Box::leak(vec![0u8; 16_640].into_boxed_slice()); + let write_buf: &'static mut [u8] = Box::leak(vec![0u8; 4_096].into_boxed_slice()); + + let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) + .tls( + TokioNet::tcp(), + TlsOptions::new(rng, ca_der, read_buf, write_buf), + ) + .with_client_id("tls-host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + let (db, runner) = builder.build().await.expect("build db"); + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let broker = tls_broker( + listener, + acceptor, + seen.clone(), + Some(("sensors/temperature", b"23")), + ); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + value = inbound.recv() => value.expect("inbound record"), + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {:?} subscribed — the handshake never completed", + seen.connects, + seen.subscribed_topics() + ); + } + }; + + assert_eq!( + received, 23, + "the message must arrive through the TLS session" + ); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.connects, 1, "exactly one MQTT session over TLS"); + assert!( + seen.subscribed_topics().contains(&"sensors/temperature"), + "the session must subscribe over TLS; saw {:?}", + seen.subscribed_topics() + ); +} + +use rand::SeedableRng as _; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 248b7ca0..3d703b5e 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -402,23 +402,27 @@ async fn main(spawner: Spawner) { .with_client_id("embassy-demo-001") }; - // `mqtts://` keeps the stack: TLS resolves DNS itself and owns its buffers - // across sessions. The board's TRNG, the broker's root CA, and the record + // `mqtts://` dials through the same transport as `mqtt://`; the adapter + // resolves the host. The board's TRNG, the broker's root CA, and the record // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer may // send full-size records). `init_with` keeps the arrays off the stack. + // This board has no RTC, so the validity clock comes from SNTP. #[cfg(feature = "tls")] let mqtt = { + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); let mqtt = MqttConnector::new(&broker_url) .tls( - stack, + EmbassyNet::tcp(*stack, MQTT_RX.init([0; 4096]), MQTT_TX.init([0; 4096])), TlsOptions::new( rng, MQTT_CA_DER, TLS_READ_BUF.init_with(|| [0; 16_640]), TLS_WRITE_BUF.init_with(|| [0; 4_096]), - ), + ) + .with_sntp(stack, "pool.ntp.org"), ) .with_client_id("embassy-demo-001"); match MQTT_CREDENTIALS { From 3b4423c76dbad1b6f3b6880dacd9d9eb33497959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 22:04:51 +0000 Subject: [PATCH 19/20] feat: update aimdb-mqtt-connector to version 0.7.0 with breaking changes and enhanced features for std and no_std runtimes --- Cargo.lock | 2 +- aimdb-embassy-adapter/CHANGELOG.md | 6 + aimdb-mqtt-connector/CHANGELOG.md | 64 ++++++ aimdb-mqtt-connector/Cargo.toml | 4 +- aimdb-mqtt-connector/README.md | 83 ++++--- aimdb-tokio-adapter/CHANGELOG.md | 3 + .../012-M5-connector-development-guide.md | 210 ++++++++++++------ 7 files changed, 270 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a64a5c0..8468ee1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,7 +273,7 @@ dependencies = [ [[package]] name = "aimdb-mqtt-connector" -version = "0.6.0" +version = "0.7.0" dependencies = [ "aimdb-core", "aimdb-data-contracts", diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index ec61f9fe..444f092c 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`Delay` for `EmbassyTcpDialer`** (feature `embassy-time`). The dialer + supplies the session clock, so a connector generic over it needs no separate + handle — which is what keeps the MQTT call sites unchanged. + ### Changed (breaking) - **Issue #131 — `EmbassyAdapter` is a stateless unit type; network capability moves to connector construction.** The `EmbassyNetwork` trait and `EmbassyAdapter::new_with_network` are deleted (an `Arc` runtime can't surface adapter-specific capabilities); network connectors take the `embassy_net::Stack` at construction, wrapped in the new force-`Send + Sync` `connectors::NetStack` so the single-core `unsafe` stays in the audited `connectors` module — the adapter itself now carries **zero `unsafe`**. `EmbassyAdapter::new()` returns `Self` (was a never-failing `ExecutorResult` forcing `.unwrap()` at every call site) and `new_db_result()` is deleted. `NetStack::new` is an `unsafe fn`: the force-`Send + Sync` rests on the single-core cooperative-executor invariant, which the constructor cannot check, so each connector constructing one acknowledges it with a `SAFETY` comment (constructing on a multicore / multi-executor setup is UB). `EmbassyRecordRegistrarExt` shrinks to `.buffer(cfg)`; `EmbassyRecordRegistrarExtCustom` (`buffer_sized`, `source_with_context`) re-targets the non-generic `RecordRegistrar<'a, T>` with the concrete `RuntimeContext`, and `source_with_context` drops its needless `Sync` bounds (`Ctx: Send`, `F: Send`, matching core's relaxed `source`). `join_queue.rs` (`EmbassyJoinQueue`) is deleted with the `JoinFanInRuntime` family; the core join queue closes when forwarders exit (the Embassy queue previously never closed) and its capacity is 16 (was 8). diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a507fc00..cd9763a8 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **The backend split is std vs `no_std`, not Tokio vs Embassy.** The embedded + backend runs on any target whose adapter supplies a `StreamDialer`, so a new + platform costs one adapter crate and no change here. Features rename + accordingly: `std` carries the `rumqttc` backend (`tokio-runtime` is a + deprecated alias), `embedded` carries `mountain-mqtt` with `alloc` only — no + executor, network stack, adapter or logger in its graph — and `embassy-runtime` + becomes a convenience bundle over it. TLS splits the same way: `embedded-tls` + is runtime-neutral, `embassy-tls` adds the SNTP time source a board with no + RTC needs. Modules follow: `tokio_client` → `native`, `embassy_client` → + `embedded` (both kept as deprecated re-exports for one release). +- **One constructor.** `MqttConnector::new(url)` is unconditional, and the + transport — or its absence — picks the backend, so both compile into one + binary. Previously the two inherent `new`s collided with `E0034` whenever + both features were on. Broker URL, client id and credentials moved onto + `MqttConnector` itself, so `with_client_id` / `with_credentials` work on + either backend; `with_credentials` now reaches `rumqttc` too, taking + precedence over the URL authority. +- **`.tls(dialer, options)` replaces `.tls(stack, options)`.** The dialer + resolves the host, so TLS needs no network stack: DNS, the socket buffers and + the SNTP task all leave the TLS path. The certificate-validity clock comes + from `RuntimeOps::unix_time()`; SNTP is opt-in via `TlsOptions::with_sntp` + for a runtime with no wall clock of its own. +- **The `mountain-mqtt-embassy` fork is absorbed and dropped.** Its state, + event handler and message pump live in `embedded::manager`, with the mutex + and the clock as this crate's choices rather than the fork's. +- **Session channels use `CriticalSectionRawMutex` in an `Arc`.** They are + therefore `Sync`, so `MqttSink` and `MqttSource` are plain `Connector` / + `Source` impls and the `EmbassySink`/`EmbassySource` force-`Send` spine is + gone from the data plane. std binaries need a `critical-section` impl; the + `critical-section-std-impl` feature supplies one, mirroring the KNX connector. + A single documented `unsafe impl Send` remains on the session future: + `embedded-io-async` puts no `Send` bound on its futures and the loop reaches + them through a generic transport, which needs return-type notation to express + — still unstable on the pinned toolchain. It rests on `StreamDialer`'s + `Stream: Send` guarantee rather than on a single-core executor, so it holds + under a preemptive scheduler. +- **Time comes from core's `Delay`**, supplied by the dialer, so the session + loop names no executor. `Settings` is `core::time::Duration` and lost its + dead `address`/`port` fields. + +### Fixed + +- **A second connector in one process no longer steals the first's identity.** + Client id and credentials were parked in process-global `OnceLock`s, so every + connector after the first connected as the first. +- **One allocation per inbound message instead of two.** The payload is built + as a `Payload` on arrival rather than as a `Vec` that is converted again. +- **`defmt` is no longer forced on `mountain-mqtt`**, and is absent from the + `embedded` graph entirely. + +### Added + +- **Host coverage for the embedded backend**, which previously had none. A fake + MQTT broker over real sockets drives the session loop on a multi-thread Tokio + runtime: reconnect-and-resubscribe, record round-trip both ways, both backends + against one broker in one process, and — the first test the TLS path has ever + had — an `mqtts://` handshake against a self-signed certificate pinned as the + root CA, with no SNTP. +- **`#[diagnostic::on_unimplemented]` for a missing backend.** A `no_std` build + that forgets `.transport(..)` now gets a message naming the fix instead of an + unsatisfied `ConnectorBuilder` bound. + ### Changed - **One `MqttConnector` over two protocol backends (breaking on Embassy).** diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 5d63844d..4b5de4d6 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "aimdb-mqtt-connector" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true -description = "MQTT connector for AimDB - bidirectional pub/sub for Tokio and Embassy runtimes" +description = "MQTT connector for AimDB - bidirectional pub/sub on std and no_std runtimes" keywords = ["mqtt", "connector", "iot", "embedded", "pubsub"] categories = ["network-programming", "embedded", "asynchronous"] diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index a29276a7..ee2888f7 100644 --- a/aimdb-mqtt-connector/README.md +++ b/aimdb-mqtt-connector/README.md @@ -10,30 +10,26 @@ Add to your `Cargo.toml`: ```toml [dependencies] -# For Tokio runtime (std) -aimdb-mqtt-connector = { version = "0.2", features = ["tokio-runtime"] } +# The rumqttc backend (std): QoS 0-2, platform trust roots +aimdb-mqtt-connector = { version = "0.7", features = ["std"] } -# For Embassy runtime (embedded) -aimdb-mqtt-connector = { version = "0.2", features = ["embassy-runtime"] } +# The mountain-mqtt backend: any target that can supply a transport +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embedded"] } -# REQUIRED for Embassy: Patch mountain-mqtt to match Embassy versions -[patch.crates-io] -mountain-mqtt = { git = "https://github.com/aimdb-dev/mountain-mqtt.git", branch = "main" } -mountain-mqtt-embassy = { git = "https://github.com/aimdb-dev/mountain-mqtt.git", branch = "main" } +# ... or the Embassy convenience bundle, which adds the transport and clock +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embassy-runtime"] } ``` -**Why the patch?** -- Embassy dependency version compatibility -- Our workspace uses a specific Embassy version that differs from crates.io - -**Tokio runtime users**: The patch is optional but recommended for consistency. +The split is **std vs `no_std`**, not Tokio vs Embassy: the embedded backend +runs on any runtime whose adapter supplies a `StreamDialer`, so a new platform +needs an adapter crate and no change here. ## Overview -`aimdb-mqtt-connector` provides MQTT publishing capabilities for AimDB records with automatic consumer registration. Works seamlessly across standard library (Tokio) and embedded (Embassy) environments. +`aimdb-mqtt-connector` provides MQTT publishing capabilities for AimDB records with automatic consumer registration. One `MqttConnector` covers both backends: supply no transport and it is `rumqttc`; supply one with `.transport(..)` and it is `mountain-mqtt` over whatever the adapter dials. **Key Features:** -- **Dual Runtime Support**: Works with both Tokio and Embassy +- **Two backends, one type**: `rumqttc` on std, `mountain-mqtt` anywhere else - **Automatic Consumer Registration**: Connects to records via builder pattern - **Topic Mapping**: Flexible record-to-topic configuration - **Custom Serialization**: Pluggable serializers (JSON, MessagePack, etc.) @@ -88,9 +84,9 @@ async fn main() -> Result<(), Box> { Add to your `Cargo.toml`: ```toml [dependencies] -aimdb-core = { version = "0.1", default-features = false } -aimdb-embassy-adapter = { version = "0.1", default-features = false } -aimdb-mqtt-connector = { version = "0.1", default-features = false, features = ["embassy-runtime"] } +aimdb-core = { version = "1", default-features = false } +aimdb-embassy-adapter = { version = "0.6", default-features = false } +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embassy-runtime"] } ``` Example: @@ -100,7 +96,7 @@ Example: use aimdb_core::AimDbBuilder; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; use alloc::sync::Arc; #[embassy_executor::main] @@ -108,14 +104,17 @@ async fn main(spawner: Spawner) { // Initialize network stack let stack: &'static embassy_net::Stack<'static> = /* ... */; - // The adapter is a stateless unit type; the connector takes the - // network stack at construction. + // The adapter is a stateless unit type; the connector takes a transport + // from it, and nothing else about the runtime. let runtime = Arc::new(EmbassyAdapter::new()); // Build database with MQTT connector let mut builder = AimDbBuilder::new() .runtime(runtime) - .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack)); + .with_connector( + MqttConnector::new("mqtt://192.168.1.100:1883") + .transport(EmbassyNet::tcp(*stack, rx_buf, tx_buf)), + ); builder.configure::("sensor-data", |reg| { reg.buffer_sized::<4, 1>(EmbassyBufferType::SingleLatest) @@ -311,13 +310,16 @@ The connector automatically handles reconnection. Serialization errors will be l ## Features -```toml -[features] -tokio-runtime = ["dep:rumqttc", "dep:tokio"] # Tokio support -embassy-runtime = ["dep:mountain-mqtt"] # Embassy support -tracing = ["dep:tracing"] # Logging (std) -defmt = ["dep:defmt"] # Logging (embedded) -``` +| Feature | Backend | +|---|---| +| `std` | `rumqttc`: QoS 0-2, platform trust roots | +| `embedded` | `mountain-mqtt` over a caller-supplied transport; `alloc` only, no executor or network stack | +| `embedded-tls` | `mqtts://` via `embedded-tls`, on the same transport | +| `embassy-runtime` | `embedded` plus the Embassy transport and clock | +| `embassy-tls` | `embedded-tls` plus the SNTP time source, for a board with no RTC | +| `critical-section-std-impl` | links a `critical-section` impl, which a std binary needs | +| `tokio-runtime` | deprecated alias for `std` | +| `tracing` / `defmt` | logging destinations | ## Connection Management @@ -341,16 +343,29 @@ When broker is unavailable: docker run -d -p 1883:1883 eclipse-mosquitto # Run tests -cargo test -p aimdb-mqtt-connector --features tokio-runtime +cargo test -p aimdb-mqtt-connector --features std ``` -### Embassy Tests +### Embedded Tests + +The embedded backend runs on the host over the Tokio adapter's transport, so it +is covered by real tests rather than a cross-compile alone: + ```bash -# Cross-compile test -cargo build -p aimdb-mqtt-connector \ +# Host smoke: session loop, reconnect and record round-trip +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-tokio-broker --test tokio_broker + +# Both backends against one broker, in one process +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-backend-parity --test backend_parity + +# `mqtts://` against a pinned self-signed root +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-tls-broker --test tls_broker + +# Cross-compile check +cargo check -p aimdb-mqtt-connector \ --target thumbv7em-none-eabihf \ --no-default-features \ - --features embassy-runtime + --features embedded ``` ## Examples diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index 8cadf8a6..aa484190 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`Delay` and `Clone` for `TokioTcpDialer`.** The dialer supplies the session + clock and can be handed to several sessions, which is what lets the embedded + MQTT backend run on a host unchanged. - **`embedded-io` feature — the `embedded-io-async` trio on the `net` streams.** `TokioByteStream` implements `Read`/`Write` for any `AsyncRead`/`AsyncWrite`, and `ReadReady` on `TokioByteStream` via diff --git a/docs/design/012-M5-connector-development-guide.md b/docs/design/012-M5-connector-development-guide.md index 05954656..d3d434da 100644 --- a/docs/design/012-M5-connector-development-guide.md +++ b/docs/design/012-M5-connector-development-guide.md @@ -151,69 +151,147 @@ fn publish(&self, dest: &str, config: &ConnectorConfig, payload: &[u8]) -> ... { --- -## Tokio Implementation Pattern +## Choosing the Transport Seam (do this first) -**Dependencies:** -```toml -[features] -tokio-runtime = ["std", "tokio", "protocol-client-crate"] +Before writing any integration code, answer one question about the protocol +library you are considering: -[dependencies] -tokio = { workspace = true, optional = true } -# Add protocol-specific client library -``` +> **Does it hand me bytes, or does it hand me a client?** + +The answer fixes the shape of your connector and it is not recoverable later. +A library that owns its own socket will not accept yours no matter how the +adapter layer is designed. This is a **library-selection** decision, not an +implementation decision. + +### The three tiers + +| Tier | Who owns the protocol | Example in this workspace | Shape you get | +|---|---|---|---| +| **1** | **AimDB** — you write the framing | TCP (`framing.rs`, length-prefix), serial (COBS `Framer`) | Symmetric. The adapter supplies bytes on both std and embedded; one implementation | +| **2** | **A sans-io library**, AimDB owns the lifecycle | KNX — `knx-pico` is sans-io, `tunnel.rs` owns tunnelling behind a three-method `TunnelIo` | Symmetric. Design 052 §2 found the two halves already 90 % shared | +| **3** | **A batteries-included client** — owns socket, TLS, reconnect | `rumqttc` (MQTT std half), `axum` / `tokio-tungstenite` (WebSocket) | **Asymmetric, or std-only.** The library dials; you cannot inject a stream | + +Tiers 1 and 2 are the good cases and they cost the same to build. Tier 3 is +sometimes the right trade, a mature client buys QoS 2, a hardened TLS stack, +platform trust roots, but buy it knowingly. + +### How to tell which tier a candidate library is + +Read its constructor and its transport type before anything else: + +- **Tier 1/2 signature** — takes a connection, a stream or nothing: + ```rust + ClientNoQueue::new(connection, buffer, delay, timeout, handler) // mountain-mqtt + ``` + Anything generic over `embedded_io_async::{Read, Write}`, or over its own + minimal `Connection` trait, is injectable. Good. +- **Tier 3 signature** — takes options and an address: + ```rust + AsyncClient::new(mqtt_options, capacity) // rumqttc + mqtt_options.set_transport(Transport::Tls(..)) // closed enum + ``` + If the transport is a **closed enum** with no "bring your own stream" variant, + the library dials internally and the seam is fixed above it. + +Also check: does it pull `tokio` (or any executor) in its own `[dependencies]`, +or only `embedded-io-async` / `embedded-hal-async`? An executor dependency in +the protocol crate is a reliable tier-3 signal. + +### What each tier means for you + +| | Tier 1 / 2 | Tier 3 | +|---|---|---| +| Runtime neutrality | Free — one module, no runtime `cfg` | Not achievable for that half | +| New runtime (FreeRTOS, …) | Zero connector edits — a new adapter is enough | Needs a second backend, or the connector stays std-only | +| Host tests for the embedded path | Run the same code over the std adapter's transport | Only if a second, injectable backend exists | +| Cost | You write framing or lifecycle logic | The library writes it for you | + +### If you land on tier 3 + +Two legitimate outcomes, both present in this workspace: -**Key patterns:** -- Use `std` types: `std::sync::Arc`, `std::string::String` -- Spawn: `tokio::spawn(async move { ... })` -- Logging: `tracing::{info, warn, error}` -- Async client libraries (e.g., `rumqttc`) +- **std-only connector** — WebSocket and UDS. Honest and simple when there is no + embedded use case. Do not invent an embedded half that nobody wants. +- **Two backends behind one type** — MQTT. `MqttConnector` carries `Native` + (`rumqttc`, std) and `Embedded` (`mountain-mqtt`, any target with a + `StreamDialer`). The seam is the *backend*, not the runtime. -**See:** `aimdb-mqtt-connector/` for complete Tokio implementation +What **not** to do: give the tier-3 backend a `.transport()` method that accepts +a dialer and discards it, to make the two look alike. A signature that lies is +worse than a documented asymmetry. + +**See:** Design 052 (runtime-neutral connectors) for the trait set tiers 1 and 2 +build on. --- -## Embassy Implementation Pattern +## Implementation Pattern + +Write **one** connector, generic over core's I/O traits. The adapter owns +sockets, clocks and channels; the connector owns framing, protocol logic and +sugar. There is no `tokio_*` / `embassy_*` module and no runtime `cfg` on the +code path — a new platform is one adapter crate and zero connector edits. -Embassy's primitives are `!Send` (single-core, cooperative), but AimDB's connector -contract is `Send`-everywhere (so a Tokio app can `tokio::spawn(runner.run())`). **Do not -hand-roll the `unsafe`/force-`Send` bridge** — it lives, audited and once, in -`aimdb_embassy_adapter::connectors` (Design 033). A connector crate contributes only its -transport-specific logic and carries **no `unsafe`**. +**Features name the environment, not the runtime.** The real split is std vs +`no_std`: a `no_std` connector runs under Embassy, FreeRTOS or a host test +alike. Keep runtime names for convenience bundles only. -**Dependencies:** ```toml [features] -# Session transport (serial/TCP): needs the framed-connection spine. -embassy-runtime = ["aimdb-core/connector-session", "aimdb-embassy-adapter/connector-io", …] -# Data-plane transport (MQTT/KNX): needs the sink/source bridges + pumps. -embassy-runtime = ["aimdb-core/connector-session", "aimdb-embassy-adapter/connectors", …] -``` - -**Session transport** (a framed byte stream — serial, TCP): -- Implement `aimdb_embassy_adapter::connectors::Framer` (encode/accumulate/next-frame). -- Client sugar → `EmbassySessionClient::new(OneShotDialer::new(EmbassyConnection::new(rx, tx, MyFramer)), Codec)`. -- Server sugar → `EmbassySessionServer::new(OneShotListener::new(conn), Codec, dispatch_factory, cfg)`, - or a thin `ConnectorBuilder` that stores the moved-in connection in a `OneShotCell` and - drives `serve` (see `aimdb-serial-connector`). - -**Data-plane transport** (a pub/sub channel — MQTT, KNX): -- Implement `EmbassySinkRaw` (outbound publish) and/or `EmbassySourceRaw` (inbound next), - then ride core's pumps: - `pump_sink(db, scheme, Arc::new(EmbassySink(my_sink)))` / - `pump_source(db, scheme, EmbassySource(my_source))`. - (If your channels are already `Send` — e.g. `CriticalSectionRawMutex` — implement core's - `Connector`/`Source` directly and skip the bridges; see `aimdb-knx-connector`.) -- Force-`Send` the long-lived protocol task with `into_box_future(async move { … })`. - -**Other:** `alloc` types (`alloc::sync::Arc`, `alloc::string::String`), `StaticCell` for -channels, `defmt` logging behind `#[cfg(feature = "defmt")]`. Network connectors take the -`embassy_net::Stack` at builder construction, wrapped in -`aimdb_embassy_adapter::connectors::NetStack` (the `EmbassyNetwork` runtime trait is gone -since issue #131 — a `dyn RuntimeOps` cannot surface adapter-specific capabilities). - -**See:** `aimdb-serial-connector` (session), `aimdb-mqtt-connector` / `aimdb-knx-connector` -(data-plane), and `examples/embassy-mqtt-connector-demo/`. +# The std backend, if the protocol library is tier 3 and std-only. +std = ["aimdb-core/std", "protocol-client-crate"] +# The neutral backend: `alloc` only, no executor and no network stack. +embedded = ["aimdb-core/alloc", "aimdb-core/connector-session"] +# Convenience: `embedded` plus one adapter's transports. +embassy-runtime = ["embedded", "aimdb-embassy-adapter/net"] +``` + +**Session transport** (a framed byte stream — serial, TCP): contribute a +`Framer` and let core's `FramedConnection` / `FramingDialer` / `FramingListener` +do the rest over the adapter's `StreamDialer` or `StreamListener`. + +**Data-plane transport** (a pub/sub channel — MQTT, KNX): implement core's +`Connector` (outbound) and `Source` (inbound) over an +`embassy_sync::channel::Channel`, then ride +`pump_sink` / `pump_source`. `CriticalSectionRawMutex` is what makes the +channel `Sync`, and therefore what lets these be plain impls with no +force-`Send` wrapper. It is a link-time obligation on std: enable +`critical-section/std` from your own feature so no std user meets the +undefined-symbol error. + +**Time:** take core's `Delay` rather than a runtime timer. `RuntimeOps::sleep` +is `dyn` and boxes per call, which a poll loop cannot afford; `Delay` is +generic and allocates nothing. The clock for elapsed time stays +`RuntimeOps::now_nanos()`, and wall-clock time is `RuntimeOps::unix_time()`. + +### The `Send` rule, and its one escape hatch + +`ConnectorBuilder::build` returns `Send` futures, so **every trait a generic +connector task calls through needs `+ Send` on its return type** — not just +core's. A bare `async fn` in your own trait will not do it: + +```rust +- async fn send(&mut self, frame: &[u8]) -> bool; ++ fn send(&mut self, frame: &[u8]) -> impl Future + Send; +``` + +That fixes every trait you own. It cannot fix a **foreign** trait: nothing adds +a bound to `embedded_io_async::Read`, and a generic parameter hides whether the +concrete future is `Send`. Expressing it needs return-type notation, which is +not stable on the pinned toolchain. Where that bites, the choices are a +documented `unsafe impl Send` on the task future — sound when the trait bounds +already guarantee every held value is `Send`, as `StreamDialer`'s +`Stream: Send` does — or type-erasing the stream behind `dyn` and paying an +allocation per read. Prefer the first, at exactly one site, with the +justification written down; see `aimdb-mqtt-connector`'s `SendSession`. + +Moved-in resources go in `aimdb_core::session::OneShot`, which is +`Send + Sync` for `T: Send` without `unsafe`. If it refuses your type, fix the +type — a missing `+ Send` on a trait object, usually — rather than forcing the +bound. + +**See:** `aimdb-serial-connector` (session), `aimdb-mqtt-connector` / +`aimdb-knx-connector` (data-plane), and `examples/embassy-mqtt-connector-demo/`. --- @@ -238,24 +316,26 @@ if topic == "sensor/temp" { temp_producer.send(data).await; } router.route(topic, data).await?; ``` -**Embassy lifetime issues:** +**A channel that cannot cross a thread:** ```rust -// ❌ Stack allocation -let channel = Channel::new(); +// ❌ `NoopRawMutex` is !Sync, so the sink and source need a force-`Send` +// wrapper and the whole connector is welded to a single-core executor. +static CH: StaticCell> = StaticCell::new(); -// ✅ Static allocation -static CH: StaticCell> = StaticCell::new(); -let ch = CH.init(Channel::new()); +// ✅ `CriticalSectionRawMutex` is Send + Sync, so `Connector`/`Source` are +// plain impls. `Arc` over `StaticCell` allows several connectors per +// process; `StaticCell` is still right for one-connector firmware. +let actions = Arc::new(Channel::::new()); ``` -**Force-`Send` a protocol task (Embassy):** +**Process-global state where per-connector state belongs:** ```rust -// ❌ Don't hand-roll the unsafe wrapper in your connector crate -Box::pin(SendFutureWrapper(async move { ... })) +// ❌ The second connector silently connects as the first +static CLIENT_ID: OnceLock = OnceLock::new(); +let id: &'static str = CLIENT_ID.get_or_init(|| client_id.to_string()); -// ✅ Use the adapter spine's helper (the unsafe lives there, audited once) -use aimdb_embassy_adapter::connectors::into_box_future; -into_box_future(async move { ... }) +// ✅ One small leak per connector, at build +let id: &'static str = Box::leak(client_id.to_string().into_boxed_str()); ``` --- @@ -445,7 +525,7 @@ Users configure it per link: ## Connector Implementation Checklist -- [ ] Create crate with `tokio-runtime` and `embassy-runtime` features +- [ ] Create crate with `std` and `embedded` features (runtime names are bundles) - [ ] Implement `ConnectorBuilder` trait with `build()` and `scheme()` - [ ] Implement `Connector` trait with `publish()` - [ ] In `build()`: Collect inbound routes via `db.collect_inbound_routes(scheme)` From 98660ff0f8d0a119fdf7345bbf4e2780f2281969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 8 Sep 2026 04:47:02 +0000 Subject: [PATCH 20/20] docs(mqtt-connector): unlink feature-gated and private items from ungated docs `make doc` runs `cargo doc` per feature leg with `-D warnings`, so an intra-doc link that resolves on one leg and not another fails the build. Five such links had crept in: - `[Embedded]` in the ungated backend table, which only exists with the embedded backend - `[build]` and `[WallClock]`, both private - `[SntpClock]`, deleted when the TLS clock became the runtime's - `[sntp]`, now `embassy-tls`-gated while `tls.rs` is `embedded-tls` The `doc` target only covered `std` and `embassy-runtime`, which is why only the first two reached CI; add the `embedded`, `embedded-tls` and `embassy-tls` legs so the rest cannot recur silently. Co-Authored-By: Claude Opus 5 --- Makefile | 3 +++ aimdb-mqtt-connector/src/connector.rs | 4 ++-- aimdb-mqtt-connector/src/embedded/sntp.rs | 12 +++++------- aimdb-mqtt-connector/src/embedded/tls.rs | 17 +++++++---------- aimdb-mqtt-connector/src/native.rs | 2 +- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 79b962da..81e19fa7 100644 --- a/Makefile +++ b/Makefile @@ -415,7 +415,10 @@ doc: @printf "$(YELLOW) → Building embedded documentation$(NC)\n" cargo doc --package aimdb-core --no-default-features --features alloc --no-deps cargo doc --package aimdb-embassy-adapter --features "embassy-runtime,net" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded-tls" --no-deps cargo doc --package aimdb-mqtt-connector --no-default-features --features "embassy-runtime" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embassy-tls" --no-deps cargo doc --package aimdb-knx-connector --no-default-features --features "embassy-runtime" --no-deps @cp -r target/doc/* target/doc-final/embedded/ @printf "$(YELLOW) → Building WASM/browser documentation$(NC)\n" diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 32511190..cc44df02 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -11,8 +11,8 @@ //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | [`Native`] (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | -//! | [`Embedded`] (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | `Native` (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | +//! | `Embedded` (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; use alloc::string::String; diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index 7414d7c5..7a75f655 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -1,11 +1,9 @@ -//! SNTP time source for TLS certificate validation. +//! SNTP time source, for a board whose runtime has no wall clock of its own. //! -//! The reference boards have no battery-backed RTC, but checking a -//! certificate's validity window needs the current Unix time. This module -//! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch -//! (boot), written after each SNTP sync and read through [`unix_now`] / -//! [`SntpClock`]. The TLS manager spawns `run` alongside its broker loop -//! and holds the first handshake until the first sync lands. +//! Checking a certificate's validity window needs the current Unix time, and +//! the reference boards have no battery-backed RTC. Each sync feeds both +//! [`unix_now`] and the TLS handshake clock. Opt in with `TlsOptions::with_sntp`; +//! a runtime that answers `unix_time()` needs none of this. use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 3c9090bb..40e2b3e3 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,18 +1,15 @@ //! The TLS transport for the embedded backend. //! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over an Embassy -//! TCP socket, presented to the MQTT layer as its own `Connection` — not +//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the caller's +//! transport, presented to the MQTT layer as its own `Connection` — not //! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give //! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) -//! against the application-embedded root CA, with time from the [`sntp`] task; -//! entropy comes from the application-injected TRNG ([`TlsOptions::new`]). +//! against the application-embedded root CA, dated by the runtime's wall +//! clock; entropy +//! comes from the application-injected TRNG ([`TlsOptions::new`]). //! -//! The session loop is mountain-mqtt-embassy's own public `handle_messages` -//! (with `State` / `ChannelEventHandler`): -//! it is transport-agnostic (generic over `Client`), so the only thing this -//! module supplies is the transport — resolve → TCP → TLS handshake → session. -//! Upstream `run()` shares that exact loop, keeping the plain and TLS paths in -//! lock-step with no copied code to drift. +//! The dialer resolves the host, so there is no network stack here: the same +//! session runs on a host over the Tokio adapter's transport. use alloc::string::String; use alloc::vec::Vec; diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index 72a19307..f9098e45 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -67,7 +67,7 @@ pub(crate) fn build<'a>( /// Internal MQTT connector build helpers. /// -/// A namespace for the broker-connection setup invoked from [`build`]; the +/// A namespace for the broker-connection setup invoked from `build`; the /// data-plane loops themselves live in the reusable `pump_sink` / /// `pump_source` helpers + the `MqttSink` / `MqttEventLoopSource` adapters /// below.