diff --git a/Cargo.lock b/Cargo.lock index 1251e840..8468ee1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,47 +271,39 @@ 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" +version = "0.7.0" dependencies = [ "aimdb-core", "aimdb-data-contracts", "aimdb-embassy-adapter", "aimdb-mountain-mqtt", - "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-hal-async", "embedded-io-async 0.7.0", "embedded-tls", + "futures", "futures-core", "futures-util", "heapless 0.8.0", + "rand 0.8.6", "rand_core 0.6.4", + "rcgen", "rumqttc", "rustls-native-certs", "serde", - "static_cell", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tokio-test", "uuid", ] @@ -401,6 +393,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-uds-connector", + "embedded-io-async 0.7.0", "futures", "log", "serde", @@ -3201,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" @@ -3380,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", ] @@ -3430,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" @@ -3446,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" @@ -4370,6 +4390,7 @@ dependencies = [ "deranged", "num-conv", "powerfmt", + "serde_core", "time-core", ] @@ -5690,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 97110e57..81e19fa7 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 @@ -94,6 +97,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 +180,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" @@ -204,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" @@ -227,6 +234,14 @@ 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 + @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 + @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" @@ -282,6 +297,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" @@ -325,14 +342,18 @@ 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 "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 @@ -356,6 +377,14 @@ 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 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 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" @@ -372,9 +401,9 @@ 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-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 @@ -386,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" @@ -447,7 +479,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" @@ -465,6 +509,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-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-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index cac48efe..5df10dcc 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -208,7 +208,60 @@ 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. +#[derive(Clone)] pub struct EmbassyTcpDialer { slot: Arc, } @@ -581,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/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a08977a8..cd9763a8 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,8 +7,82 @@ 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).** + `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 +92,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 diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 42189d9c..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"] @@ -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,33 +36,50 @@ 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 - "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", - "mountain-mqtt-embassy", - "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 -# (`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", ] @@ -65,9 +87,53 @@ 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"] +defmt = [ + "dep:defmt", + "aimdb-core/defmt", + "mountain-mqtt?/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", + "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", "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 +# `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", + "critical-section-std-impl", +] [dependencies] aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false } @@ -98,7 +164,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 = [ @@ -114,9 +179,7 @@ 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 } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ @@ -125,17 +188,27 @@ 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", 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" +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 = [ @@ -143,6 +216,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/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-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs new file mode 100644 index 00000000..cc44df02 --- /dev/null +++ b/aimdb-mqtt-connector/src/connector.rs @@ -0,0 +1,228 @@ +//! 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. +//! +//! 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` (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; + +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::{AimDb, DbResult}; + +/// 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 over a caller-supplied transport. +#[cfg(feature = "embedded")] +pub struct Embedded { + pub(crate) dialer: D, +} + +/// 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, +} + +/// An MQTT connector over the 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, +} + +impl MqttConnector { + /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). + /// + /// 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 { + 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 = "embedded")] + pub fn transport(self, dialer: D) -> MqttConnector> { + MqttConnector { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: Embedded { dialer }, + } + } + + /// 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, + dialer: D, + options: crate::embedded::tls::TlsOptions, + ) -> MqttConnector> { + MqttConnector { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: EmbeddedTls { + dialer, + options: crate::embedded::TlsSlot::new(options), + }, + } + } +} + +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 + } + + /// 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( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.credentials = Some((username.into(), password.into())); + self + } +} + +mod sealed { + pub trait Sealed {} + impl Sealed for super::Native {} + #[cfg(feature = "embedded")] + impl Sealed for super::Embedded {} + #[cfg(feature = "embedded-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, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a>; +} + +#[cfg(feature = "std")] +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::native::build(db, broker_url, client_id, credentials) + } +} + +#[cfg(feature = "embedded")] +impl Backend for Embedded +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, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embedded::build_plain(db, broker_url, client_id, credentials, &self.dialer) + } +} + +#[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, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embedded::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 { + "mqtt" + } +} diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs deleted file mode 100644 index 2ec8dab4..00000000 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ /dev/null @@ -1,702 +0,0 @@ -//! Embassy MQTT client implementation using mountain-mqtt-embassy -//! -//! 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. -//! -//! # 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) -//! .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?; -//! ``` - -extern crate alloc; - -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}; -use alloc::sync::Arc; -use alloc::vec::Vec; -use core::future::Future; -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}; -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::{self, MqttEvent, Settings}; - -#[cfg(feature = "embassy-tls")] -pub use crate::embassy_tls::TlsOptions; -#[cfg(feature = "embassy-tls")] -use crate::embassy_tls::{host_ip_literal, run_tls, READ_BUF_MIN}; - -/// Maximum number of pending MQTT actions and events -pub(crate) const CHANNEL_SIZE: usize = 32; - -/// Buffer size for MQTT packets (4KB) -pub(crate) const BUFFER_SIZE: usize = 4096; - -/// Maximum properties in MQTT packets -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>; - -/// MQTT actions that can be performed -/// -/// Implements the `MqttOperations` trait required by mountain-mqtt-embassy. -#[derive(Clone)] -pub enum AimdbMqttAction { - /// Publish a message to a topic - Publish { - topic: String, - payload: Vec, - qos: QualityOfService, - retain: bool, - }, - /// Subscribe to a topic - Subscribe { - topic: String, - qos: QualityOfService, - }, -} - -/// Implementation of MqttOperations trait for AimDB actions -impl MqttOperations for AimdbMqttAction { - async fn perform<'a, 'b, C>( - &'b mut self, - client: &mut C, - _client_id: &'a str, - _connection_id: ConnectionId, - is_retry: bool, - ) -> Result<(), ClientError> - where - C: Client<'a>, - { - match self { - Self::Publish { - topic, - payload, - qos, - retain, - } => { - #[cfg(feature = "defmt")] - { - if is_retry { - defmt::debug!("Retrying publish to {}", topic.as_str()); - } else { - defmt::debug!( - "Publishing {} bytes to {} (QoS={:?})", - payload.len(), - topic.as_str(), - qos - ); - } - } - - #[cfg(not(feature = "defmt"))] - let _ = is_retry; - - client.publish(topic, payload, *qos, *retain).await?; - - #[cfg(feature = "defmt")] - defmt::info!("Published {} bytes to {}", payload.len(), topic.as_str()); - - Ok(()) - } - Self::Subscribe { topic, qos } => { - #[cfg(feature = "defmt")] - { - if is_retry { - defmt::debug!("Retrying subscribe to {} (QoS={:?})", topic.as_str(), qos); - } else { - defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); - } - } - - #[cfg(not(feature = "defmt"))] - let _ = is_retry; - - client.subscribe(topic, *qos).await?; - - #[cfg(feature = "defmt")] - defmt::info!("Subscribed to {}", topic.as_str()); - - Ok(()) - } - } - } -} - -/// MQTT events for received messages -/// -/// Handles incoming MQTT messages that will be routed to the appropriate -/// record producers via core's `pump_source`. -#[derive(Clone)] -pub enum AimdbMqttEvent { - /// A message was received from a subscribed topic - MessageReceived { - /// The topic the message was received on - topic: String, - /// The message payload - payload: Vec, - }, -} - -impl mountain_mqtt_embassy::mqtt_manager::FromApplicationMessage - for AimdbMqttEvent -{ - fn from_application_message( - message: &mountain_mqtt::packets::publish::ApplicationMessage, - ) -> Result { - #[cfg(feature = "defmt")] - defmt::debug!( - "Received message on topic '{}', {} bytes", - message.topic_name, - message.payload.len() - ); - - Ok(Self::MessageReceived { - topic: message.topic_name.to_string(), - payload: message.payload.to_vec(), - }) - } -} - -// =========================================================================== -// Data-plane bridges — ride core's pumps via the adapter's force-`Send` wrappers. -// =========================================================================== - -/// 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. -struct MqttSink { - sender: ActionSender, -} - -impl EmbassySinkRaw for MqttSink { - async fn publish( - &self, - destination: String, - config: ConnectorConfig, - payload: Vec, - ) -> Result<(), PublishError> { - // `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") - .map(map_qos) - .unwrap_or(QualityOfService::Qos1); - let retain = opt_bool(&config, "retain").unwrap_or(false); - - self.sender - .send(AimdbMqttAction::Publish { - topic: destination, - 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). -struct MqttSource { - receiver: EventReceiver, -} - -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, - } - } - } -} - -/// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds -/// `&'static mut` exclusive resources (TRNG, record buffers), so it is -/// neither `Sync` nor takeable through the `&self` that -/// [`ConnectorBuilder::build`] receives without interior mutability. -/// -/// 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. -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, -} - -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 { - Self { - broker_url: broker_url.into(), - client_id: "aimdb-client".to_string(), - credentials: None, - #[cfg(feature = "embassy-tls")] - tls: TlsSlot::default(), - // 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) }, - } - } - - /// 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 - } - - /// 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. -/// -/// 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 { - fn build<'a>( - &'a self, - 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"); - let topics: Vec = RouterBuilder::from_routes(inbound_routes) - .build() - .resource_ids() - .iter() - .map(|t| t.to_string()) - .collect(); - - #[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 (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)? - } - } - }; - #[cfg(not(feature = "embassy-tls"))] - let (action_sender, event_receiver, manager_tasks) = { - if broker.tls { - return Err(build_err( - "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", - )); - } - setup_manager(&broker, connection_settings, self.stack, topics)? - }; - - // 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. - futures.extend(manager_tasks); - - Ok(futures) - }) - } - - fn scheme(&self) -> &str { - "mqtt" - } -} - -/// Parsed broker endpoint: transport + authority. -struct BrokerUrl { - tls: bool, - host: String, - port: u16, -} - -fn build_err(msg: &str) -> aimdb_core::DbError { - #[cfg(feature = "defmt")] - defmt::error!("Failed to build MQTT connector: {}", msg); - aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", msg)) -} - -/// Parse the broker URL into transport + host + port (`mqtt://` 1883, -/// `mqtts://` 8883). -fn parse_broker_url(broker_url: &str) -> Result { - // Add a dummy topic if none, so parsing succeeds. - let mut url = broker_url.to_string(); - if !url.contains('/') || url.matches('/').count() < 3 { - url = format!("{}/dummy", url.trim_end_matches('/')); - } - let connector_url = ConnectorUrl::parse(&url).map_err(|_| build_err("Invalid MQTT URL"))?; - let tls = match connector_url.scheme.as_str() { - "mqtt" => false, - "mqtts" => true, - _ => return Err(build_err("Broker URL scheme must be mqtt:// or mqtts://")), - }; - let port = connector_url.port.unwrap_or(if tls { 8883 } else { 1883 }); - Ok(BrokerUrl { - tls, - host: connector_url.host, - port, - }) -} - -/// Build the `ConnectionSettings<'static>` for MQTT CONNECT, parking the -/// identity strings in statics for the `'static` lifetime requirement. -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(); - - let client_id: &'static str = CLIENT_ID_STORAGE.get_or_init(|| client_id.to_string()); - 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(), - ) - } - None => ConnectionSettings::unauthenticated(client_id), - } -} - -/// Sender half of the event channel (used by the broker manager tasks). -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>; - -/// 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 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`. -fn setup_manager( - broker: &BrokerUrl, - connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, - topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { - let broker_ip = 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 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(); - - #[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; - } - }); - - Ok((action_sender, event_receiver, alloc::vec![manager_task])) -} - -/// 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( - broker: &BrokerUrl, - options: TlsOptions, - connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, - topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { - match host_ip_literal(&broker.host) { - Some(core::net::IpAddr::V6(_)) => { - return Err(build_err( - "mqtts:// with an IPv6 literal can never pass certificate verification — use a hostname", - )); - } - Some(core::net::IpAddr::V4(_)) => { - // Verifies only via the certificate's CN — private-CA bench - // setups pin the IP there; public CAs won't issue such certs. - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT-TLS: broker host is an IP literal; the certificate must carry it in CN — prefer a hostname" - ); - } - None => {} - } - if options.read_buf.len() < READ_BUF_MIN { - return Err(build_err( - "TLS read buffer too small — a TLS 1.3 peer may send 16 KB records; provide at least 16 640 bytes", - )); - } - - let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); - - // `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 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 sntp_task = into_box_future(async move { - #[allow(unreachable_code)] - { - let _: () = crate::sntp::run(*network, sntp_server).await; - } - }); - - Ok(( - action_sender, - event_receiver, - alloc::vec![manager_task, sntp_task], - )) -} - -/// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). -fn map_qos(qos: u8) -> QualityOfService { - match qos { - 0 => QualityOfService::Qos0, - 1 => QualityOfService::Qos1, - 2 => QualityOfService::Qos1, // Downgrade to QoS 1 - _ => QualityOfService::Qos0, // Default to QoS 0 - } -} - -/// Read a `u8` option from the per-route `protocol_options` (URL query). -fn opt_u8(config: &ConnectorConfig, key: &str) -> Option { - config - .protocol_options - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| v.parse::().ok()) -} - -/// Read a `bool` option from the per-route `protocol_options` (URL query). -fn opt_bool(config: &ConnectorConfig, key: &str) -> Option { - config - .protocol_options - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| v.parse::().ok()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_qos_mapping() { - assert!(matches!(map_qos(0), QualityOfService::Qos0)); - assert!(matches!(map_qos(1), QualityOfService::Qos1)); - assert!(matches!(map_qos(2), QualityOfService::Qos1)); // Downgrades to QoS 1 - assert!(matches!(map_qos(99), QualityOfService::Qos0)); // Defaults to QoS 0 - } -} diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs new file mode 100644 index 00000000..7dc4df60 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/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/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs new file mode 100644 index 00000000..8e080261 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -0,0 +1,648 @@ +//! The `mountain-mqtt` backend: broker session plus the data-plane bridges. +//! +//! 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 +//! +//! ```rust,ignore +//! let db = AimDbBuilder::new() +//! .runtime(embassy_adapter) +//! .with_connector( +//! MqttConnector::new("mqtt://192.168.1.100:1883") +//! .transport(EmbassyNet::tcp(stack, rx, tx)) +//! .with_client_id("my-unique-device-id"), +//! ) +//! .build() +//! .await?; +//! ``` + +pub mod manager; +pub mod session; + +// TLS transport + SNTP time source. +#[cfg(feature = "embassy-tls")] +pub mod sntp; +#[cfg(feature = "embedded-tls")] +pub mod tls; + +extern crate alloc; + +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 alloc::boxed::Box; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::future::Future; +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; + +use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; + +use crate::embedded::manager::{MqttEvent, Settings}; + +#[cfg(feature = "embedded-tls")] +pub use crate::embedded::tls::TlsOptions; +#[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; + +/// Buffer size for MQTT packets (4KB) +pub(crate) const BUFFER_SIZE: usize = 4096; + +/// Maximum properties in MQTT packets +pub(crate) const MAX_PROPERTIES: usize = 16; + +/// The runner's collected future type. +type EmbassyBoxFuture = Pin + Send + 'static>>; + +/// 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::embedded::manager::ActionChannel; +/// Inbound messages: broker session to pumps. +pub(crate) type EventChannel = crate::embedded::manager::EventChannel; + +/// MQTT actions that can be performed +/// +/// Implements the `MqttOperations` trait required by mountain-mqtt-embassy. +#[derive(Clone)] +pub enum AimdbMqttAction { + /// Publish a message to a topic + Publish { + topic: String, + payload: Vec, + qos: QualityOfService, + retain: bool, + }, + /// Subscribe to a topic + Subscribe { + topic: String, + qos: QualityOfService, + }, +} + +/// Implementation of MqttOperations trait for AimDB actions +impl MqttOperations for AimdbMqttAction { + async fn perform<'a, 'b, C>( + &'b mut self, + client: &mut C, + _client_id: &'a str, + _connection_id: ConnectionId, + is_retry: bool, + ) -> Result<(), ClientError> + where + C: Client<'a>, + { + match self { + Self::Publish { + topic, + payload, + qos, + retain, + } => { + #[cfg(feature = "defmt")] + { + if is_retry { + defmt::debug!("Retrying publish to {}", topic.as_str()); + } else { + defmt::debug!( + "Publishing {} bytes to {} (QoS={:?})", + payload.len(), + topic.as_str(), + qos + ); + } + } + + #[cfg(not(feature = "defmt"))] + let _ = is_retry; + + client.publish(topic, payload, *qos, *retain).await?; + + #[cfg(feature = "defmt")] + defmt::info!("Published {} bytes to {}", payload.len(), topic.as_str()); + + Ok(()) + } + Self::Subscribe { topic, qos } => { + #[cfg(feature = "defmt")] + { + if is_retry { + defmt::debug!("Retrying subscribe to {} (QoS={:?})", topic.as_str(), qos); + } else { + defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); + } + } + + #[cfg(not(feature = "defmt"))] + let _ = is_retry; + + client.subscribe(topic, *qos).await?; + + #[cfg(feature = "defmt")] + defmt::info!("Subscribed to {}", topic.as_str()); + + Ok(()) + } + } + } +} + +/// MQTT events for received messages +/// +/// Handles incoming MQTT messages that will be routed to the appropriate +/// record producers via core's `pump_source`. +#[derive(Clone)] +pub enum AimdbMqttEvent { + /// A message was received from a subscribed topic + MessageReceived { + /// The topic the message was received on + topic: String, + /// The message payload, built once from the wire bytes. + payload: Payload, + }, +} + +impl crate::embedded::manager::FromApplicationMessage for AimdbMqttEvent { + fn from_application_message( + message: &mountain_mqtt::packets::publish::ApplicationMessage, + ) -> Result { + #[cfg(feature = "defmt")] + defmt::debug!( + "Received message on topic '{}', {} bytes", + message.topic_name, + message.payload.len() + ); + + Ok(Self::MessageReceived { + topic: message.topic_name.to_string(), + // 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), + }) + } +} + +// =========================================================================== +// 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 session's action channel. +struct MqttSink { + actions: Arc, +} + +impl aimdb_core::transport::Connector for MqttSink { + fn publish( + &self, + 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") + .map(map_qos) + .unwrap_or(QualityOfService::Qos1); + let retain = opt_bool(config, "retain").unwrap_or(false); + let topic = destination.to_string(); + let payload = payload.to_vec(); + + Box::pin(async move { + self.actions + .send(AimdbMqttAction::Publish { + topic, + payload, + qos, + retain, + }) + .await; + Ok(()) + }) + } +} + +/// Inbound source: drains the session's event channel, yielding each received +/// message as `(topic, payload)` for `pump_source` to fan out. +struct MqttSource { + events: Arc, +} + +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)), + // Connection lifecycle events carry no record data; skip + // and keep draining. + _ => continue, + } + } + }) + } +} + +/// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds +/// `&'static mut` exclusive resources (TRNG, record buffers), so it is +/// neither `Sync` nor takeable through the `&self` that +/// [`ConnectorBuilder::build`] receives without interior mutability. +/// +/// 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 = "embedded-tls")] +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 + + 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)?; + 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)) + }) +} + +/// Connect and collect the data-plane futures for an `mqtts://` session. +#[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>> +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)?; + 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.dialer.clone(), + topics, + db.runtime_ops(), + )?; + Ok(collect_pumps(db, actions, events, 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(); + + #[cfg(feature = "defmt")] + defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); + + 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. +struct BrokerUrl { + tls: bool, + host: String, + port: u16, +} + +fn build_err(msg: &str) -> aimdb_core::DbError { + #[cfg(feature = "defmt")] + defmt::error!("Failed to build MQTT connector: {}", msg); + aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", msg)) +} + +/// Parse the broker URL into transport + host + port (`mqtt://` 1883, +/// `mqtts://` 8883). +fn parse_broker_url(broker_url: &str) -> Result { + // Add a dummy topic if none, so parsing succeeds. + let mut url = broker_url.to_string(); + if !url.contains('/') || url.matches('/').count() < 3 { + url = format!("{}/dummy", url.trim_end_matches('/')); + } + let connector_url = ConnectorUrl::parse(&url).map_err(|_| build_err("Invalid MQTT URL"))?; + let tls = match connector_url.scheme.as_str() { + "mqtt" => false, + "mqtts" => true, + _ => return Err(build_err("Broker URL scheme must be mqtt:// or mqtts://")), + }; + let port = connector_url.port.unwrap_or(if tls { 8883 } else { 1883 }); + Ok(BrokerUrl { + tls, + host: connector_url.host, + port, + }) +} + +/// 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: 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.unwrap_or("aimdb-client")); + match credentials { + Some((username, password)) => { + ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) + } + None => ConnectionSettings::unauthenticated(client_id), + } +} + +/// 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`. +fn setup_manager( + broker: &BrokerUrl, + connection_settings: ConnectionSettings<'static>, + dialer: D, + topics: Vec, + runtime: Arc, +) -> 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, +{ + Ipv4Addr::from_str(&broker.host).map_err(|_| { + build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") + })?; + + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); + + // 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::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::embedded::session::SendSession::new({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT background task starting"); + + crate::embedded::session::run_sessions( + transport, + topics, + connection_settings, + Settings::default(), + events, + actions, + delay, + runtime, + ) + .await + } + }) + }); + + Ok((actions, events, alloc::vec![manager_task])) +} + +/// 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 = "embedded-tls")] +fn setup_tls_manager( + broker: &BrokerUrl, + options: TlsOptions, + connection_settings: ConnectionSettings<'static>, + dialer: D, + topics: Vec, + runtime: Arc, +) -> 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( + "mqtts:// with an IPv6 literal can never pass certificate verification — use a hostname", + )); + } + Some(core::net::IpAddr::V4(_)) => { + // Verifies only via the certificate's CN — private-CA bench + // setups pin the IP there; public CAs won't issue such certs. + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT-TLS: broker host is an IP literal; the certificate must carry it in CN — prefer a hostname" + ); + } + None => {} + } + if options.read_buf.len() < READ_BUF_MIN { + return Err(build_err( + "TLS read buffer too small — a TLS 1.3 peer may send 16 KB records; provide at least 16 640 bytes", + )); + } + + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); + + let host = broker.host.clone(); + let port = broker.port; + #[cfg(feature = "embassy-tls")] + let sntp = options.sntp; + + 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 _: () = crate::embedded::sntp::run(*stack.get(), server).await; + } + })); + } + + Ok((actions, events, tasks)) +} + +/// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). +fn map_qos(qos: u8) -> QualityOfService { + match qos { + 0 => QualityOfService::Qos0, + 1 => QualityOfService::Qos1, + 2 => QualityOfService::Qos1, // Downgrade to QoS 1 + _ => QualityOfService::Qos0, // Default to QoS 0 + } +} + +/// Read a `u8` option from the per-route `protocol_options` (URL query). +fn opt_u8(config: &ConnectorConfig, key: &str) -> Option { + config + .protocol_options + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| v.parse::().ok()) +} + +/// Read a `bool` option from the per-route `protocol_options` (URL query). +fn opt_bool(config: &ConnectorConfig, key: &str) -> Option { + config + .protocol_options + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| v.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_qos_mapping() { + assert!(matches!(map_qos(0), QualityOfService::Qos0)); + assert!(matches!(map_qos(1), QualityOfService::Qos1)); + assert!(matches!(map_qos(2), QualityOfService::Qos1)); // Downgrades to QoS 1 + assert!(matches!(map_qos(99), QualityOfService::Qos0)); // Defaults to QoS 0 + } +} diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs new file mode 100644 index 00000000..dd431881 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -0,0 +1,214 @@ +//! 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 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. +//! +//! 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, + )) + } +} + +/// 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. 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( + transport: T, + topics: alloc::vec::Vec, + connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, + settings: crate::embedded::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 mountain_mqtt::client::ClientNoQueue; + 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, + }; + + // 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::embedded::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"); + delay.sleep(settings.reconnection_delay).await; + continue; + } + }; + + 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, &events, &state, runtime.as_ref()); + let mut client = ClientNoQueue::new( + connection, + &mut mqtt_buffer, + mountain_mqtt::embedded_hal_async::DelayEmbedded::new(ClientDelay(&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, + &events, + &actions, + &settings, + &delay, + runtime.as_ref(), + ) + .await + { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: session errored: {:?}", error); + events + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + } + + delay.sleep(settings.reconnection_delay).await; + } +} diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs similarity index 87% rename from aimdb-mqtt-connector/src/sntp.rs rename to aimdb-mqtt-connector/src/embedded/sntp.rs index cdd28bf7..7a75f655 100644 --- a/aimdb-mqtt-connector/src/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}; @@ -46,17 +44,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 +56,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/embassy_tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs similarity index 60% rename from aimdb-mqtt-connector/src/embassy_tls.rs rename to aimdb-mqtt-connector/src/embedded/tls.rs index 07d35a0f..40e2b3e3 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,32 +1,22 @@ -//! 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 -//! 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`]). +//! `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, 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`](mountain_mqtt_embassy::mqtt_manager::handle_messages) -//! (with [`State`](mountain_mqtt_embassy::mqtt_manager::State) / -//! [`ChannelEventHandler`](mountain_mqtt_embassy::mqtt_manager::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; use core::cell::RefCell; use core::net::IpAddr; -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 alloc::sync::Arc; use embedded_tls::pki::CertVerifier; use embedded_tls::{ @@ -36,20 +26,17 @@ use embedded_tls::{ use embedded_io_async::Write as _; +use crate::embedded::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, -}; -use crate::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. @@ -72,7 +59,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 { @@ -97,56 +87,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 } @@ -169,14 +173,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) @@ -211,13 +221,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<'_> { @@ -234,21 +278,29 @@ 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, 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, + 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, @@ -257,8 +309,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 @@ -271,50 +321,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() - ); - Timer::after(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, - settings.port - ); - if let Err(e) = socket.connect((address, settings.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; - 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); @@ -330,7 +366,7 @@ pub(crate) async fn run_tls( ); #[cfg(not(feature = "defmt"))] let _ = e; - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; continue; } #[cfg(feature = "defmt")] @@ -341,10 +377,9 @@ 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: 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; @@ -355,12 +390,12 @@ 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, &mut mqtt_buffer, - delay, + DelayEmbedded::new(crate::embedded::session::ClientDelay(&delay)), timeout_millis, event_handler, ); @@ -371,15 +406,17 @@ pub(crate) async fn run_tls( &state, &connection_settings, &subscribe_topics, - &event_sender, - &mut action_receiver, + &events, + &actions, &settings, + &delay, + runtime.as_ref(), ) .await { #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: session errored: {:?}", error); - event_sender + events .send(MqttEvent::Disconnected { connection_id, error, @@ -387,16 +424,7 @@ pub(crate) async fn run_tls( .await; } - Timer::after(settings.reconnection_delay).await; - } -} - -/// 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 6e8c064c..92ded46f 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) +//! The split is std vs `no_std`, not Tokio vs Embassy: the embedded backend +//! runs on any target that can supply a `StreamDialer`. //! -//! ## Tokio Usage (Standard Library) +//! - `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; @@ -94,43 +100,39 @@ 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; + +// 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; +// The `rumqttc` backend. +#[cfg(feature = "std")] +pub mod native; -#[cfg(feature = "embassy-runtime")] -pub mod embassy_client; +// The `mountain-mqtt` backend: session loop, manager, and the TLS transport. +#[cfg(feature = "embedded")] +pub mod embedded; // SNTP wire codec — pure and feature-independent so it is unit-tested on the -// host; only the `embassy-tls` I/O task consumes it. +// 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 for the Embassy client -#[cfg(feature = "embassy-tls")] -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; +// Deprecated module names, kept for one release so existing imports keep +// working. The modules no longer name a runtime. +#[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(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use tokio_client::MqttConnectorBuilder as MqttConnector; // Default to tokio when both enabled +#[cfg(feature = "embedded")] +pub use connector::Embedded; +#[cfg(feature = "embedded-tls")] +pub use connector::EmbeddedTls; +pub use connector::{MqttConnector, Native}; +#[cfg(feature = "embedded-tls")] +pub use embedded::tls::TlsOptions; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/native.rs similarity index 74% rename from aimdb-mqtt-connector/src/tokio_client.rs rename to aimdb-mqtt-connector/src/native.rs index 51a076c0..f9098e45 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -1,125 +1,76 @@ -//! 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}; 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 +87,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 +114,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 +359,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 +367,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 +387,7 @@ mod tests { let connector = MqttConnectorImpl::build_internal( "mqtts://hub-sub:secret@broker.example.com:8883", None, + None, router, ) .await; @@ -451,7 +416,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/src/sntp_codec.rs b/aimdb-mqtt-connector/src/sntp_codec.rs index 6b37a40a..1f555c2b 100644 --- a/aimdb-mqtt-connector/src/sntp_codec.rs +++ b/aimdb-mqtt-connector/src/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/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs new file mode 100644 index 00000000..5d20c437 --- /dev/null +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -0,0 +1,235 @@ +//! 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::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") +} +// 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 { + 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())); + + // 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"); + + 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" + ); +} + +/// `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 new file mode 100644 index 00000000..7bf2b61b --- /dev/null +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -0,0 +1,330 @@ +//! 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, + /// The username/password each CONNECT carried, when it carried any. + pub credentials: 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 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]; + + 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) +} + +/// 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; + skip_varint(body, &mut i); + // The varint is the property block's length, which follows it. + i += *body.get(start)? as usize; + } + + 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. +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<'_>) { + 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; + + 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, credentials)) = connect_identity(&body, v5) { + seen.client_ids.push(id); + seen.credentials.push(credentials); + } + } + 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/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs new file mode 100644 index 00000000..25b997e6 --- /dev/null +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -0,0 +1,359 @@ +//! 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)) + .transport(aimdb_embassy_adapter::net::EmbassyNet::tcp( + *stack, + buf(), + buf(), + )) + .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 + ); +} 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/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/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs new file mode 100644 index 00000000..f2a9fd37 --- /dev/null +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -0,0 +1,281 @@ +//! 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::net::TcpListener; + +mod common; +use common::{fake_broker, 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") +} +// 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`. +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); + +// --------------------------------------------------------------------------- +// 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 = "multi_thread", worker_threads = 4)] +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, None); + 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:?}" + ); + } +} + +/// 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"]); +} 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; diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index ce01dde3..aa484190 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -17,6 +17,14 @@ 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 + 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..f1894899 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 { @@ -91,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); @@ -114,6 +123,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 +370,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(); 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)` diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index abd996c8..3d703b5e 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -90,7 +90,8 @@ 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_embassy_adapter::net::EmbassyNet; +use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] use aimdb_mqtt_connector::embassy_client::TlsOptions; @@ -385,21 +386,45 @@ 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"); + // 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://` 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 = 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( + 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 { 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 7202efd0..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,8 +27,9 @@ 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::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; use defmt::*; use embassy_executor::Spawner; use embassy_net::StackResources; @@ -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( - MqttConnectorBuilder::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