diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 00a5c931..08346a0e 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -28,4 +28,5 @@ ignore = [ "RUSTSEC-2026-0098", # rustls-webpki: URI name constraints "RUSTSEC-2026-0099", # rustls-webpki: name constraints vs wildcard names "RUSTSEC-2026-0104", # rustls-webpki: panic in CRL parsing + "RUSTSEC-2025-0134", # rustls-pemfile unmaintained (transitive via rumqttc) ] diff --git a/Cargo.lock b/Cargo.lock index 6070576a..4c85e06e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,58 +249,47 @@ dependencies = [ [[package]] name = "aimdb-mountain-mqtt" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5035a7a126cd374a5e1e83dcbb04d5d5361d28084f32f59710c6f144b5b26349" -dependencies = [ - "defmt 0.3.100", - "embedded-hal-async", - "embedded-io 0.7.1", - "embedded-io-async 0.7.0", - "heapless 0.8.0", -] - -[[package]] -name = "aimdb-mountain-mqtt-embassy" -version = "0.2.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ab4d7bdbef7a5e8a95edda95652887c2de8898a4fe1771345d8791ec127d3c" +checksum = "0069824828d8b3102324245a42618e65bab9c63fd2917ed52fa49e016d89cecf" dependencies = [ - "aimdb-mountain-mqtt", "defmt 1.1.1", - "embassy-net", - "embassy-sync", - "embassy-time", + "heapless 0.8.0", ] [[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-futures 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "embassy-net", + "embassy-net-driver-channel", "embassy-sync", "embassy-time", + "embassy-time-driver", "embedded-io-async 0.7.0", "embedded-tls", + "futures", "futures-core", "futures-util", "heapless 0.8.0", + "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", ] @@ -387,6 +376,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-uds-connector", + "embedded-io-async 0.7.0", "futures", "log", "serde", @@ -800,9 +790,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1899,11 +1889,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "pin-project-lite", ] @@ -3187,6 +3176,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" @@ -3366,6 +3365,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", ] @@ -3416,6 +3416,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" @@ -3432,6 +3435,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" @@ -3654,15 +3670,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] @@ -3710,9 +3726,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -4058,9 +4074,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4356,6 +4372,7 @@ dependencies = [ "deranged", "num-conv", "powerfmt", + "serde_core", "time-core", ] @@ -5676,6 +5693,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 2001c59c..d5cd9056 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,10 @@ 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|embedded-hal-async +MQTT_DEPENDENCY_FORBIDDEN := embedded-io|embedded-hal|tokio NC := \033[0m # No Color ## Show available commands @@ -94,6 +98,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" @@ -177,6 +183,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" @@ -206,11 +214,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 --no-default-features --features "std" @printf "$(YELLOW) → Testing WebSocket connector (server + client: unit, real-socket e2e, AimDB round-trip)$(NC)\n" @@ -229,6 +237,20 @@ 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 + @printf "$(YELLOW) → Testing MQTT connector (event-driven session: wake cadence, partial packets, QoS 1)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop + @printf "$(YELLOW) → Testing MQTT connector (the same criteria over mqtts://)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session + @printf "$(YELLOW) → Testing MQTT connector (no_std unit tests: framing, deadlines, TLS duplex)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "embedded-tls,critical-section-std-impl" --lib fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -284,6 +306,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" @@ -327,14 +351,18 @@ clippy: @printf "$(YELLOW) → Clippy on KNX connector (neutral, no_std+alloc)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "connector" -- -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 - @printf "$(YELLOW) → Clippy on MQTT connector (embassy + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --features "std,tokio-rustls" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (neutral, no_std+alloc)$(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" + @printf "$(YELLOW) → Clippy on MQTT connector (neutral + TLS)$(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 (neutral + defmt)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "connector,defmt" -- -D warnings @@ -358,6 +386,20 @@ 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 MQTT connector (event-driven session criteria)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (the same criteria over mqtts://)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (no_std unit tests)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "embedded-tls,critical-section-std-impl" --lib --tests -- -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" @@ -374,9 +416,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 --no-default-features --features "std" --no-deps cargo doc --package aimdb-codegen --no-deps cargo doc --package aimdb-cli --no-deps @@ -397,7 +439,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 "connector" --no-deps cargo doc --package aimdb-serial-connector --no-default-features --features "connector" --no-deps cargo doc --package aimdb-tcp-connector --no-default-features --features "connector" --no-deps @@ -460,7 +505,29 @@ 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) → Asserting the MQTT dependency is codec-only$(NC)\n" + @out=$$(cargo tree -p aimdb-mountain-mqtt --target thumbv7em-none-eabihf -e normal 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_DEPENDENCY_FORBIDDEN)'; then \ + printf "$(RED)✗ the MQTT dependency pulled a driver crate$(NC)\n"; \ + printf '%s\n' "$$out" | grep -iE '$(MQTT_DEPENDENCY_FORBIDDEN)'; exit 1; \ + fi + @printf "$(BLUE)✓ mountain-mqtt is the codec alone$(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" @@ -478,6 +545,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-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 1589e0c6..eeb4387a 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ByteStream::split`, with `ByteRead` / `ByteWrite`.** + Borrows a stream into independently usable read and write halves, so a + session can run a reader and a writer concurrently in one `select` — which a + single `&mut` stream cannot express at all. Borrowed halves are enough: both + live in the same stack frame, which is why this needs nothing owned or + `'static` and why the objection design 052 recorded against `connector-io` + does not apply. `read`/`write_all`/`flush` stay for the handshake and for + callers that never split. The MQTT connector's event-driven session is the + first consumer; the Embassy and Tokio adapters implement it. +- **The cancellation contract is written down.** `read` is + cancel-safe on both adapters AimDB ships — dropping the future consumes + nothing, verified per layer and end to end over a drip transport — but that + is documented as a property of those transports rather than a promise of the + trait, so a reader that cannot resume mid-packet is still free to implement + it. `write_all` is cancel-safe **nowhere** and must never sit in a `select` + arm: a partial write desynchronises the framing above it with nothing to + resync on. + - **Runtime-neutral I/O layer (`session::io`, feature `connector-session`).** `ByteStream`/`StreamDialer`/`StreamListener`/`Datagram`/`DatagramBinder`/`Delay` sit below `Connection`, so an adapter owns sockets and clocks while a connector diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index 531e2dd2..c7344c80 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -33,17 +33,43 @@ pub type IoError = TransportError; // Byte streams — the one real fork between runtimes. // =========================================================================== +/// The read half of a [`split`](ByteStream::split) stream. +pub trait ByteRead { + /// Read into `buf`, returning the byte count; `Ok(0)` is EOF. + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a; +} + +/// The write half of a [`split`](ByteStream::split) stream. +pub trait ByteWrite { + /// Write every byte of `buf`, or fail. + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a; + + /// Flush any buffered bytes toward the peer. + fn flush(&mut self) -> impl Future> + Send + '_; +} + /// An unframed, bidirectional byte stream — one TCP connection, one UART, one /// TLS session. The adapter owns it; the connector never names its type. /// /// `read` returning `Ok(0)` is end of stream, matching both -/// `embedded_io_async::Read` and `tokio::io::AsyncRead`. +/// `embedded_io_async::Read` and `tokio::io::AsyncRead`. The stream is **one +/// value** — `&mut self` on both directions — so it can wrap a socket that +/// lends out only borrowed halves; a caller needing both directions at once +/// borrows them apart with [`split`](ByteStream::split). +/// +/// # Cancellation /// -/// The stream is **unsplit** — one value, `&mut self` on both directions — so -/// it can wrap a socket that lends out only borrowed halves while a -/// [`Connection`](super::Connection) must own it. Nothing is lost by it: -/// `Connection`'s own `recv`/`send` take `&mut self`, so reads and writes were -/// already serialized. +/// [`read`](ByteStream::read) is cancel-safe on both adapters AimDB ships, but +/// that is a property of those transports, not a promise of this trait. +/// [`write_all`](ByteStream::write_all) is **not** cancel-safe anywhere and +/// must never sit in a `select` arm: a partial write desynchronises the framing +/// above it with nothing to resync on. pub trait ByteStream { /// Read into `buf`, returning the byte count; `Ok(0)` is EOF. fn read<'a>( @@ -59,6 +85,13 @@ pub trait ByteStream { /// Flush any buffered bytes toward the peer. fn flush(&mut self) -> impl Future> + Send + '_; + + /// Borrow this stream as independent read and write halves, pollable + /// concurrently without either waiting on the other. + /// + /// The halves borrow rather than own, so a caller wanting owned or + /// `'static` halves needs a different seam. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_); } /// Produces streams: the client side. @@ -70,6 +103,7 @@ pub trait StreamDialer { type Stream: ByteStream + Send; /// Open a stream to `host:port`. + /// `host` is either a hostname or an unbracketed IP literal. fn connect<'a>( &'a self, host: &'a str, @@ -618,6 +652,30 @@ mod tests { self.0.lock().flushes += 1; Ok(()) } + + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (MockHalf(self.clone()), MockHalf(self.clone())) + } + } + + /// Either half of a split [`MockStream`]. The mock is already a shared + /// handle, so a half is just a clone of it. + struct MockHalf(MockStream); + + impl ByteRead for MockHalf { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + ByteStream::read(&mut self.0, buf).await + } + } + + impl ByteWrite for MockHalf { + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + ByteStream::write_all(&mut self.0, buf).await + } + + async fn flush(&mut self) -> TransportResult<()> { + ByteStream::flush(&mut self.0).await + } } /// A stream whose first read fails, to check the error is propagated as-is @@ -634,6 +692,79 @@ mod tests { async fn flush(&mut self) -> TransportResult<()> { Ok(()) } + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (FailingHalf, FailingHalf) + } + } + + /// Either half of a split [`FailingStream`]. + struct FailingHalf; + + impl ByteRead for FailingHalf { + async fn read<'a>(&'a mut self, _buf: &'a mut [u8]) -> TransportResult { + Err(TransportError::Closed) + } + } + + impl ByteWrite for FailingHalf { + async fn write_all<'a>(&'a mut self, _buf: &'a [u8]) -> TransportResult<()> { + Err(TransportError::Closed) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + } + + /// A stream whose read cannot finish until its write half has run, so a + /// blocked reader that blocked the writer would deadlock the test. + #[derive(Clone, Default)] + struct DuplexMock { + written: Arc>>, + wrote: Arc, + } + + impl ByteStream for DuplexMock { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + self.wrote.notified().await; + let written = self.written.lock(); + let n = written.len().min(buf.len()); + buf[..n].copy_from_slice(&written[..n]); + Ok(n) + } + + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + self.written.lock().extend_from_slice(buf); + self.wrote.notify_one(); + Ok(()) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (DuplexHalf(self.clone()), DuplexHalf(self.clone())) + } + } + + /// Either half of a split [`DuplexMock`]. + struct DuplexHalf(DuplexMock); + + impl ByteRead for DuplexHalf { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + ByteStream::read(&mut self.0, buf).await + } + } + + impl ByteWrite for DuplexHalf { + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + ByteStream::write_all(&mut self.0, buf).await + } + + async fn flush(&mut self) -> TransportResult<()> { + ByteStream::flush(&mut self.0).await + } } struct MockDialer(MockStream); @@ -790,6 +921,41 @@ mod tests { let _boxed: Box = Box::new(conn); } + // --- ByteStream::split ------------------------------------------------ + + #[tokio::test] + async fn split_halves_run_concurrently() { + let mut stream = DuplexMock::default(); + let (mut rx, mut tx) = stream.split(); + + // The read cannot complete until the write has run, so this joining at + // all is the assertion: both halves were live at the same time. + let mut buf = [0u8; 4]; + let (read, write) = tokio::join!(rx.read(&mut buf), tx.write_all(b"ping")); + + write.expect("write half"); + assert_eq!(read.expect("read half"), 4); + assert_eq!(&buf, b"ping"); + } + + #[tokio::test] + async fn split_halves_address_the_same_stream() { + let stream = MockStream::with_reads(vec![b"hi".to_vec()]); + let mut split_me = stream.clone(); + let (mut rx, mut tx) = split_me.split(); + + tx.write_all(b"out").await.expect("write half"); + let mut buf = [0u8; 8]; + let n = rx.read(&mut buf).await.expect("read half"); + + assert_eq!(&buf[..n], b"hi", "the read half drains the stream's reads"); + assert_eq!( + stream.0.lock().written, + b"out", + "the write half reaches the stream the halves came from" + ); + } + // --- OneShot / FramerFactory ------------------------------------------ #[test] diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 55b839f8..491436ad 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -49,8 +49,8 @@ pub use connector::{SessionClientConnector, SessionServerConnector}; pub use endpoint::{split_host_port, split_host_port_opt, EndpointError}; #[cfg(feature = "connector-session")] pub use io::{ - ByteStream, Datagram, DatagramBinder, Delay, FrameFault, FramedConnection, Framer, - FramerFactory, FramingDialer, FramingListener, IoError, OneShot, OneShotDialer, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, Delay, FrameFault, FramedConnection, + Framer, FramerFactory, FramingDialer, FramingListener, IoError, OneShot, OneShotDialer, OneShotListener, StreamDialer, StreamListener, }; #[cfg(feature = "connector-session")] diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 04140067..1c88e63f 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`ByteStream::split` for `EmbassyTcpStream` and `EmbassyUart`.** The TCP + stream delegates to `embassy-net`'s own lock-free `TcpSocket::split` — both + halves are a copy of the socket's `io` handle — and a stream whose socket has + already gone still yields halves, which report `TransportError::Closed` on + use exactly as the unsplit methods do. `EmbassyUart` simply hands back the two + halves it was built from. Like the rest of this module the halves are + force-`Send` under the single-core cooperative-executor invariant, since + `TcpReader`/`TcpWriter` are `!Send`. + +- **`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. + ### Fixed - **`EmbassyUdpBinder::bind` reports `TransportError::Busy`, not `Io`, when its @@ -25,6 +40,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`EmbassyTcpDialer` resolves hostnames, and `net` therefore enables + `embassy-net/dns`.** `StreamDialer::connect` takes a host *string* and the + trait puts resolution on the adapter, but the Embassy dialer only parsed IP + literals — so a connector handing through a name dialed fine on + `TokioTcpDialer` and failed with `TransportError::Io` here, forever, on every + reconnect. Connectors papered over it with per-runtime validation gates (the + MQTT connector rejected any plain `mqtt://` host that was not an IPv4 + literal); those are gone now that the contract holds on both adapters. + `EmbassyNet::tcp` takes the `Stack` into the dialer and queries it — `A` + first, `AAAA` only if that answers nothing — while an IP literal is still + parsed locally and never queried, so a stack with no resolver configured + dials literals exactly as before. + **Action required:** `embassy_net::new` adds the resolver socket itself, so + every application using the `net` feature must grow its `StackResources` + by one; too small a `N` panics at stack construction. A name needs a DNS + server in the config (DHCP supplies one; `StaticConfigV4` lists them in + `dns_servers`). `tests/dns.rs` covers the name, literal and + does-not-resolve paths against two crossover-wired stacks. + - **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). ### Added diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index a38a999e..a5b8eb40 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -36,6 +36,7 @@ net = [ "connector-io", "embassy-net-support", "embassy-net/udp", + "embassy-net/dns", "dep:embassy-futures", ] diff --git a/aimdb-embassy-adapter/src/io.rs b/aimdb-embassy-adapter/src/io.rs index 90af083e..d762e952 100644 --- a/aimdb-embassy-adapter/src/io.rs +++ b/aimdb-embassy-adapter/src/io.rs @@ -17,7 +17,7 @@ use core::future::Future; -use aimdb_core::session::{ByteStream, TransportError, TransportResult}; +use aimdb_core::session::{ByteRead, ByteStream, ByteWrite, TransportError, TransportResult}; use crate::SendFutureWrapper; @@ -65,6 +65,60 @@ where fn flush(&mut self) -> impl Future> + Send + '_ { SendFutureWrapper(async move { self.tx.flush().await.map_err(|_| TransportError::Closed) }) } + + /// Hand back the halves this type was built from: a UART arrives already + /// split, so there is nothing to divide and nothing to lock. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + ( + EmbassyUartReader(&mut self.rx), + EmbassyUartWriter(&mut self.tx), + ) + } +} + +/// The read half of a split [`EmbassyUart`]. +struct EmbassyUartReader<'a, Rd>(&'a mut Rd); + +/// The write half of a split [`EmbassyUart`]. +struct EmbassyUartWriter<'a, Wr>(&'a mut Wr); + +// SAFETY: single-core cooperative Embassy executor — see the module invariant, +// which is what already makes `EmbassyUart` itself `Send`. +unsafe impl Send for EmbassyUartReader<'_, Rd> {} +// SAFETY: as above. +unsafe impl Send for EmbassyUartWriter<'_, Wr> {} + +impl ByteRead for EmbassyUartReader<'_, Rd> +where + Rd: embedded_io_async::Read, +{ + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { self.0.read(buf).await.map_err(|_| TransportError::Io) }) + } +} + +impl ByteWrite for EmbassyUartWriter<'_, Wr> +where + Wr: embedded_io_async::Write, +{ + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + self.0 + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { self.0.flush().await.map_err(|_| TransportError::Closed) }) + } } #[cfg(test)] diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 4bd0f247..6afb5e1a 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -21,14 +21,15 @@ use alloc::string::ToString; use alloc::sync::Arc; use aimdb_core::session::{ - ByteStream, Datagram, DatagramBinder, PeerInfo, StreamDialer, StreamListener, TransportError, - TransportResult, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, PeerInfo, StreamDialer, + StreamListener, TransportError, TransportResult, }; use embassy_futures::yield_now; +use embassy_net::dns::DnsQueryType; use embassy_net::tcp::TcpSocket; use embassy_net::udp::{PacketMetadata, UdpSocket}; -use embassy_net::{IpEndpoint, IpListenEndpoint, Stack}; +use embassy_net::{IpAddress, IpEndpoint, IpListenEndpoint, Stack}; use embedded_io_async::Write as _; use crate::SendFutureWrapper; @@ -206,6 +207,118 @@ impl ByteStream for EmbassyTcpStream { socket.flush().await.map_err(|_| TransportError::Closed) }) } + + /// Borrow the socket's own halves, which `embassy-net` hands out lock-free. + /// + /// A stream whose socket is already gone still has to produce halves, so + /// each carries the `Option` and reports [`TransportError::Closed`] on use. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + let (rx, tx) = match self.socket.as_mut() { + Some(socket) => { + let (rx, tx) = socket.split(); + (Some(rx), Some(tx)) + } + None => (None, None), + }; + (EmbassyTcpReader(rx), EmbassyTcpWriter(tx)) + } +} + +/// The read half of a split [`EmbassyTcpStream`]; `None` once the socket is +/// gone. +struct EmbassyTcpReader<'s>(Option>); + +/// The write half of a split [`EmbassyTcpStream`]; `None` once the socket is +/// gone. +struct EmbassyTcpWriter<'s>(Option>); + +// SAFETY: single-core cooperative Embassy executor — see the module invariant, +// which is what already makes `EmbassyTcpStream` itself `Send`. +unsafe impl Send for EmbassyTcpReader<'_> {} +// SAFETY: as above. +unsafe impl Send for EmbassyTcpWriter<'_> {} + +impl ByteRead for EmbassyTcpReader<'_> { + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let rx = self.0.as_mut().ok_or(TransportError::Closed)?; + rx.read(buf).await.map_err(|_| TransportError::Io) + }) + } +} + +impl ByteWrite for EmbassyTcpWriter<'_> { + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let tx = self.0.as_mut().ok_or(TransportError::Closed)?; + tx.write_all(buf).await.map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { + let tx = self.0.as_mut().ok_or(TransportError::Closed)?; + tx.flush().await.map_err(|_| TransportError::Closed) + }) + } +} + +// `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. @@ -216,11 +329,46 @@ impl ByteStream for EmbassyTcpStream { /// [`TransportError::Busy`]. For a second *concurrent* connection call /// [`EmbassyNet::tcp`] again with its own buffers, which is the only way to get /// a second socket. +/// +/// Holds the stack as well as the socket because [`StreamDialer::connect`] +/// takes a host *string*: resolving it is the dialer's job, and on Embassy that +/// means a DNS query the stack owns. #[derive(Clone)] pub struct EmbassyTcpDialer { + stack: Stack<'static>, slot: Arc, } +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +// The `Arc` is already `Send`/`Sync` on that invariant; `Stack` +// is the `!Send` half, exactly as in `EmbassyUdpBinder` below. +unsafe impl Send for EmbassyTcpDialer {} +// SAFETY: same invariant. +unsafe impl Sync for EmbassyTcpDialer {} + +impl EmbassyTcpDialer { + /// Turn a host into an address to dial. + /// + /// An IP literal is never queried, so a stack with no DNS server configured + /// still dials literals. A name goes to the resolver, `A` first and `AAAA` + /// only if that answers nothing — the order `getaddrinfo` reports, so a + /// connector sees one behaviour across adapters. Every failure is + /// [`TransportError::Io`], matching `TokioTcpDialer`. + async fn resolve(&self, host: &str) -> TransportResult { + if let Ok(addr) = host.parse::() { + return Ok(addr.into()); + } + for qtype in [DnsQueryType::A, DnsQueryType::Aaaa] { + if let Ok(addrs) = self.stack.dns_query(host, qtype).await { + if let Some(addr) = addrs.first().copied() { + return Ok(addr); + } + } + } + Err(TransportError::Io) + } +} + impl StreamDialer for EmbassyTcpDialer { type Stream = EmbassyTcpStream; @@ -230,10 +378,10 @@ impl StreamDialer for EmbassyTcpDialer { port: u16, ) -> impl Future> + Send + 'a { SendFutureWrapper(async move { - // Resolution belongs to the adapter: IP literals here, hostnames - // once embassy-net's `dns` feature is on. - let addr: core::net::IpAddr = host.parse().map_err(|_| TransportError::Io)?; - let endpoint = IpEndpoint::new(addr.into(), port); + // Resolution belongs to the adapter, so the socket is only taken + // once there is somewhere to dial — a name that does not resolve + // must not hold the slot against a concurrent literal dial. + let endpoint = IpEndpoint::new(self.resolve(host).await?, port); let Some(socket) = self.slot.take() else { return Err(TransportError::Busy); @@ -519,6 +667,7 @@ impl EmbassyNet { tx_buffer: &'static mut [u8], ) -> EmbassyTcpDialer { EmbassyTcpDialer { + stack, slot: Arc::new(TcpSocketSlot::new(TcpSocket::new( stack, rx_buffer, tx_buffer, ))), @@ -559,6 +708,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. /// @@ -584,6 +742,7 @@ impl aimdb_core::session::Delay for EmbassyDelay { fn _transports_are_send() { fn assert_send() {} assert_send::(); + assert_send::(); assert_send::(); assert_send::>(); assert_send::(); diff --git a/aimdb-embassy-adapter/tests/dns.rs b/aimdb-embassy-adapter/tests/dns.rs new file mode 100644 index 00000000..9a780309 --- /dev/null +++ b/aimdb-embassy-adapter/tests/dns.rs @@ -0,0 +1,329 @@ +//! Host smoke for the resolution half of [`StreamDialer`] on Embassy. +//! +//! `connect` takes a host *string*, and every adapter must accept both a +//! hostname and an IP literal so no connector needs a per-runtime validation +//! gate. Only a real stack shows a name being queried, so two crossover-wired +//! `embassy-net` stacks drive it: B answers DNS on UDP/53, A dials it by name. +#![cfg(feature = "net")] + +extern crate alloc; + +use core::future::Future; + +use aimdb_core::session::{ + ByteStream, Datagram, DatagramBinder, StreamDialer, StreamListener, TransportError, +}; +use aimdb_embassy_adapter::net::EmbassyNet; +use embassy_net::udp::PacketMetadata; +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 must define 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}", 0u64); + +/// Real wall-clock time; a frozen `now()` stalls the stack's timers, and DNS +/// retransmission is on one of them. +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 A_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const B_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); +const PORT: u16 = 7301; +const DNS_PORT: u16 = 53; + +/// The only name the stub resolver knows, pointing at B. +const BROKER: &str = "broker.test"; + +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; 1024].into_boxed_slice()) +} + +fn meta() -> &'static mut [PacketMetadata] { + alloc::boxed::Box::leak(alloc::vec![PacketMetadata::EMPTY; 8].into_boxed_slice()) +} + +fn make_stack( + ip: Ipv4Address, + dns: Option, + 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 mut dns_servers = heapless::Vec::new(); + if let Some(server) = dns { + dns_servers.push(server).expect("one DNS server fits"); + } + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers, + }); + // One slot over the three sockets the test opens: `embassy_net::new` adds + // the resolver socket itself now that `net` enables `embassy-net/dns`. + 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); + } +} + +// =========================================================================== +// Stub resolver. +// =========================================================================== + +/// Build a reply to `query`: one `A` record holding [`B_IP`] when the query +/// names [`BROKER`], `NXDomain` otherwise. +/// +/// Enough of RFC 1035 for smoltcp's client, which checks the transaction id, +/// the question type and the answer's name — so the question is echoed verbatim +/// and the answer name repeated uncompressed rather than as a `0xC00C` pointer. +fn reply(query: &[u8]) -> Option> { + const A: u16 = 0x0001; + const IN: u16 = 0x0001; + + if query.len() < 12 { + return None; + } + // Walk the QNAME's length-prefixed labels to the root label. A query never + // uses compression, so every octet here is a length. + let mut root = 12; + while *query.get(root)? != 0 { + root += 1 + *query.get(root)? as usize; + } + let name = query.get(12..=root)?; + let qtype = u16::from_be_bytes([*query.get(root + 1)?, *query.get(root + 2)?]); + let question_end = root + 5; + let asked = name_to_str(name); + + let mut reply = query.get(..question_end)?.to_vec(); + let known = asked == BROKER && qtype == A; + // QR | recursion desired | recursion available, plus NXDomain (rcode 3) + // for anything but the one name-and-type the stub serves. A name it knows + // and a type it does not gets NOERROR with no answer, as a real resolver + // would for an `AAAA` on a v4-only host. + let flags: u16 = if asked == BROKER { 0x8180 } else { 0x8183 }; + reply[2..4].copy_from_slice(&flags.to_be_bytes()); + reply[6..8].copy_from_slice(&u16::from(known).to_be_bytes()); + if known { + reply.extend_from_slice(name); + reply.extend_from_slice(&A.to_be_bytes()); + reply.extend_from_slice(&IN.to_be_bytes()); + reply.extend_from_slice(&60u32.to_be_bytes()); // TTL + reply.extend_from_slice(&4u16.to_be_bytes()); // RDLENGTH + reply.extend_from_slice(&B_IP.octets()); + } + Some(reply) +} + +/// Render a wire-format QNAME as `label.label`, for comparison against +/// [`BROKER`]. +fn name_to_str(name: &[u8]) -> alloc::string::String { + let mut out = alloc::string::String::new(); + let mut i = 0; + while let Some(&len) = name.get(i) { + if len == 0 { + break; + } + let Some(label) = name.get(i + 1..i + 1 + len as usize) else { + break; + }; + if !out.is_empty() { + out.push('.'); + } + out.push_str(&alloc::string::String::from_utf8_lossy(label)); + i += 1 + len as usize; + } + out +} + +/// Answer queries on `stack`'s UDP/53 forever. +async fn serve_dns(stack: Stack<'static>) -> ! { + let mut socket = EmbassyNet::udp(stack, meta(), buf(), meta(), buf()) + .bind(DNS_PORT) + .await + .expect("bind the stub resolver"); + let mut rx = [0u8; 512]; + loop { + let (len, from) = socket.recv_from(&mut rx).await.expect("read a query"); + if let Some(reply) = reply(&rx[..len]) { + socket.send_to(&reply, from).await.expect("write a reply"); + } + } +} + +// =========================================================================== +// Rig. +// =========================================================================== + +/// Run `foreground` while both stacks poll and B resolves 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::{join, join4, select, Either}; + use futures::pin_mut; + + const WATCHDOG: Duration = Duration::from_secs(20); + + let (a_stack, mut a_net, a_ch) = make_stack(A_IP, Some(B_IP), 0x1111_2222); + let (b_stack, mut b_net, b_ch) = make_stack(B_IP, None, 0x3333_4444); + + let (a_state, a_rx, a_tx) = a_ch.split(); + let (b_state, b_rx, b_tx) = b_ch.split(); + a_state.set_link_state(LinkState::Up); + b_state.set_link_state(LinkState::Up); + + let background = join( + join4( + a_net.run(), + b_net.run(), + cable(a_tx, b_rx), + cable(b_tx, a_rx), + ), + serve_dns(b_stack), + ); + let foreground = foreground(a_stack, b_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"), + } + }) +} + +// =========================================================================== +// Tests. +// =========================================================================== + +/// A hostname is resolved rather than rejected: dial by name and exchange a +/// byte, proving the resolved address is the one that got connected. +#[test] +fn dials_a_hostname() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + let (dialed, accepted) = join(dialer.connect(BROKER, PORT), listener.accept()).await; + let mut client = dialed.expect("a hostname must dial"); + let (mut server, _peer) = accepted.expect("accept"); + + client.write_all(b"ping").await.expect("write"); + client.flush().await.expect("flush"); + let mut got = [0u8; 4]; + server.read(&mut got).await.expect("read"); + assert_eq!(&got, b"ping"); + }); + assert_eq!(outcome, Ok(())); +} + +/// An IP literal dials without a query, so a deployment with no resolver +/// configured is unaffected by the name path. +#[test] +fn dials_an_ip_literal() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + let (dialed, accepted) = join(dialer.connect("192.168.0.2", PORT), listener.accept()).await; + dialed.expect("an IP literal must dial"); + accepted.expect("accept"); + }); + assert_eq!(outcome, Ok(())); +} + +/// A name that does not resolve fails as a connect failure would *and* hands +/// the socket back, so a reconnect loop is not stuck on +/// [`TransportError::Busy`] forever. +#[test] +fn an_unresolvable_name_fails_and_frees_the_socket() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + assert_eq!( + dialer.connect("nowhere.test", PORT).await.err(), + Some(TransportError::Io), + "an unknown name is an I/O failure, not a panic or a hang" + ); + + let (dialed, accepted) = join(dialer.connect(BROKER, PORT), listener.accept()).await; + dialed.expect("the socket must still be dialable"); + accepted.expect("accept"); + }); + assert_eq!(outcome, Ok(())); +} diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a08977a8..7c3467d3 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,17 +7,185 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed +### Changed (breaking) + +- **The embedded session is event-driven: nothing polls.** The + loop used to wake every 10 ms to ask three sources whether they had work, + which on a battery node is the only state that normally runs — and the + "non-blocking peek" it polled with could block indefinitely, parking the loop + and with it the pings, the liveness check and every queued publish. Both were + one problem. The stream is now split into halves driven by three futures in + one `select`: a reader that lifts bytes off the socket, a writer that drains + encoded packets, and the session itself selecting on two channels and one + timer. Measured against the loop it replaces: **5 wakes in 3 seconds where + the poll cost ~300**, and a QoS 1 publish no longer spins at 1 kHz waiting + inline for its PUBACK — the acknowledgement arrives through the read half + like any other packet while the ping deadline keeps running. + + Two consequences worth knowing about. A partial packet is now "not enough + yet" rather than a parked loop, because packets are reassembled incrementally + instead of being read to a length the peer promised. And **the largest MQTT + packet the session can receive is 3328 bytes** (previously 4096): the + reassembly buffer, the read scratch and the inbound slot are carved out of + the same total the old single buffer cost, rather than added to it, and the + reassembly buffer keeps one read chunk of that in reserve so a chunk + completing one packet can still carry the head of the next. Outbound packets + are encoded to exactly their own size on the heap the action channel already + uses, so they gain no fixed cap. + + Size the inbound topics accordingly: an over-limit packet ends the session + rather than being skipped. An ordinary publish then costs one dropped message + and a reconnect, because the session is clean-start and the broker requeues + nothing — but a **retained** message lives with the topic, so it is replayed + on every resubscribe and reconnect-loops the connector until it is cleared. + Either way the cause is named in the session's error log. + +- **TLS runs that same session.** `mqtts://` was a loop of + its own because the MQTT client wanted a readiness peek that a TLS session + cannot answer honestly — its readiness is two-layered, since bytes on the + wire may decrypt to no application data at all. Nothing peeks any more, so + the bespoke `Connection`, the readiness probe onto the raw socket underneath + the TLS session, and the single `RefCell` that wrapped the whole socket so + both could reach it are all gone. What replaces them is one lock per + direction behind a cloneable handle, which is what lets `embedded-tls`'s + reader and writer run at once. TLS is now two adapter types and a handshake. + +- **`Settings::poll_interval` is removed.** There is no poll to pace. The other + fields are unchanged, and `ping_interval`, `connection_event_max_interval` + and `stabilisation_interval` now arm real deadlines rather than being + compared against a 10 ms tick. + +- **`BrokerTransport` and `SocketTransport` are removed** from + `embedded::session`. They existed to carry the readiness peek that a + `ByteStream` could not express; with the peek gone, a runtime that can dial a + `StreamDialer` can speak MQTT with no protocol code and no + `embedded-io-async` of its own. `MqttConnector::new(..).transport(..)` and + `.tls(..)` are untouched — this only affects code naming those two items + directly. + +- **The `D::Stream: embedded_io_async::{Read, Write, ReadReady}` bounds are + gone** from the connector's builders. A relaxation, so no caller breaks: the + connector now reaches a stream only through core's byte-stream traits. + +- **The `mountain-mqtt` dependency is the codec alone.** It + moves to `aimdb-mountain-mqtt` 0.5.1 — upstream `main` with a zero-line + source delta — with `default-features = false` and **no features**, `defmt` + added back on the defmt leg alone. What this crate takes from it is the + sans-io half: the packet types, the readers and writers, the client state + machine. The driver half — the incremental reader, the loop, the in-flight + tracking — lives here now, so `embedded-hal-async` leaves the crate entirely + and `embedded-io-async` moves to the `embedded-tls` feature, the only place + that still names those traits. A Makefile guard asserts the dependency's + subtree stays codec-only. + +- **At-most-once delivery is unchanged, but a publish fails later.** An action + is still taken off the queue before it is performed and still dropped if the + session ends, logged with its topic. What changed is *when* a publish counts + as failed: it no longer blocks the loop waiting for its acknowledgement, so a + slow broker no longer stops pings, and only one QoS 1 publish is in flight at + a time — the action arm simply parks until the PUBACK lands. + + Everything the protocol obliges the session to send — CONNECT, SUBSCRIBE, + PUBLISH, and the PUBACKs answering QoS 1 delivery — waits for a slot in the + write queue rather than being discarded when it is full, which is also where + the session takes backpressure from a peer that has stopped reading. Only + pings are still dropped on a full queue: a ping arms no response deadline, so + skipping one costs nothing and the next deadline reissues it, whereas parking + on one would stall the loop that has to notice the link is gone. + +- **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`, renamed outright with no compatibility re-export. A shim would + have been theatre: the builders those modules held are gone too, so the old + import fails either way. Failing at the module boundary — `unresolved import + ... could not find 'tokio_client'` — at least points at the line to change, + where a module alias would have resolved and then failed on a type the caller + never named. +- **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. Both backends + honour credentials in the URL authority (`mqtt://user:pass@host`), and on + both the setter takes precedence over them — it is the only way to name a + password that is not URL-safe. +- **`.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. +- **Two protocol backends behind one type.** `Native` is `rumqttc` (QoS 0–2, + rustls); `Embedded` is `mountain-mqtt` over a caller-supplied transport + (QoS 0–1 — a `qos=2` route publishes at QoS 1, and the build now names each + such route in a warning, since the same route gets exactly-once on `Native`). + The Tokio path is unchanged; Embassy callers now write + `MqttConnector::new(url).transport(EmbassyNet::tcp(..))`, or + `.tls(EmbassyNet::tcp(..), opts)` for `mqtts://`, 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 `embedded::session::run_sessions` — one loop for both plain + and TLS, extracted from the TLS path already running it. + + +- **`TlsOptions::new` requires a `Send` RNG** — + `&'static mut (dyn CryptoRngCore + Send)`. Every concrete CSPRNG already + satisfies it (`embassy_stm32::rng::Rng` included), so callers are unchanged + textually. With it, `TlsSlot` becomes core's `OneShot` and this + crate carries **zero `unsafe impl`s** (was two). +- **Issue #131:** the Embassy `MqttConnectorBuilder::new` takes the network stack — `MqttConnectorBuilder::new(broker_url, stack)` — since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls and the `MqttLinkExt`/`MqttOutboundLinkExt` link-builder ext traits are non-generic over the runtime. -- **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 - `#[cfg(feature = "tracing")]` the facade carries itself. The `tracing` feature - no longer pulls `dep:tracing`; a mirrored `log` feature is added alongside it. - No change to what is emitted, or to a consumer that enables `tracing`. + +- **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. +- `spawn_event_loop()` → `build_event_loop_future()` (Tokio side). `spawn_outbound_publishers()` → `collect_outbound_futures()` on both Tokio and Embassy. +- The `transport::Connector` impl on `MqttConnectorImpl` was removed alongside the discarded `Arc` return path; direct programmatic publish was already unreachable through the `AimDbBuilder` public API. +- **`MqttConnectorImpl` (Embassy) removed entirely (M17).** It was a build-time aggregation holder; its logic collapsed into the private `setup_manager` + the pump composition in `build()`. Register via `MqttConnectorBuilder` as before — the builder's public API is unchanged. ### 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. +- **`embedded::session` — 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 @@ -31,46 +199,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`MqttConnectorBuilder::with_credentials(username, password)` (Embassy, design 044 D8).** Feeds the MQTT CONNECT username/password on both the plain and TLS transports. The `aimdb-dev/mountain-mqtt` fork submodule is bumped to pick up upstream 0.4's `ConnectionSettings::with_auth`/`authenticated` (`aimdb-dev/mountain-mqtt@89a7129`). - `make check` gains an `embassy-runtime,embassy-tls,defmt` clippy leg on `thumbv7em-none-eabihf`. -### Fixed -- **The rustls path no longer builds its configuration through - `TlsConfiguration::default()`**, which `expect`s on `load_native_certs()` and - `unwrap`s each `add()`. Two panics on the connect path, in a crate reachable - through an FFI boundary where a panic is undefined behaviour rather than an - error. The configuration is now built explicitly, and a machine with no usable - trust roots gets a message saying so. +- **`MqttLinkExt` / `MqttOutboundLinkExt` — the MQTT knobs, now where the protocol lives (Issue #134, design 034 §3.6).** New `link_ext` module (compiled on every feature leg, `alloc`-only) with extension traits over core's generic link builders: `MqttLinkExt::with_qos(u8)` on outbound *and* inbound links (publish / subscribe QoS), and `MqttOutboundLinkExt::with_retain(bool)` on outbound links only (retain is a publish-side flag). They push the exact `("qos", …)` / `("retain", …)` option keys both clients have always read from `protocol_options` — wire behavior identical to the deleted core methods; only an extra `use aimdb_mqtt_connector::{MqttLinkExt, MqttOutboundLinkExt};` is needed. The crate now declares `extern crate alloc` unconditionally. ### Changed -- **Embassy connector reuses the upstream session loop instead of copying it.** The TLS path no longer duplicates mountain-mqtt-embassy's `handle_messages`/`State`/`ChannelEventHandler`/`try_action` (~195 lines): the `aimdb-dev/mountain-mqtt` fork now exposes them publicly (plus a `run_with_subscriptions`), so the plain and TLS transports share one keep-alive/action-dispatch/event loop and can no longer drift. The plain `mqtt://` path also switches to `run_with_subscriptions`, which **re-subscribes inbound topics on every connection** — previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. The submodule is bumped to the matching change; no public API change. -- **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. - -### Changed (breaking) - -- **`TlsOptions::new` requires a `Send` RNG** — - `&'static mut (dyn CryptoRngCore + Send)`. Every concrete CSPRNG already - satisfies it (`embassy_stm32::rng::Rng` included), so callers are unchanged - textually. With it, `TlsSlot` becomes core's `OneShot` and this - crate carries **zero `unsafe impl`s** (was two). -- **Issue #131:** the Embassy `MqttConnectorBuilder::new` takes the network stack — `MqttConnectorBuilder::new(broker_url, stack)` — since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls and the `MqttLinkExt`/`MqttOutboundLinkExt` link-builder ext traits are non-generic over the runtime. +- **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 + `#[cfg(feature = "tracing")]` the facade carries itself. The `tracing` feature + no longer pulls `dep:tracing`; a mirrored `log` feature is added alongside it. + No change to what is emitted, or to a consumer that enables `tracing`. -### Added -- **`MqttLinkExt` / `MqttOutboundLinkExt` — the MQTT knobs, now where the protocol lives (Issue #134, design 034 §3.6).** New `link_ext` module (compiled on every feature leg, `alloc`-only) with extension traits over core's generic link builders: `MqttLinkExt::with_qos(u8)` on outbound *and* inbound links (publish / subscribe QoS), and `MqttOutboundLinkExt::with_retain(bool)` on outbound links only (retain is a publish-side flag). They push the exact `("qos", …)` / `("retain", …)` option keys both clients have always read from `protocol_options` — wire behavior identical to the deleted core methods; only an extra `use aimdb_mqtt_connector::{MqttLinkExt, MqttOutboundLinkExt};` is needed. The crate now declares `extern crate alloc` unconditionally. +- **Embassy connector reuses the upstream session loop instead of copying it.** The TLS path no longer duplicates mountain-mqtt-embassy's `handle_messages`/`State`/`ChannelEventHandler`/`try_action` (~195 lines): the `aimdb-dev/mountain-mqtt` fork now exposes them publicly (plus a `run_with_subscriptions`), so the plain and TLS transports share one keep-alive/action-dispatch/event loop and can no longer drift. The plain `mqtt://` path also switches to `run_with_subscriptions`, which **re-subscribes inbound topics on every connection** — previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. The submodule is bumped to the matching change; no public API change. +- **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. -### Changed - **Connector-build errors carry their message on `no_std` too (Issue #129).** With `DbError` unified on `alloc::String`, the dual `#[cfg]` error-construction branches in both clients collapse to one `DbError::runtime_error(...)` expression; the Embassy client's "Failed to build MQTT connector" detail is no longer dropped on embedded targets. No API change. - **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers (the connector now writes only its `Connector`/`Source` I/O adapters and composes the pumps in `build()`). Per-route configuration (`qos` / `retain` / `timeout_ms` / …) is threaded from each link URL's query via `ConnectorConfig::from_query`. `std` now enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. - **Outbound publisher survives a consumer lag (Embassy client, Issue #39).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. - **M17 — Embassy client rebuilt on core's pumps via the adapter spine ([Design 033](../docs/design/033-M17-unify-connectors-drop-send.md)).** The hand-rolled outbound publisher and inbound event-router loops are gone: the Embassy half now rides core's `pump_sink` / `pump_source` through the force-`Send` `EmbassySink` / `EmbassySource` bridges in `aimdb-embassy-adapter::connectors`, exactly like the Tokio half rides them — this crate contributes only the broker **manager task** (mountain-mqtt's `run`, force-`Send`ed once via `into_box_future`) and the `MqttSink` / `MqttSource` over its action/event channels. **No `unsafe`, no `SendFutureWrapper`** remain in this crate. Per-route `qos` / `retain` still arrive from each link URL's query (now via `ConnectorConfig::protocol_options`, parsed per publish). Note: per-message inbound routing logs moved from this crate's `defmt` calls into core's `pump_source` (`tracing` feature), so defmt-only MCU builds no longer log per-message routing failures. -### Changed (breaking) +### Fixed -- **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. -- `spawn_event_loop()` → `build_event_loop_future()` (Tokio side). `spawn_outbound_publishers()` → `collect_outbound_futures()` on both Tokio and Embassy. -- The `transport::Connector` impl on `MqttConnectorImpl` was removed alongside the discarded `Arc` return path; direct programmatic publish was already unreachable through the `AimDbBuilder` public API. -- **`MqttConnectorImpl` (Embassy) removed entirely (M17).** It was a build-time aggregation holder; its logic collapsed into the private `setup_manager` + the pump composition in `build()`. Register via `MqttConnectorBuilder` as before — the builder's public API is unchanged. +- **A broker hostname works on every backend, `mqtt://` and `mqtts://` alike.** + `setup_manager` vetted plain `mqtt://` hosts with `Ipv4Addr::from_str`, a + rule inherited from the days when this crate built the `embassy_net` + address itself. Since the host string now goes to a `StreamDialer`, that gate + described no dialer in particular: `mqtt://broker.local:1883` connected on + `Native`, and the same URL with `.transport(TokioNet::tcp())` — a dialer that + resolves names perfectly well — was rejected at `build()`. On Embassy the + mirror image bit `mqtts://`, which skips the gate: its hostname reached a + dialer that parsed only IP literals — and a hostname is the configuration + `build()` steers TLS users toward — so it reconnect-looped. The gate is gone + and `EmbassyTcpDialer` resolves (see the adapter's changelog: its `net` + feature now enables `embassy-net/dns` and each stack needs one more + `StackResources` slot). `backend_parity` dials `localhost` on both backends. +- **The embedded session's dead retry path is gone, and a dropped publish now + says so.** `try_action` parked a failed action in `SessionState` for the next + loop iteration to retry, but both call sites propagated the error with `?`, + which ends the session — and `run_sessions` built a *fresh* `SessionState` + per connection, so the parked action was dropped with the old one. + `take_pending_action` could only ever return `None` and `is_retry` was never + `true`. The mechanism is removed rather than repaired: the loss window is + narrow (a dead link is normally found by the 10 ms poll or the 2 s ping, not + by a publish), and where a publish *is* the detector — a response timeout — + the broker has most likely already received the message, so a resend would + duplicate it. An action that fails now logs its topic and the `ClientError` + before the session ends, so the drop is visible instead of silent, and + `handle_messages` documents the at-most-once contract: the action in flight + is lost, everything still queued survives. Dropping the parking slot also + makes `SessionState` non-generic and removes the unused type parameter it + forced onto `ChannelEventHandler`. +- **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. + + +- **The rustls path no longer builds its configuration through + `TlsConfiguration::default()`**, which `expect`s on `load_native_certs()` and + `unwrap`s each `add()`. Two panics on the connect path, in a crate reachable + through an FFI boundary where a panic is undefined behaviour rather than an + error. The configuration is now built explicitly, and a machine with no usable + trust roots gets a message saying so. ## [0.6.0] - 2026-05-22 diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 42189d9c..18a6221d 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,55 @@ 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", + # Executor-independent: channels and future combinators only. + "embassy-sync", + "dep:embassy-futures", + # `mountain-mqtt`'s packet types take `heapless::Vec`, so the session names + # it to build them. Same 0.8 the dependency itself resolves. + "dep:heapless", +] + +# 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). -embassy-tls = [ - "embassy-runtime", + +# 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:embedded-io-async", "dep:rand_core", + "dep:embedded-io-async", +] + +# `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", "embassy-net/dns", "embassy-net/udp", ] @@ -65,9 +92,49 @@ 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,9 +165,10 @@ 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-futures = { workspace = true, optional = true } +heapless = { workspace = true, optional = true } embassy-net = { version = "0.9.0", optional = true, features = [ "tcp", "dhcpv4", @@ -108,15 +176,11 @@ embassy-net = { version = "0.9.0", optional = true, features = [ "proto-ipv4", ] } -# MQTT for Embassy — aimdb-dev fork: adds `ConnectionSettings::authenticated` -# and embassy 0.9/0.8/0.5.1, so upstream 0.2.0 doesn't build. A patch won't fix -# that downstream. Keyed as before to keep imports and feature references. -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 } +# MQTT for Embassy — aimdb-dev fork: upstream `main` with a zero-line source +# delta, published because crates.io `mountain-mqtt` is still 0.2.0 and a +# published crate cannot take a git dependency. Keyed as before to keep imports +# and feature references. Retire it when upstream releases 0.5.0 or later. +mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.5.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ @@ -127,15 +191,24 @@ embedded-tls = { version = "0.19", default-features = false, optional = true, fe embedded-io-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] +embedded-io-async = { workspace = true } +# 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", + "net", ] } [package.metadata.docs.rs] diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index a29276a7..7e9c440a 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: @@ -99,8 +95,9 @@ Example: #![no_main] use aimdb_core::AimDbBuilder; +use aimdb_embassy_adapter::net::EmbassyNet; 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 +105,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) @@ -285,6 +285,14 @@ MQTT Quality of Service levels are configured using integers: - **QoS 1**: Commands, important events (default) - **QoS 2**: Critical state changes, financial transactions +> **Backend note:** only the `std` (`rumqttc`) backend implements QoS 2. On the +> `embedded` backend a `qos=2` route publishes at QoS 1 (at-least-once) and logs +> a warning naming that route at startup, so the same route gives a weaker +> guarantee there. Design for at-least-once if the route has to run on both. +> +> Set QoS with `.with_qos(n)` on the link — a `?qos=` query in the link URL is +> stripped during parsing and has no effect. + ## Error Handling ```rust @@ -311,13 +319,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 +352,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..21075236 --- /dev/null +++ b/aimdb-mqtt-connector/src/connector.rs @@ -0,0 +1,218 @@ +//! One `MqttConnector` over two protocol backends. +//! +//! The two backends cannot converge: `rumqttc`'s `Transport` is a closed enum, +//! so no stream can be injected, while `mountain-mqtt` is generic over +//! `embedded-io-async`. Broker URL, client id and credentials live here rather +//! than in either backend, so there is one set of setters whichever 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; without + /// [`with_client_id`](Self::with_client_id) the client id is a generated + /// UUID. + 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. + #[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. + #[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 `std`, so a `no_std` build that +/// forgets `.transport(..)` fails here rather than deep in core. +#[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 `std` 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, +{ + 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, +{ + 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/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs deleted file mode 100644 index 07d35a0f..00000000 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! TLS transport for the Embassy MQTT client. -//! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy -//! TCP socket, wrapped in mountain-mqtt's [`ConnectionEmbedded`] so the MQTT -//! layer is identical to the plain path. Certificate verification is -//! `rustpki` (pure Rust) against the application-embedded root CA, with time -//! from the [`sntp`](crate::sntp) task; entropy comes from the -//! application-injected TRNG ([`TlsOptions::new`]). -//! -//! 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. - -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 embedded_tls::pki::CertVerifier; -use embedded_tls::{ - Aes128GcmSha256, Certificate, CryptoProvider, CryptoRngCore, TlsConfig, TlsConnection, - TlsContext, TlsError, TlsVerifier, -}; - -use embedded_io_async::Write as _; - -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}; - -/// Room for the server's leaf certificate (DER) inside the verifier — 4 KB -/// covers RSA-4096 leaves with headroom. -const CERT_BUFFER_SIZE: usize = 4096; - -/// Minimum TLS record read buffer: a TLS 1.3 peer may send full-size records -/// (2^14 payload + record overhead) regardless of our `max_fragment_length` -/// offer, and `embedded-tls` fails any record larger than the buffer — a -/// smaller buffer works until the first big record, then reconnect-loops. -/// Enforced at `build()` so undersizing fails loudly instead. -pub(crate) const READ_BUF_MIN: usize = 16_640; - -/// TLS materials for a `mqtts://` broker connection. -/// -/// All references are `'static`: the session outlives `build()`, so buffers -/// and the RNG live in `StaticCell`s (or equivalents) owned by the -/// application — the one party that knows the board's memory budget. -pub struct TlsOptions { - pub(crate) rng: &'static mut (dyn CryptoRngCore + Send), - 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, -} - -impl TlsOptions { - /// TLS with certificate verification against `ca_der` (the root CA, DER). - /// - /// * `rng` — CSPRNG for the handshake; on STM32 the hardware TRNG - /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). Must be - /// `Send`, which every concrete CSPRNG satisfies. - /// * `read_buf` — TLS record read buffer. At least 16 640 bytes (a - /// TLS 1.3 peer may send full-size records regardless of our - /// `max_fragment_length` offer); `build()` rejects smaller buffers. - /// * `write_buf` — TLS record write buffer; 4 096 bytes is plenty for - /// MQTT-sized writes. - pub fn new( - rng: &'static mut (dyn CryptoRngCore + Send), - ca_der: &'static [u8], - read_buf: &'static mut [u8], - write_buf: &'static mut [u8], - ) -> Self { - Self { - rng, - ca_der, - read_buf, - write_buf, - sntp_server: "pool.ntp.org", - } - } - - /// 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; - self - } -} - -/// The TCP socket 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`. -/// -/// 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>); - -impl Clone for SharedTcp<'_, '_> { - fn clone(&self) -> Self { - Self(self.0) - } -} - -impl SharedTcp<'_, '_> { - fn can_recv(&self) -> bool { - self.0.borrow().can_recv() - } -} - -impl embedded_io_async::ErrorType for SharedTcp<'_, '_> { - type Error = embassy_net::tcp::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<'_, '_> { - 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<'_, '_> { - async fn write(&mut self, buf: &[u8]) -> Result { - self.0.borrow_mut().write(buf).await - } - - async fn flush(&mut self) -> Result<(), Self::Error> { - self.0.borrow_mut().flush().await - } -} - -/// mountain-mqtt [`Connection`] over an open TLS session. -/// -/// Not `ConnectionEmbedded`: that adapter needs `ReadReady`, which -/// [`TlsConnection`] cannot offer — and TLS readiness is two-layered anyway. -/// Data can be ready as already-decrypted plaintext left over from a record -/// that carried more than one MQTT packet (`plaintext_remaining`), or as -/// undecrypted bytes on the wire (`can_recv` on the shared socket). Checking -/// both keeps coalesced packets flowing promptly. -/// -/// Known limitation: wire bytes that decrypt to *no* application data -/// (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>, - /// Decrypted-but-unread plaintext left in the TLS record buffer. - plaintext_remaining: usize, -} - -impl Connection for TlsSession<'_, '_, '_> { - async fn send(&mut self, buf: &[u8]) -> Result<(), PacketWriteError> { - self.tls - .write_all(buf) - .await - .map_err(|_| PacketWriteError::ConnectionSend)?; - self.tls - .flush() - .await - .map_err(|_| PacketWriteError::ConnectionSend) - } - - async fn receive(&mut self, buf: &mut [u8]) -> Result<(), PacketReadError> { - let mut filled = 0; - while filled < buf.len() { - let mut read_buffer = self - .tls - .read_buffered() - .await - .map_err(|_| PacketReadError::ConnectionReceive)?; - filled += read_buffer.pop_into(&mut buf[filled..]); - self.plaintext_remaining = read_buffer.len(); - } - Ok(()) - } - - async fn receive_if_ready(&mut self, buf: &mut [u8]) -> Result { - if self.plaintext_remaining == 0 && !self.socket.can_recv() { - return Ok(false); - } - self.receive(buf).await?; - Ok(true) - } -} - -/// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate -/// verification (time from [`SntpClock`]). 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>, -} - -impl CryptoProvider for TrngProvider<'_> { - type CipherSuite = Aes128GcmSha256; - // Unused (no client certificates); any `AsRef<[u8]>` satisfies the bound. - type Signature = &'static [u8]; - - fn rng(&mut self) -> impl CryptoRngCore { - &mut *self.rng - } - - fn verifier(&mut self) -> Result<&mut impl TlsVerifier, TlsError> { - Ok(&mut self.verifier) - } -} - -/// 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). -#[allow(clippy::too_many_arguments)] -pub(crate) async fn run_tls( - stack: Stack<'static>, - options: TlsOptions, - host: String, - 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>, -) -> ! { - let TlsOptions { - rng, - ca_der, - read_buf, - write_buf, - .. - } = 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 - // routing survives reconnects. Built once — borrows `topics` for the loop. - let subscribe_topics: Vec<(&str, QualityOfService)> = topics - .iter() - .map(|topic| (topic.as_str(), QualityOfService::Qos1)) - .collect(); - - 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() { - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS: waiting for SNTP time sync..."); - while sntp::unix_now().is_none() { - Timer::after_millis(500).await; - } - } - - let address = match resolve(stack, &host).await { - Some(address) => address, - None => { - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT-TLS: DNS lookup for {} failed, will retry", - host.as_str() - ); - Timer::after(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 tls_config = TlsConfig::new().with_server_name(&host); - let mut tls = TlsConnection::new(shared.clone(), &mut *read_buf, &mut *write_buf); - let provider = TrngProvider { - rng: &mut *rng, - verifier: CertVerifier::new(Certificate::X509(ca_der)), - }; - if let Err(e) = tls.open(TlsContext::new(&tls_config, provider)).await { - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT-TLS: handshake failed, will retry: {:?}", - defmt::Debug2Format(&e) - ); - #[cfg(not(feature = "defmt"))] - let _ = e; - Timer::after(settings.reconnection_delay).await; - continue; - } - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS: session established"); - - let connection = TlsSession { - 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 connection_id = ConnectionId::new(connection_index); - connection_index += 1; - - let event_handler: ChannelEventHandler< - '_, - AimdbMqttAction, - AimdbMqttEvent, - MAX_PROPERTIES, - CHANNEL_SIZE, - > = ChannelEventHandler::new(connection_id, &event_sender, &state); - - let mut client = ClientNoQueue::new( - connection, - &mut mqtt_buffer, - delay, - timeout_millis, - event_handler, - ); - - if let Err(error) = handle_messages( - connection_id, - &mut client, - &state, - &connection_settings, - &subscribe_topics, - &event_sender, - &mut action_receiver, - &settings, - ) - .await - { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT-TLS: session errored: {:?}", error); - event_sender - .send(MqttEvent::Disconnected { - connection_id, - error, - }) - .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, - } -} - -/// Parse the broker host as an IP literal (with or without URL-style -/// brackets, `[::1]`). `build()` uses this to vet `mqtts://` hosts: -/// certificate verification prefers a DNS name, but an IPv4 literal can -/// still pass through `rustpki`'s CN fallback when a private CA pins the -/// dotted quad there (the dev bench does) — allowed with a warning. An IPv6 -/// literal can never match (the verifier's hostname charset has no `:`) and -/// is rejected. -pub(crate) fn host_ip_literal(host: &str) -> Option { - let host = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host); - host.parse::().ok() -} diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs new file mode 100644 index 00000000..adaf318f --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -0,0 +1,134 @@ +//! Session cadence, the events a session reports, and the channels it reports +//! them over. +//! +//! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and +//! source need no force-`Send` wrapper. Time comes from core's +//! [`aimdb_core::session::Delay`], so nothing here names an executor. + +use core::time::Duration; + +use aimdb_core::RuntimeOps; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use mountain_mqtt::client::{ClientError, EventHandlerError}; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::mqtt_manager::ConnectionId; +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, + /// 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), + 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, + }, +} diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs new file mode 100644 index 00000000..e6cf2362 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -0,0 +1,619 @@ +//! 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. +//! +//! See the crate docs for a usage example. + +pub mod manager; +pub mod session; + +// The session's own machinery: incremental framing, and the three futures that +// replace the polled loop. +pub(crate) mod packet_reader; +pub(crate) mod session_loop; + +// 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::pin::Pin; + +#[cfg(feature = "embassy-tls")] +use aimdb_embassy_adapter::connectors::into_box_future; + +use mountain_mqtt::client::ConnectionSettings; +use mountain_mqtt::data::quality_of_service::QualityOfService; + +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; + +/// What the pumps ask the session to put on the wire. +/// +/// The session encodes each of these itself against the MQTT client state, so +/// an action is data rather than a call (`session_loop::perform`). +#[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, + }, +} + +/// What the session hands back for `pump_source` to route. +#[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. +// =========================================================================== + +/// Turns a `pump_sink` publish into an `AimdbMqttAction::Publish` on 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 = match opt_u8(config, "qos").map(map_qos) { + Some(Ok(qos)) => qos, + Some(Err(e)) => return Box::pin(async move { Err(e) }), + None => 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(()) + }) + } +} + +/// Drains the session's event channel as `(topic, payload)` for `pump_source`. +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, so it is neither `Sync` nor takeable +/// through the `&self` that [`ConnectorBuilder::build`] receives. +#[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, +{ + Box::pin(async move { + let topics = inbound_topics(db); + warn_unsupported_qos(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, broker.credentials.as_ref()); + + 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, +{ + Box::pin(async move { + let topics = inbound_topics(db); + warn_unsupported_qos(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, broker.credentials.as_ref()); + + 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, + /// Credentials from the URL authority (`mqtt://user:pass@host`), which + /// `MqttConnector::with_credentials` overrides. + credentials: Option<(String, String)>, +} + +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), plus any credentials in the authority. +/// +/// A username without a password is ignored rather than sent half-formed, +/// which is what the `rumqttc` backend does with the same URL. +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.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 }); + let credentials = match (connector_url.username, connector_url.password) { + (Some(username), Some(password)) => Some((username, password)), + _ => None, + }; + Ok(BrokerUrl { + tls, + host: connector_url.host, + port, + credentials, + }) +} + +/// Build the `ConnectionSettings<'static>` for MQTT CONNECT. +/// +/// `credentials` is what the connector was given; `url_credentials` is what the +/// broker URL's authority carried. The explicit setter wins, as it does on the +/// `rumqttc` backend — it is the only way to name a password that is not +/// URL-safe. +/// +/// The identity strings are leaked to reach `'static` — the session task is +/// `'static`, so what it borrows must be too — giving each connector its own +/// identity rather than a shared one. +/// +/// The leak is per `build()` call, not per connector: three short strings, once +/// at startup, which is the normal case and indistinguishable from a static. +/// Only a process that rebuilds the database repeatedly accumulates them. The +/// alternative is owning the strings in the session task and rebuilding +/// `ConnectionSettings` per connection, which costs four signatures for memory +/// nobody misses. +fn static_connection_settings( + client_id: Option<&str>, + credentials: Option<&(String, String)>, + url_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.or(url_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. 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, +{ + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); + + // The dialer is both the transport and the clock the session runs on. + let host = broker.host.clone(); + let port = 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( + dialer, + host, + port, + topics, + connection_settings, + Settings::default(), + events, + actions, + 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, +{ + 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 to mountain-mqtt's `QualityOfService`. +/// +/// `2` downgrades to 1 — this client implements no exactly-once handshake, +/// where [`Native`](crate::connector::Native) honours the same route URL +/// exactly. [`warn_unsupported_qos`] is what says so, once per route at build. +/// Anything above 2 is rejected, as `Native` rejects it. +fn map_qos(qos: u8) -> Result { + match qos { + 0 => Ok(QualityOfService::Qos0), + 1 => Ok(QualityOfService::Qos1), + 2 => Ok(QualityOfService::Qos1), + _ => Err(PublishError::UnsupportedQoS), + } +} + +/// Name, at build, every outbound route asking for a QoS this backend cannot +/// give. +/// +/// Checked here rather than in [`map_qos`] because `map_qos` runs per publish: +/// warning there would repeat at the route's own rate for the life of the +/// process, and latching it to fire once would hide the message whenever the +/// first publish beats the logger into place. The route set is fixed at build, +/// so once per offending route — naming the route, while the caller is still +/// reading startup output — is both quieter and more use than either. +/// +/// Both facades fire: they are independent, and neither covers the other. +/// `log_warn!` reaches `tracing`/`log` when this backend runs on a host, +/// `defmt` reaches an MCU. +fn warn_unsupported_qos(db: &aimdb_core::builder::AimDb) { + for route in db.collect_outbound_routes("mqtt") { + let asked = route + .config + .iter() + .find(|(k, _)| k == "qos") + .and_then(|(_, v)| v.parse::().ok()); + + if asked == Some(2) { + aimdb_core::log_warn!( + "MQTT: route '{}' asks for qos=2; this backend publishes it at QoS 1 (at-least-once). The std backend honours qos=2 on the same URL.", + route.topic + ); + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: route '{}' asks qos=2; publishing at QoS 1 (at-least-once)", + route.topic.as_str() + ); + } + } +} + +/// 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), Ok(QualityOfService::Qos0))); + assert!(matches!(map_qos(1), Ok(QualityOfService::Qos1))); + assert!(matches!(map_qos(2), Ok(QualityOfService::Qos1))); // Downgrades to QoS 1 + assert!(matches!(map_qos(99), Err(PublishError::UnsupportedQoS))); // Not a QoS level + } +} diff --git a/aimdb-mqtt-connector/src/embedded/packet_reader.rs b/aimdb-mqtt-connector/src/embedded/packet_reader.rs new file mode 100644 index 00000000..ce376f3d --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/packet_reader.rs @@ -0,0 +1,249 @@ +//! Incremental MQTT packet framing: push bytes in, take whole packets out. +//! +//! A partial packet is a non-event: nothing blocks, where `mountain-mqtt`'s own +//! reader waits inside one read for as many bytes as the fixed header promises +//! and so parks the caller on a peer that stalls mid-packet. +//! +//! Framing and parsing are separate because [`parse`](PacketReader::parse) +//! takes `&self`: a parsed packet holds only a shared borrow, so dropping it +//! leaves [`consume`](PacketReader::consume) free to take `&mut self` and +//! compact. No `unsafe`, no self-referential struct, no allocation per packet. + +use mountain_mqtt::codec::mqtt_reader::{MqttBufReader, MqttReader}; +use mountain_mqtt::data::packet_type::PacketType; +use mountain_mqtt::error::PacketReadError; +use mountain_mqtt::packets::packet_generic::PacketGeneric; + +/// Reassembles MQTT packets from arbitrary byte chunks. +/// +/// A packet of up to `N` minus one feed chunk is always received; a longer one +/// is [`PacketReadError::PacketTooLargeForBuffer`] rather than a stall. The +/// chunk of slack is what [`feed`](Self::feed) needs to take a whole read at +/// once — do not reclaim it without changing `feed` to accept partial chunks. +pub(crate) struct PacketReader { + buf: [u8; N], + len: usize, +} + +impl PacketReader { + /// An empty reader. + pub(crate) const fn new() -> Self { + Self { + buf: [0u8; N], + len: 0, + } + } + + /// Append freshly read bytes, all or nothing: the whole chunk has to fit + /// beside what is already buffered. So a packet within a chunk of `N` can + /// still be refused, when the chunk completing it also carries the head of + /// the next one — see the type's stated limit. + pub(crate) fn feed(&mut self, bytes: &[u8]) -> Result<(), PacketReadError> { + if self.len + bytes.len() > N { + return Err(PacketReadError::PacketTooLargeForBuffer); + } + self.buf[self.len..self.len + bytes.len()].copy_from_slice(bytes); + self.len += bytes.len(); + Ok(()) + } + + /// Total length of the complete packet at the head of the buffer, or + /// `Ok(None)` if not enough bytes have landed yet. Borrows nothing and + /// commits to nothing. + pub(crate) fn framed_len(&self) -> Result, PacketReadError> { + if self.len < 1 { + return Ok(None); + } + if !PacketType::is_valid_first_header_byte(self.buf[0]) { + return Err(PacketReadError::InvalidPacketType); + } + + // The remaining-length field: up to 4 bytes, each continuing while its + // top bit is set. + let mut pos = 1usize; + loop { + if pos > 4 { + return Err(PacketReadError::InvalidVariableByteIntegerEncoding); + } + if self.len < pos + 1 { + return Ok(None); // the length itself is still arriving + } + if self.buf[pos] & 128 == 0 { + pos += 1; + break; + } + pos += 1; + } + + let remaining = { + let mut reader = MqttBufReader::new(&self.buf[1..pos]); + reader.get_variable_u32()? as usize + }; + let total = pos + remaining; + if total > N { + return Err(PacketReadError::PacketTooLargeForBuffer); + } + if self.len < total { + return Ok(None); // header complete, body still arriving + } + Ok(Some(total)) + } + + /// Parse the complete packet at the head of the buffer. + /// + /// `total` must come from [`framed_len`](Self::framed_len). Takes `&self`, + /// so the returned packet holds only a shared borrow. + pub(crate) fn parse( + &self, + total: usize, + ) -> Result, PacketReadError> { + let mut reader = MqttBufReader::new(&self.buf[0..total]); + reader.get() + } + + /// Drop a consumed packet from the head, sliding the next one down. Takes + /// `&mut self`, so it can only run once the parsed packet is dropped. + pub(crate) fn consume(&mut self, total: usize) { + self.buf.copy_within(total..self.len, 0); + self.len -= total; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + + /// MQTT 5 CONNACK, as the fake broker in `tests/common` sends it. + const CONNACK: &[u8] = &[0x20, 0x03, 0x00, 0x00, 0x00]; + + /// A QoS-0 MQTT 5 PUBLISH, built the way `tests/common::publish` builds it. + fn publish_bytes(topic: &str, payload: &[u8]) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.push(0x00); // no properties + rest.extend_from_slice(payload); + + let mut packet = vec![0x30]; + let mut n = rest.len(); + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 128; + } + packet.push(byte); + if n == 0 { + break; + } + } + packet.extend_from_slice(&rest); + packet + } + + /// Drain whatever is complete, reporting each packet as a short label. + fn drain(reader: &mut PacketReader) -> Vec<&'static str> { + let mut got = Vec::new(); + while let Some(total) = reader.framed_len().expect("framing must not error") { + { + let packet = reader.parse::<8, 0, 0>(total).expect("parse"); + got.push(match packet { + PacketGeneric::Connack(_) => "connack", + PacketGeneric::Publish(_) => "publish", + _ => "other", + }); + // the packet drops here, releasing the shared borrow... + } + // ...so `consume` can take `&mut self`. + reader.consume(total); + } + got + } + + #[test] + fn one_byte_at_a_time_both_packets_parse() { + let mut wire = Vec::new(); + wire.extend_from_slice(CONNACK); + wire.extend_from_slice(&publish_bytes("sensor/temp", b"21.5")); + + let mut reader = PacketReader::<256>::new(); + let mut got = Vec::new(); + for byte in &wire { + reader.feed(&[*byte]).expect("feed"); + got.extend(drain(&mut reader)); + } + + assert_eq!(got, vec!["connack", "publish"]); + assert_eq!(reader.len, 0, "buffer fully drained"); + } + + #[test] + fn coalesced_chunk_yields_both_packets() { + let mut wire = Vec::new(); + wire.extend_from_slice(CONNACK); + wire.extend_from_slice(&publish_bytes("a/b", b"x")); + + let mut reader = PacketReader::<256>::new(); + reader.feed(&wire).expect("feed"); + + assert_eq!(drain(&mut reader), vec!["connack", "publish"]); + assert_eq!(reader.len, 0); + } + + #[test] + fn every_partial_packet_reads_as_incomplete() { + let bytes = publish_bytes("sensor/temp", b"21.5"); + let mut reader = PacketReader::<256>::new(); + + for cut in 1..bytes.len() { + reader.len = 0; + reader.feed(&bytes[..cut]).expect("feed prefix"); + assert_eq!( + reader.framed_len().expect("a prefix must not error"), + None, + "a strict prefix must read as incomplete, not as a packet" + ); + } + + reader.len = 0; + reader.feed(&bytes).expect("feed whole"); + assert_eq!(reader.framed_len().expect("framing"), Some(bytes.len())); + } + + #[test] + fn two_byte_varint_length_reassembles() { + let bytes = publish_bytes("t", &vec![b'z'; 300]); + assert!(bytes[1] & 128 != 0, "length needs two varint bytes"); + + let mut reader = PacketReader::<512>::new(); + let mut got = Vec::new(); + for byte in &bytes { + reader.feed(&[*byte]).expect("feed"); + got.extend(drain(&mut reader)); + } + + assert_eq!(got, vec!["publish"]); + } + + #[test] + fn a_packet_larger_than_the_buffer_errors_rather_than_stalling() { + let bytes = publish_bytes("t", &vec![b'z'; 300]); + let mut reader = PacketReader::<64>::new(); + + // The header alone is enough to know it will never fit. + reader.feed(&bytes[..4]).expect("feed header"); + assert_eq!( + reader.framed_len(), + Err(PacketReadError::PacketTooLargeForBuffer) + ); + } + + #[test] + fn a_bad_first_header_byte_errors_immediately() { + let mut reader = PacketReader::<64>::new(); + reader.feed(&[0x00]).expect("feed"); + assert_eq!(reader.framed_len(), Err(PacketReadError::InvalidPacketType)); + } +} diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs new file mode 100644 index 00000000..cf4dfeb1 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -0,0 +1,120 @@ +//! The broker session loop for the [`Embedded`](crate::connector::Embedded) +//! backend: dial, run one MQTT session over the stream's two halves, wait, +//! repeat. +//! +//! Built on core's [`ByteStream`](aimdb_core::session::ByteStream) alone: any +//! runtime that can dial a +//! [`StreamDialer`](aimdb_core::session::StreamDialer) can speak MQTT. + +use core::future::Future; + +/// Asserts that a broker session future is `Send`. +/// +/// Everything the session holds is `Send` — [`StreamDialer`] guarantees +/// `Stream: Send`, the channels use `CriticalSectionRawMutex` — but +/// `embedded-io-async` puts no `Send` bound on its futures, and naming that +/// bound through a generic transport needs return-type notation, unstable on +/// the pinned toolchain. +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) + } +} + +/// Dial, run one session, wait, repeat. Never returns. +/// +/// The dialer supplies both the stream and the clock. `topics` is re-subscribed +/// on each connection, so inbound routing survives a reconnect. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_sessions( + dialer: D, + host: alloc::string::String, + port: u16, + topics: alloc::vec::Vec, + connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, + settings: crate::embedded::manager::Settings, + events: alloc::sync::Arc, + actions: alloc::sync::Arc, + runtime: alloc::sync::Arc, +) -> ! +where + D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, +{ + use aimdb_core::session::{ByteStream, Delay}; + use mountain_mqtt::data::quality_of_service::QualityOfService; + use mountain_mqtt::mqtt_manager::ConnectionId; + + use crate::embedded::manager::MqttEvent; + use crate::embedded::session_loop::run_session; + + // 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 connection_index = 0u32; + + loop { + let mut stream = match dialer.connect(&host, port).await { + Ok(stream) => stream, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: connect failed, will retry"); + Delay::sleep(&dialer, settings.reconnection_delay).await; + continue; + } + }; + + let connection_id = ConnectionId::new(connection_index); + connection_index += 1; + + // The halves live exactly as long as the session that reads and writes + // them, which is why borrowed halves are enough. + let (rx, tx) = stream.split(); + let error = run_session( + connection_id, + rx, + tx, + &connection_settings, + &subscribe_topics, + &events, + &actions, + &settings, + &dialer, + runtime.as_ref(), + ) + .await; + + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: session errored: {:?}", error); + events + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + + Delay::sleep(&dialer, settings.reconnection_delay).await; + } +} diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs new file mode 100644 index 00000000..141d548c --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -0,0 +1,643 @@ +//! The event-driven broker session: three futures in one `select3`. +//! +//! The stream is split and the only thing ever cancelled is a channel receive, +//! so the non-cancel-safe `write_all` never sits in a `select` arm and no +//! partially-read packet is ever discarded — which is what lets the TLS path +//! share this loop. +//! +//! [`read_into`] and [`write_out`] know no MQTT; [`client_loop`] owns all +//! client state and wakes only on data, an action or a deadline. Raw chunks +//! cross the inbound channel rather than whole packets, so framing needs no +//! second packet-sized buffer. + +use core::convert::Infallible; +use core::time::Duration; + +use aimdb_core::session::{ByteRead, ByteWrite, Delay}; +use aimdb_core::RuntimeOps; +use alloc::vec::Vec; +use embassy_futures::select::{select3, Either3}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; + +use mountain_mqtt::client::{ClientError, ConnectionSettings}; +use mountain_mqtt::client_state::{ClientState, ClientStateNoQueue, ClientStateReceiveEvent}; +use mountain_mqtt::codec::mqtt_writer::{MqttBufWriter, MqttLenWriter, MqttWriter}; +use mountain_mqtt::codec::write::Write; +use mountain_mqtt::data::property::ConnectProperty; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::error::{PacketReadError, PacketWriteError}; +use mountain_mqtt::mqtt_manager::ConnectionId; +use mountain_mqtt::packets::connect::Connect; +use mountain_mqtt::packets::packet_generic::PacketGeneric; + +use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, MqttEvent, Settings}; +use crate::embedded::packet_reader::PacketReader; +use crate::embedded::{ + ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, +}; + +/// Bytes lifted off the socket at a time, and the size of one `inbound` slot. +const RX_CHUNK: usize = 256; + +/// The largest MQTT packet the session can receive. +/// +/// Reassembly, the inbound slots and the encode buffer all come out of one +/// `BUFFER_SIZE`; outbound packets are encoded to exactly-sized `Vec`s +/// rather than a fixed buffer. +const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; + +/// One chunk of freshly read bytes, in flight from the read half to the loop. +type Chunk = heapless::Vec; + +/// Drive one MQTT session over a split stream until an error ends it. +/// +/// Connects, subscribes `subscribe_topics`, then dispatches actions and +/// forwards events. Returns only on failure — the caller reconnects. +/// +/// **At most once**: an action is taken off `actions` before it is performed, +/// so the one in flight when a session ends is lost. Everything still queued +/// survives. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_session( + connection_id: ConnectionId, + rx: R, + tx: W, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Error +where + R: ByteRead, + W: ByteWrite, + D: Delay, +{ + let inbound: Channel = Channel::new(); + // Four slots of a pointer each: enough that a burst of small packets does + // not park the loop, and cheap because the bytes live on the heap. + let outbound: Channel, 4> = Channel::new(); + + let session = client_loop( + connection_id, + &inbound, + &outbound, + connection_settings, + subscribe_topics, + events, + actions, + settings, + delay, + runtime, + ); + + match select3(read_into(rx, &inbound), write_out(tx, &outbound), session).await { + Either3::First(error) => error, + Either3::Second(error) => error, + Either3::Third(Err(error)) => error, + // `client_loop` only ever returns an error. + Either3::Third(Ok(never)) => match never {}, + } +} + +/// Lift bytes off the socket and hand them to the loop. Never cancelled, so a +/// read is never dropped mid-packet. +async fn read_into( + mut rx: R, + inbound: &Channel, +) -> Error { + let mut scratch = [0u8; RX_CHUNK]; + loop { + let n = match rx.read(&mut scratch).await { + // A closed peer is an ended session, not an error condition to sit in. + Ok(0) | Err(_) => return receive_failed(), + Ok(n) => n, + }; + let mut chunk = Chunk::new(); + // `n <= RX_CHUNK` by construction, so this cannot overflow the chunk. + if chunk.extend_from_slice(&scratch[..n]).is_err() { + return receive_failed(); + } + inbound.send(chunk).await; + } +} + +/// Drain encoded packets to the socket. Never cancelled, which is what keeps +/// the non-cancel-safe `write_all` out of a `select` arm. +async fn write_out( + mut tx: W, + outbound: &Channel, 4>, +) -> Error { + loop { + let packet = outbound.receive().await; + if tx.write_all(&packet).await.is_err() || tx.flush().await.is_err() { + return Error::Client(ClientError::PacketWrite(PacketWriteError::ConnectionSend)); + } + } +} + +fn receive_failed() -> Error { + Error::Client(ClientError::PacketRead(PacketReadError::ConnectionReceive)) +} + +/// Everything the session knows, in one future: state, framing, deadlines. +#[allow(clippy::too_many_arguments)] +async fn client_loop( + connection_id: ConnectionId, + inbound: &Channel, + outbound: &Channel, 4>, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Result { + let ping_interval = settings.ping_interval.as_millis() as u64; + let max_silence = settings.connection_event_max_interval.as_millis() as u64; + let stabilisation = settings.stabilisation_interval.as_millis() as u64; + let response_timeout = settings.response_timeout.as_millis() as u64; + + let mut state = ClientStateNoQueue::new(); + let mut reader = PacketReader::::new(); + + let start = now_ms(runtime); + let mut last_ack_ms = start; + let mut last_ping_ms = start; + // Set while an acknowledgement is outstanding, so a broker that never + // answers a CONNECT, SUBSCRIBE or QoS 1 PUBLISH is caught by + // `response_timeout` rather than only by the liveness window. + let mut waiting_since: Option = Some(start); + let mut stable_at: Option = None; + let mut connected = false; + let mut next_topic = 0usize; + + // CONNECT goes out first; its CONNACK is what flips `connected`. + { + let mut properties = heapless::Vec::new(); + // Topic aliases are declined: honouring them would mean storing the + // server's topic names for the life of the connection. + let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); + let connect: Connect<'_, 1, 0> = Connect::new( + connection_settings.keep_alive(), + *connection_settings.username(), + *connection_settings.password(), + connection_settings.client_id(), + true, + None, + properties, + ); + state.connect(&connect).map_err(client_error)?; + queue(outbound, encode(&connect)?).await; + } + + loop { + let now = now_ms(runtime); + + // --- deadlines, checked before anything parks ---------------------- + + if now.saturating_sub(last_ack_ms) > max_silence { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: broker unresponsive"); + return Err(Error::MqttServerUnresponsive); + } + + if let Some(since) = waiting_since { + if now.saturating_sub(since) > response_timeout { + return Err(Error::Client(ClientError::TimeoutOnResponsePacket)); + } + } + + if let Some(at) = stable_at { + if now >= at { + stable_at = None; + events + .send(MqttEvent::ConnectionStable { connection_id }) + .await; + } + } + + if connected && now.saturating_sub(last_ping_ms) >= ping_interval { + last_ping_ms = now; + let ping = state.send_ping().map_err(client_error)?; + // The one packet worth dropping rather than waiting for — see + // `queue_lossy`. + queue_lossy(outbound, encode(&ping)?); + } + + // Subscriptions go out one at a time: `ClientStateNoQueue` tracks a + // single outstanding request, so the next one waits for this SUBACK. + if connected && !state.waiting_for_responses() && next_topic < subscribe_topics.len() { + let (topic, qos) = subscribe_topics[next_topic]; + let packet = state.subscribe_packet(topic, qos).map_err(client_error)?; + queue(outbound, encode(&packet)?).await; + state.subscribe_update(&packet).map_err(client_error)?; + next_topic += 1; + // `continue` skips the bottom-of-loop bookkeeping, so arm the + // response deadline here: a broker that never SUBACKs should be + // caught by `response_timeout`, not only by the liveness window. + waiting_since = Some(now); + continue; + } + + // --- park until something happens ---------------------------------- + + // The action arm is armed only when a publish can actually be sent: + // connected, nothing awaiting acknowledgement (the client state holds + // one in-flight slot), every subscription placed, and room to queue the + // bytes. This + // is what replaces the old inline wait for a PUBACK — the ping and + // liveness deadlines keep running while it is parked. + let action_ready = connected + && !state.waiting_for_responses() + && next_topic >= subscribe_topics.len() + && !outbound.is_full(); + let action_arm = async { + if !action_ready { + core::future::pending::<()>().await; + } + actions.receive().await + }; + + let sleep_for = Duration::from_millis(next_deadline( + now, + connected, + last_ping_ms + ping_interval, + last_ack_ms + max_silence, + stable_at, + waiting_since.map(|since| since + response_timeout), + )); + + match select3(inbound.receive(), action_arm, delay.sleep(sleep_for)).await { + Either3::First(chunk) => { + reader.feed(&chunk).map_err(client_error)?; + drain_packets( + &mut reader, + &mut state, + connection_id, + outbound, + events, + runtime, + &mut last_ack_ms, + &mut connected, + &mut stable_at, + stabilisation, + ) + .await?; + } + Either3::Second(action) => { + perform(action, &mut state, outbound).await?; + } + // The timer fired: the top of the loop re-evaluates every deadline. + Either3::Third(()) => {} + } + + waiting_since = match (state.waiting_for_responses(), waiting_since) { + (true, Some(since)) => Some(since), + (true, None) => Some(now_ms(runtime)), + (false, _) => None, + }; + } +} + +/// Parse and dispatch every whole packet the reader now holds. +#[allow(clippy::too_many_arguments)] +async fn drain_packets( + reader: &mut PacketReader, + state: &mut ClientStateNoQueue, + connection_id: ConnectionId, + outbound: &Channel, 4>, + events: &EventChannel, + runtime: &dyn RuntimeOps, + last_ack_ms: &mut u64, + connected: &mut bool, + stable_at: &mut Option, + stabilisation: u64, +) -> Result<(), Error> { + while let Some(total) = reader.framed_len().map_err(client_error)? { + // The packet borrows the reader's buffer, so everything that outlives + // it — the response bytes, the application event — is made owned inside + // this scope. `consume` can then take `&mut`. + let (response, received) = { + let packet: PacketGeneric<'_, MAX_PROPERTIES, 0, 0> = + reader.parse(total).map_err(client_error)?; + + // Produce the PUBACK before the state update, as upstream does, so + // the two cannot disagree about what was acknowledged. + let response = match state + .receive_produce_response(&packet) + .map_err(client_error)? + { + Some(puback) => Some(encode(&puback)?), + None => None, + }; + + let event = state.receive(packet).map_err(client_error)?; + (response, Received::of(event, connection_id)?) + }; + reader.consume(total); + + if let Some(bytes) = response { + queue(outbound, bytes).await; + } + + // Every packet the state accepted proves the broker is alive. + *last_ack_ms = now_ms(runtime); + + // The CONNACK is whatever moved the state to `Connected`; nothing else + // does, so there is no need to inspect packet types for it. + if !*connected && matches!(state, ClientStateNoQueue::Connected(_)) { + *connected = true; + *stable_at = Some(now_ms(runtime) + stabilisation); + events.send(MqttEvent::Connected { connection_id }).await; + } + + if let Received::Event(event) = received { + events.send(event).await; + } + } + Ok(()) +} + +/// What a received packet leaves for the loop to do, owned so the reader's +/// buffer can be compacted first. +enum Received { + /// An acknowledgement: liveness only, nothing to forward. + Ack, + /// Something the application asked to hear about. + Event(MqttEvent), +} + +impl Received { + fn of( + event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>, + connection_id: ConnectionId, + ) -> Result { + Ok(match event { + ClientStateReceiveEvent::Ack => Self::Ack, + + ClientStateReceiveEvent::Publish { publish } + | ClientStateReceiveEvent::PublishAndPuback { publish, .. } => { + if publish.topic_name().is_empty() { + return Err(Error::Client( + ClientError::EmptyTopicNameWithAliasesDisabled, + )); + } + let message = publish.into(); + let event = AimdbMqttEvent::from_application_message(&message) + .map_err(|e| Error::Client(ClientError::EventHandler(e)))?; + Self::Event(MqttEvent::ApplicationEvent { + connection_id, + event, + }) + } + + ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { + granted_qos, + maximum_qos, + } => Self::Event(MqttEvent::SubscriptionGrantedBelowMaximumQos { + connection_id, + granted_qos, + maximum_qos, + }), + + ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers => { + Self::Event(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) + } + + ClientStateReceiveEvent::NoSubscriptionExisted => { + Self::Event(MqttEvent::NoSubscriptionExisted { connection_id }) + } + + ClientStateReceiveEvent::Disconnect { disconnect } => { + return Err(Error::Client(ClientError::Disconnected( + *disconnect.reason_code(), + ))) + } + }) + } +} + +/// Turn one queued action into a packet on the wire. +/// +/// Sent before the state update, as upstream does: a state that believes a +/// publish is in flight when it is not parks the action arm forever. +async fn perform( + action: AimdbMqttAction, + state: &mut ClientStateNoQueue, + outbound: &Channel, 4>, +) -> Result<(), Error> { + match action { + AimdbMqttAction::Publish { + topic, + payload, + qos, + retain, + } => { + #[cfg(feature = "defmt")] + defmt::debug!( + "Publishing {} bytes to {} (QoS={:?})", + payload.len(), + topic.as_str(), + qos + ); + let packet = state + .publish_packet(&topic, &payload, qos, retain) + .inspect_err(|_e| { + // The action is already off the channel, so a failure here + // loses this message and ends the session — say which. + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: dropping publish of {} bytes to {}: {}", + payload.len(), + topic.as_str(), + _e + ); + }) + .map_err(client_error)?; + queue(outbound, encode(&packet)?).await; + state.publish_update(&packet).map_err(client_error)?; + } + AimdbMqttAction::Subscribe { topic, qos } => { + #[cfg(feature = "defmt")] + defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); + let packet = state + .subscribe_packet(&topic, qos) + .inspect_err(|_e| { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); + }) + .map_err(client_error)?; + queue(outbound, encode(&packet)?).await; + state.subscribe_update(&packet).map_err(client_error)?; + } + } + Ok(()) +} + +/// Encode a packet to exactly its own length: a counting pass, then a real +/// one, so no fixed buffer is sized for the largest packet anyone might send. +fn encode(packet: &P) -> Result, Error> { + let mut len_writer = MqttLenWriter::new(); + len_writer.put(packet).map_err(write_error)?; + + let mut bytes = alloc::vec![0u8; len_writer.position()]; + let mut writer = MqttBufWriter::new(&mut bytes); + writer.put(packet).map_err(write_error)?; + Ok(bytes) +} + +/// Queue encoded bytes for the write half, waiting for a slot. +/// +/// Everything the protocol obliges us to send goes through here: CONNECT, +/// SUBSCRIBE, PUBLISH and the PUBACKs answering QoS 1 delivery. None of those +/// can be dropped — the state machine has already committed to them, so a +/// discarded packet leaves our state and the wire disagreeing, with nothing to +/// resync on. +/// +/// Waiting cannot deadlock: [`write_out`] is this channel's only consumer and +/// is a sibling arm of the same `select`, so parking here is what lets it run. +/// It is also the backpressure — a peer that stops reading stops us encoding. +async fn queue(outbound: &Channel, 4>, bytes: Vec) { + outbound.send(bytes).await; +} + +/// Queue encoded bytes only if the write half is keeping up, dropping them if +/// it is not. +/// +/// For pings alone. A ping carries no state — `send_ping` bumps a counter but +/// arms no response deadline — so a dropped one costs nothing and the next +/// ping deadline tries again; if the link really is gone, the liveness window +/// closes the session. Parking on a ping would be worse than skipping it: the +/// loop that has to notice the link is gone would be the thing stuck. +fn queue_lossy(outbound: &Channel, 4>, bytes: Vec) { + if outbound.try_send(bytes).is_err() { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: write queue full, ping dropped"); + } +} + +/// Milliseconds to sleep before the earliest armed deadline. +fn next_deadline( + now: u64, + connected: bool, + ping_at: u64, + liveness_at: u64, + stable_at: Option, + response_at: Option, +) -> u64 { + let mut earliest = liveness_at; + if connected { + earliest = earliest.min(ping_at); + } + if let Some(at) = stable_at { + earliest = earliest.min(at); + } + if let Some(at) = response_at { + earliest = earliest.min(at); + } + earliest.saturating_sub(now).max(1) +} + +fn client_error(error: impl Into) -> Error { + Error::Client(error.into()) +} + +fn write_error(error: PacketWriteError) -> Error { + Error::Client(ClientError::PacketWrite(error)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Halves that never do anything: enough to build the session future and + /// measure it without polling it. + struct NullRead; + struct NullWrite; + + impl ByteRead for NullRead { + async fn read(&mut self, _buf: &mut [u8]) -> aimdb_core::session::TransportResult { + core::future::pending().await + } + } + + impl ByteWrite for NullWrite { + async fn write_all(&mut self, _buf: &[u8]) -> aimdb_core::session::TransportResult<()> { + Ok(()) + } + + async fn flush(&mut self) -> aimdb_core::session::TransportResult<()> { + Ok(()) + } + } + + struct NullDelay; + + impl Delay for NullDelay { + fn sleep(&self, _d: Duration) -> impl core::future::Future + Send { + core::future::pending() + } + } + + #[test] + fn the_buffer_budget_is_what_the_old_loop_cost() { + // The reassembly buffer plus the read scratch plus one + // inbound slot come out of `BUFFER_SIZE`, not in addition to it. + assert_eq!(PACKET_BUFFER_SIZE + 2 * RX_CHUNK, BUFFER_SIZE); + } + + #[test] + fn the_earliest_armed_deadline_wins() { + // Liveness only, before the connection is up. + assert_eq!(next_deadline(0, false, 100, 500, None, None), 500); + // Once connected the ping is usually nearest. + assert_eq!(next_deadline(0, true, 100, 500, None, None), 100); + // Stabilisation and the response timeout arm independently. + assert_eq!(next_deadline(0, true, 100, 500, Some(50), None), 50); + assert_eq!(next_deadline(0, true, 100, 500, None, Some(20)), 20); + } + + /// A ceiling on the session task's footprint. The bound is loose enough to + /// absorb codegen drift, but not loose enough to fit another buffer. + #[test] + fn the_session_future_has_not_outgrown_the_loop_it_replaced() { + let events = EventChannel::new(); + let actions = ActionChannel::new(); + let settings = Settings::default(); + let connection_settings = ConnectionSettings::unauthenticated("size-probe"); + let runtime = aimdb_core::executor::test_support::NoopRuntimeOps; + + // Built, never polled: `size_of_val` on the future is the whole point. + let session = run_session( + ConnectionId::new(0), + NullRead, + NullWrite, + &connection_settings, + &[], + &events, + &actions, + &settings, + &NullDelay, + &runtime, + ); + + let size = core::mem::size_of_val(&session); + assert!( + size <= BUFFER_SIZE * 2, + "the session future is {size} bytes, over the {} allowed — has a \ + buffer been added rather than carved out of BUFFER_SIZE?", + BUFFER_SIZE * 2 + ); + } + + #[test] + fn a_deadline_in_the_past_still_sleeps_a_tick() { + // Never zero: a zero-length sleep would spin the loop. + assert_eq!(next_deadline(1_000, true, 100, 500, None, None), 1); + } +} diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs similarity index 77% rename from aimdb-mqtt-connector/src/sntp.rs rename to aimdb-mqtt-connector/src/embedded/sntp.rs index cdd28bf7..650337a1 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -1,11 +1,8 @@ -//! 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. +//! Certificate validity 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`. use core::sync::atomic::{AtomicU32, Ordering}; @@ -16,16 +13,14 @@ use embassy_time::{with_timeout, Duration, Instant, Timer}; use crate::sntp_codec; -/// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is -/// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit -/// atomics there). +/// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` stays a +/// single atomic on Cortex-M, which has no 64-bit atomics. static BOOT_UNIX_SECS: AtomicU32 = AtomicU32::new(0); /// NTP server port. const SNTP_PORT: u16 = 123; -/// Local ephemeral-port range for the client socket. smoltcp cannot bind -/// port 0, so "random source port" is randomized here per attempt — a reply -/// must land on the right port *and* echo the request nonce to be accepted. +/// Local ephemeral-port range for the client socket, randomized per attempt +/// because smoltcp cannot bind port 0. const LOCAL_PORT_BASE: u16 = 49152; const LOCAL_PORT_SPAN: u16 = 16384; /// How long to wait for a server reply before treating the sync as failed. @@ -46,17 +41,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 +53,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; @@ -108,11 +95,9 @@ pub(crate) enum SntpError { InvalidReply, } -/// Best-effort request nonce: the hardware TRNG belongs to the TLS session -/// (injected via `TlsOptions`), so unpredictability comes from the tick -/// counter through a splitmix64 finalizer. Enough to defeat *blind* reply -/// spoofing — an off-path attacker cannot observe when the request fired — -/// while an on-path attacker defeats unauthenticated NTP regardless. +/// Best-effort request nonce from the tick counter — the hardware TRNG belongs +/// to the TLS session. Enough to defeat blind reply spoofing; an on-path +/// attacker defeats unauthenticated NTP regardless. fn request_nonce() -> u64 { let mut z = Instant::now() .as_ticks() diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs new file mode 100644 index 00000000..86ea9c25 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -0,0 +1,514 @@ +//! The TLS transport for the embedded backend. +//! +//! An `embedded-tls` 1.3 session over the caller's transport, split into halves +//! the session loop reads and writes exactly as it does a plaintext socket. +//! Certificate verification is `rustpki` against the application-embedded root +//! CA, dated by the runtime's wall clock; entropy comes from the injected TRNG +//! ([`TlsOptions::new`]). The dialer resolves the host, so there is no network +//! stack here. + +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::future::Future; +use core::net::IpAddr; + +use aimdb_core::session::{ByteRead, ByteStream, ByteWrite, TransportError, TransportResult}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; +use embedded_tls::pki::CertVerifier; +use embedded_tls::{ + Aes128GcmSha256, Certificate, CryptoProvider, CryptoRngCore, TlsConfig, TlsConnection, + TlsContext, TlsError, TlsReader, TlsVerifier, TlsWriter, +}; + +use crate::embedded::manager::{MqttEvent, Settings}; +use crate::embedded::session_loop::run_session; +use mountain_mqtt::client::ConnectionSettings; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::mqtt_manager::ConnectionId; + +/// Room for the server's leaf certificate (DER) inside the verifier — 4 KB +/// covers RSA-4096 leaves with headroom. +const CERT_BUFFER_SIZE: usize = 4096; + +/// Minimum TLS record read buffer. A peer may send full-size records (2^14 + +/// overhead) whatever `max_fragment_length` we offer, and `embedded-tls` fails +/// any record larger than the buffer, so `build()` rejects a smaller one. +pub(crate) const READ_BUF_MIN: usize = 16_640; + +/// TLS materials for a `mqtts://` broker connection. +/// +/// All references are `'static`: the session outlives `build()`, so the buffers +/// and the RNG are owned by the application, which knows the memory budget. +pub struct TlsOptions { + pub(crate) rng: &'static mut (dyn CryptoRngCore + Send), + pub(crate) ca_der: &'static [u8], + pub(crate) read_buf: &'static mut [u8], + pub(crate) write_buf: &'static mut [u8], + /// 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 { + /// TLS with certificate verification against `ca_der` (the root CA, DER). + /// + /// * `rng` — CSPRNG for the handshake; on STM32 the hardware TRNG + /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). Must be + /// `Send`, which every concrete CSPRNG satisfies. + /// * `read_buf` — TLS record read buffer. At least 16 640 bytes (a + /// TLS 1.3 peer may send full-size records regardless of our + /// `max_fragment_length` offer); `build()` rejects smaller buffers. + /// * `write_buf` — TLS record write buffer; 4 096 bytes is plenty for + /// MQTT-sized writes. + pub fn new( + rng: &'static mut (dyn CryptoRngCore + Send), + ca_der: &'static [u8], + read_buf: &'static mut [u8], + write_buf: &'static mut [u8], + ) -> Self { + Self { + rng, + ca_der, + read_buf, + write_buf, + #[cfg(feature = "embassy-tls")] + sntp: None, + } + } + + /// 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 socket's two halves behind one handle, so `embedded-tls` can clone a +/// "socket" into its reader and its writer. +/// +/// One clone is read while another is written, so the directions take +/// **separate async locks** — `TlsReader` only ever touches `rx`, `TlsWriter` +/// only `tx`, and a guard may be held across the inner `.await`. That holds +/// only while `embedded-tls` never writes from its reader. +/// +/// Two tests cover that, and only one of them can see the bet go bad: +/// +/// * `a_parked_reader_does_not_hold_up_the_writer` below proves the locks +/// really are separate — but over mock halves, with no `embedded-tls` in the +/// picture, so it would keep passing if a version bump started writing from +/// the read path. +/// * `tls_session::a_slow_puback_over_tls_does_not_block_the_ping` is the one +/// that would catch it: a real `TlsConnection`, split, writing a ping while +/// the reader is parked on a PUBACK the broker is withholding. Check that +/// test still passes after bumping `embedded-tls`. +struct DuplexHandle<'a, Rx, Tx> { + rx: &'a Mutex, + tx: &'a Mutex, +} + +impl Clone for DuplexHandle<'_, Rx, Tx> { + fn clone(&self) -> Self { + Self { + rx: self.rx, + tx: self.tx, + } + } +} + +impl embedded_io_async::ErrorType for DuplexHandle<'_, Rx, Tx> { + type Error = embedded_io_async::ErrorKind; +} + +impl embedded_io_async::Read for DuplexHandle<'_, Rx, Tx> +where + Rx: ByteRead, +{ + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.rx + .lock() + .await + .read(buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +impl embedded_io_async::Write for DuplexHandle<'_, Rx, Tx> +where + Tx: ByteWrite, +{ + async fn write(&mut self, buf: &[u8]) -> Result { + self.tx + .lock() + .await + .write_all(buf) + .await + .map(|()| buf.len()) + .map_err(|_| embedded_io_async::ErrorKind::Other) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + self.tx + .lock() + .await + .flush() + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +/// Asserts that a TLS half's I/O future is `Send`. +/// +/// `embedded-tls` holds a `Range<*const u8>` into the record buffer its own +/// future owns exclusively, which makes that future `!Send` by type. The +/// session's three futures are polled as one task, so nothing here is ever +/// touched from two threads at once. +struct AssertSend(F); + +// SAFETY: upheld by the single-task argument above. +unsafe impl Send for AssertSend {} + +impl Future for AssertSend { + type Output = F::Output; + + fn poll( + self: core::pin::Pin<&mut Self>, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + // SAFETY: a transparent projection; `AssertSend` is never moved out of. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } +} + +/// The TLS session's read half, as the session loop's [`ByteRead`]. Below this +/// pair the session cannot tell a plaintext socket from a record stream. +struct TlsRead<'a, 'b, Rx, Tx>(TlsReader<'a, DuplexHandle<'b, Rx, Tx>, Aes128GcmSha256>); + +/// The TLS session's write half, as the session loop's [`ByteWrite`]. +struct TlsWrite<'a, 'b, Rx, Tx>(TlsWriter<'a, DuplexHandle<'b, Rx, Tx>, Aes128GcmSha256>); + +impl<'a, 'b, Rx, Tx> ByteRead for TlsRead<'a, 'b, Rx, Tx> +where + // The socket handle outlives the TLS session borrowed from it. + 'b: 'a, + Rx: ByteRead + Send + 'b, + Tx: ByteWrite + Send + 'b, +{ + fn read<'r>( + &'r mut self, + buf: &'r mut [u8], + ) -> impl Future> + Send + 'r { + AssertSend(async move { + use embedded_io_async::Read as _; + self.0.read(buf).await.map_err(|_| TransportError::Io) + }) + } +} + +impl<'a, 'b, Rx, Tx> ByteWrite for TlsWrite<'a, 'b, Rx, Tx> +where + 'b: 'a, + Rx: ByteRead + Send + 'b, + Tx: ByteWrite + Send + 'b, +{ + fn write_all<'w>( + &'w mut self, + buf: &'w [u8], + ) -> impl Future> + Send + 'w { + AssertSend(async move { + use embedded_io_async::Write as _; + self.0 + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + AssertSend(async move { + use embedded_io_async::Write as _; + self.0.flush().await.map_err(|_| TransportError::Closed) + }) + } +} + +/// Unix seconds for certificate validity, refreshed before each handshake. +/// +/// A global because `embedded_tls::TlsClock::now` is a static method. Fed by +/// the runtime's wall clock, or by the SNTP task on an MCU with no RTC — and +/// process-wide, so two TLS connectors share one reading rather than keeping a +/// clock each. Harmless while they agree on what time it is, which any two +/// sources of wall-clock time had better. +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 [`WallClock`]). No client-certificate signing: the +/// mesh authenticates with MQTT credentials. +struct TrngProvider<'a> { + rng: &'a mut (dyn CryptoRngCore + Send), + verifier: CertVerifier<'static, Aes128GcmSha256, WallClock, CERT_BUFFER_SIZE>, +} + +impl CryptoProvider for TrngProvider<'_> { + type CipherSuite = Aes128GcmSha256; + // Unused (no client certificates); any `AsRef<[u8]>` satisfies the bound. + type Signature = &'static [u8]; + + fn rng(&mut self) -> impl CryptoRngCore { + &mut *self.rng + } + + fn verifier(&mut self) -> Result<&mut impl TlsVerifier, TlsError> { + Ok(&mut self.verifier) + } +} + +/// The TLS broker manager: dial → TLS handshake → MQTT session, reconnecting +/// forever with the same [`Settings`] cadence as the plain path. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_tls( + dialer: D, + options: TlsOptions, + host: String, + port: u16, + topics: Vec, + connection_settings: ConnectionSettings<'static>, + settings: Settings, + events: Arc, + actions: Arc, + delay: D, + runtime: Arc, +) -> ! +where + D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, +{ + let TlsOptions { + rng, + ca_der, + read_buf, + write_buf, + .. + } = options; + + // Re-subscribed by the session on every (re)connection, so inbound + // routing survives reconnects. Built once — borrows `topics` for the loop. + let subscribe_topics: Vec<(&str, QualityOfService)> = topics + .iter() + .map(|topic| (topic.as_str(), QualityOfService::Qos1)) + .collect(); + + let mut connection_index = 0u32; + + loop { + // 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 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 mut stream = match dialer.connect(&host, port).await { + Ok(stream) => stream, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: connect failed, will retry"); + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; + continue; + } + }; + + // One lock per direction, so the TLS reader and writer never contend. + let (rx, tx) = stream.split(); + let rx = Mutex::new(rx); + let tx = Mutex::new(tx); + let handle = DuplexHandle { rx: &rx, tx: &tx }; + + let tls_config = TlsConfig::new().with_server_name(&host); + let mut tls = TlsConnection::new(handle.clone(), &mut *read_buf, &mut *write_buf); + let provider = TrngProvider { + rng: &mut *rng, + verifier: CertVerifier::new(Certificate::X509(ca_der)), + }; + // The handshake reads and writes sequentially through one connection, + // so it needs no split and takes neither lock twice. + if let Err(e) = tls.open(TlsContext::new(&tls_config, provider)).await { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT-TLS: handshake failed, will retry: {:?}", + defmt::Debug2Format(&e) + ); + #[cfg(not(feature = "defmt"))] + let _ = e; + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; + continue; + } + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS: session established"); + + let connection_id = ConnectionId::new(connection_index); + connection_index += 1; + + // From here the session is the plain path's, byte for byte: the record + // layer is just another pair of halves. + let (tls_rx, tls_tx) = tls.split(); + let error = run_session( + connection_id, + TlsRead(tls_rx), + TlsWrite(tls_tx), + &connection_settings, + &subscribe_topics, + &events, + &actions, + &settings, + &delay, + runtime.as_ref(), + ) + .await; + + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: session errored: {:?}", error); + events + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; + } +} + +/// Parse the broker host as an IP literal, brackets optional (`[::1]`). +/// +/// `build()` vets `mqtts://` hosts with this: an IPv4 literal can still match +/// through `rustpki`'s CN fallback, so it is allowed with a warning, while an +/// IPv6 literal never can (the hostname charset has no `:`) and is rejected. +pub(crate) fn host_ip_literal(host: &str) -> Option { + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + host.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use core::pin::pin; + use core::task::{Context, Poll}; + + /// A read half that never completes, so the reader's lock stays held. + struct PendingRead; + + impl ByteRead for PendingRead { + async fn read(&mut self, _buf: &mut [u8]) -> TransportResult { + core::future::pending().await + } + } + + /// A write half that completes immediately, recording what it was given. + struct RecordingWrite(Vec); + + impl ByteWrite for RecordingWrite { + async fn write_all(&mut self, buf: &[u8]) -> TransportResult<()> { + self.0.extend_from_slice(buf); + Ok(()) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + } + + /// A read parked inside one clone of the handle must not hold up a write + /// through another. + /// + /// Mock halves, so this is about [`DuplexHandle`]'s own locking and nothing + /// else: it cannot tell you whether `embedded-tls` still reads and writes + /// from the halves it was given. `tls_session::a_slow_puback_over_tls_does_not_block_the_ping` + /// is the test that does. + #[test] + fn a_parked_reader_does_not_hold_up_the_writer() { + let rx = Mutex::new(PendingRead); + let tx = Mutex::new(RecordingWrite(Vec::new())); + let handle = DuplexHandle { rx: &rx, tx: &tx }; + + // What `TlsConnection::split` does: a clone apiece. + let mut reader = handle.clone(); + let mut writer = handle; + + let mut cx = Context::from_waker(core::task::Waker::noop()); + + let mut buf = [0u8; 4]; + let mut read = pin!(embedded_io_async::Read::read(&mut reader, &mut buf)); + assert!( + matches!(read.as_mut().poll(&mut cx), Poll::Pending), + "the read must park — otherwise this test proves nothing" + ); + + let mut write = pin!(embedded_io_async::Write::write(&mut writer, b"ping")); + assert!( + matches!(write.as_mut().poll(&mut cx), Poll::Ready(Ok(4))), + "the write must complete while the read is parked" + ); + + assert!( + matches!(read.as_mut().poll(&mut cx), Poll::Pending), + "and the reader must be undisturbed by it" + ); + } +} diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 6e8c064c..c0984ad9 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -6,14 +6,22 @@ //! //! ## 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 +//! - `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 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; @@ -59,22 +67,28 @@ //! # } //! ``` //! -//! ## Embassy Usage (Embedded) +//! ## Embedded Usage //! //! Illustrative (not compiled: requires the `embassy-runtime` feature and a -//! device network stack): +//! device network stack). The transport is what selects the backend — the same +//! call on any other adapter's dialer gets the same connector. //! //! ```rust,ignore //! use aimdb_core::AimDbBuilder; +//! use aimdb_embassy_adapter::net::EmbassyNet; //! use aimdb_embassy_adapter::EmbassyAdapter; -//! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +//! use aimdb_mqtt_connector::MqttConnector; //! use alloc::sync::Arc; //! //! let runtime = Arc::new(EmbassyAdapter::new()); //! //! let db = 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, tx)) +//! .with_client_id("my-unique-device-id"), +//! ) //! .configure::(|reg| { //! reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) //! .source(sensor_producer) @@ -94,43 +108,30 @@ 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; - -#[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 64% rename from aimdb-mqtt-connector/src/tokio_client.rs rename to aimdb-mqtt-connector/src/native.rs index 51a076c0..f47f7d8f 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -1,142 +1,85 @@ -//! 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. +/// The broker-connection setup invoked from `build`; the data-plane loops +/// themselves are core's `pump_sink` / `pump_source`. pub struct MqttConnectorImpl; impl MqttConnectorImpl { - /// Connect to the broker and subscribe to all configured topics (internal). - /// - /// Creates the MQTT client, sizes the send-channel from the route count, and - /// subscribes to every topic in `router`. Returns the shared client (for the - /// outbound `pump_sink`) plus the raw event loop (handed to a - /// [`MqttEventLoopSource`] for the inbound `pump_source`). + /// Connect to the broker and subscribe to every topic in `router`, sizing + /// the send channel from the route count. /// - /// # Arguments - /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) - /// * `client_id` - Optional client ID (if None, generates UUID-based ID) - /// * `router` - Routes used only for the subscription list + capacity sizing + /// Returns the shared client (for the outbound `pump_sink`) plus the raw + /// event loop (for [`MqttEventLoopSource`] and the inbound `pump_source`). + /// A `None` `client_id` generates a UUID-based one. 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 +105,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 @@ -237,10 +191,8 @@ impl MqttConnectorImpl { /// Pure outbound publish adapter driven by `pump_sink`. /// -/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's protocol -/// options (threaded through by `pump_sink` via [`ConnectorConfig::from_query`]), -/// interpreted with MQTT's legacy defaults — **QoS 1 (`AtLeastOnce`)** when -/// unspecified, no retain — so the wire stays byte-identical to the old loop. +/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's +/// protocol options, defaulting to **QoS 1 (`AtLeastOnce`)** and no retain. struct MqttSink { client: Arc, } @@ -302,16 +254,13 @@ impl Connector for MqttSink { /// Inbound frame source driven by `pump_source`. /// -/// Yields `(topic, payload)` for each incoming MQTT publish. The inner poll loop -/// discards non-publish packets — keeping QoS handshakes and keepalive flowing — -/// and backs off 5s on a connection error before retrying, reproducing the old -/// hand-rolled event-loop future exactly. It never yields `None`: the reader runs -/// for the lifetime of the connector. +/// Yields `(topic, payload)` for each incoming MQTT publish, discarding other +/// packets and backing off 5s on a connection error. Never yields `None`: the +/// reader runs for the lifetime of the connector. struct MqttEventLoopSource { event_loop: EventLoop, - /// Only ever used to name the broker in an error line. One `String` per - /// connection, held for its lifetime — no longer feature-gated, because the - /// facade decides its own gating and a `#[cfg]` here could not follow it. + /// Only ever used to name the broker in an error line. Ungated, because the + /// logging facade decides its own gating. broker_key: String, } @@ -353,9 +302,9 @@ fn tls_configuration() -> Result { Ok(rumqttc::TlsConfiguration::Native) } -/// Built by hand rather than via `TlsConfiguration::default()`, which does the -/// same work and then `expect`s on failure. A panic on the connect path is -/// undefined behaviour across an FFI boundary; a returned error is a status. +/// Built by hand rather than via `TlsConfiguration::default()`, which `expect`s +/// on failure: a panic on the connect path is undefined behaviour across an FFI +/// boundary. #[cfg(all(feature = "tokio-rustls", not(feature = "tokio-native-tls")))] fn tls_configuration() -> Result { use rumqttc::tokio_rustls::rustls::{ClientConfig, RootCertStore}; @@ -396,7 +345,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 +353,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 +373,7 @@ mod tests { let connector = MqttConnectorImpl::build_internal( "mqtts://hub-sub:secret@broker.example.com:8883", None, + None, router, ) .await; @@ -451,7 +402,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..602f46e4 --- /dev/null +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -0,0 +1,385 @@ +//! 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 — 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. +/// +/// A setter that was accepted and then dropped would look exactly like success, +/// so the assertion is on the CONNECT packet the broker saw. +#[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] + ); + } +} + +/// Credentials in the broker URL's authority reach the wire on both backends. +/// +/// The sibling above covers the explicit setter. This one covers +/// `mqtt://user:pass@host`, which the embedded backend used to parse for its +/// host and port and then drop — producing an unauthenticated CONNECT, a +/// rejecting broker, and a silent reconnect loop. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn url_credentials_reach_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://hub:s3cret@127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()).with_client_id("url-creds-native"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("url-creds-embedded"); + + 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 URL's credentials", + seen.client_ids[n] + ); + } +} + +/// The explicit setter overrides what the URL carries, on both backends. +/// +/// `with_credentials` is the only way to name a password that is not URL-safe, +/// so it has to win rather than merely fill a gap. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_setter_overrides_url_credentials_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://urluser:urlpass@127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()) + .with_client_id("override-native") + .with_credentials("hub", "s3cret"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("override-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} ({}) let the URL win over the setter", + seen.client_ids[n] + ); + } +} + +/// A **hostname** is a broker address on both backends. +/// +/// Resolving `host` is the dialer's job on every adapter, so `.transport(..)` +/// makes no difference to which broker URLs are accepted. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_hostname_is_a_broker_address_on_both_backends() { + // Bound by name, so the address the broker listens on is whichever one + // `localhost` resolves to first here — the same one the dialers get. + let listener = TcpListener::bind("localhost:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://localhost:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()).with_client_id("host-native"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("host-embedded"); + + 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().connects < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: only {} of 2 backends connected by name ({:?})", + seen.connects, seen.client_ids + ); + } + } + + let seen = seen.lock().unwrap(); + let mut ids = seen.client_ids.clone(); + ids.sort(); + assert_eq!( + ids, + vec![String::from("host-embedded"), String::from("host-native")], + "both backends must reach the broker through a hostname" + ); +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs new file mode 100644 index 00000000..13d419f9 --- /dev/null +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -0,0 +1,669 @@ +//! 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 listener serves both. +//! +//! Compiled into each test binary, so not every item is used by all of them. +#![allow(dead_code)] + +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aimdb_core::session::{Delay, StreamDialer, TransportResult}; + +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; + } + } +} + +/// Decode an MQTT variable-byte integer at `i`, stepping past it. +fn take_varint(body: &[u8], i: &mut usize) -> Option { + let mut value = 0usize; + let mut shift = 0; + loop { + let byte = *body.get(*i)?; + *i += 1; + value |= ((byte & 0x7f) as usize) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + // MQTT caps a variable-byte integer at four bytes. + if shift > 21 { + return None; + } + } +} + +/// Step `i` past an MQTT 5 property block — its length varint, then the +/// properties themselves. +fn skip_properties(body: &[u8], i: &mut usize) -> Option<()> { + let len = take_varint(body, i)?; + *i += len; + Some(()) +} + +/// 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. Nothing here sets a will, so the payload 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 { + skip_properties(body, &mut i)?; + } + + 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 { + // Best-effort: this returns a SUBACK either way, and a malformed + // property block shows up as an unparsable topic below. + let _ = skip_properties(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 +} + +/// An MQTT 5 QoS 1 PUBLISH, which obliges the receiver to answer with a PUBACK +/// carrying `packet_id`. [`publish`] builds the QoS 0 form, which obliges +/// nothing. +fn publish_qos1(topic: &str, payload: &[u8], packet_id: u16) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.extend_from_slice(&packet_id.to_be_bytes()); + rest.push(0x00); // no properties + rest.extend_from_slice(payload); + + let mut packet = vec![0x32]; // PUBLISH, QoS 1 + 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_properties(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, as a parity test needs. +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; + }); + } +} + +// =========================================================================== +// The scripted broker: the same wire format as above, but the test decides +// when each answer goes out. +// =========================================================================== + +/// The topic the scripted broker pushes on. +pub const SCRIPT_TOPIC: &str = "sensors/temperature"; + +/// What the scripted broker saw, and when. +#[derive(Default)] +pub struct Log { + pub pings: usize, + /// Client publishes, as (topic, payload). + pub publishes: Vec<(String, Vec)>, + /// Pings that arrived while the broker was deliberately stalling. + pub pings_during_stall: usize, + /// Client publishes that arrived while the broker was stalling. + pub publishes_during_stall: usize, + /// QoS 1 PUBLISHes the broker pushed, by packet id. + pub pushed_qos1: Vec, + /// PUBACKs the client sent back, by packet id. Short of `pushed_qos1` + /// means the client received a message and never acknowledged it. + pub pubacks: Vec, +} + +/// Read one packet: header byte, varint remaining length, body. +async fn read_one(socket: &mut S) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + let mut body = vec![0u8; remaining]; + socket.read_exact(&mut body).await.ok()?; + Some((first, body)) +} + +/// How the scripted broker should misbehave after it has SUBACKed. +#[derive(Clone, Copy)] +pub enum Script { + /// Answer nothing but pings: an idle, healthy session. + Idle, + /// Push a PUBLISH split in two with `gap` between the halves. + SplitPublish { gap: Duration }, + /// Hold every PUBACK back by `delay`. + SlowPuback { delay: Duration }, + /// Push inbound PUBLISHes as fast as they will go, for `duration`. + Flood { duration: Duration }, + /// Push `count` QoS 1 PUBLISHes in a single write, so they reach the client + /// coalesced and it has to answer every one with a PUBACK. + FloodQos1 { count: u16 }, +} + +/// The broker's write side. +/// +/// While `hold` is `Some`, a packet is half-written and nothing else may go on +/// the wire: injecting a PUBACK between the halves of a PUBLISH would corrupt +/// the framing rather than test it. Held bytes go out behind the packet's tail. +struct Wire { + writer: tokio::io::WriteHalf, + hold: Option>, +} + +type Writer = Arc>>; + +async fn send(writer: &Writer, bytes: &[u8]) -> bool { + let mut wire = writer.lock().await; + match wire.hold.as_mut() { + Some(held) => { + held.extend_from_slice(bytes); + true + } + None => wire.writer.write_all(bytes).await.is_ok(), + } +} + +/// Serve one already-accepted connection, following `script`. +/// +/// Generic over the stream, so the same script runs over plain TCP and TLS. +/// +/// Every scripted delay runs in its own task, so the broker **never stops +/// reading** — without which a ping arriving mid-stall would be counted after +/// the stall rather than during it. +pub async fn scripted_broker(stream: S, log: Arc>, script: Script) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let (mut reader, writer) = tokio::io::split(stream); + let writer: Writer = Arc::new(tokio::sync::Mutex::new(Wire { writer, hold: None })); + + // Open while the broker is deliberately withholding something. + let stalling = Arc::new(AtomicUsize::new(0)); + + loop { + let Some((first, body)) = read_one(&mut reader).await else { + return; + }; + let in_stall = stalling.load(Ordering::Relaxed) == 1; + + match first >> 4 { + // CONNECT -> CONNACK + 1 => { + if !send(&writer, &[0x20, 0x03, 0x00, 0x00, 0x00]).await { + return; + } + } + // SUBSCRIBE -> SUBACK, then run the script. + 8 => { + let packet_id = [body[0], body[1]]; + if !send( + &writer, + &[0x90, 0x04, packet_id[0], packet_id[1], 0x00, 0x01], + ) + .await + { + return; + } + + match script { + Script::Idle | Script::SlowPuback { .. } => {} + Script::SplitPublish { gap } => { + // Half a packet, a long silence, then the rest. The + // polled loop's `receive_if_ready` commits to reading + // the whole packet and parks here. + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + let packet = publish(SCRIPT_TOPIC, b"21", true); + let cut = packet.len() / 2; + { + let mut wire = writer.lock().await; + if wire.writer.write_all(&packet[..cut]).await.is_err() { + return; + } + // Nothing else may reach the wire until the + // tail does. + wire.hold = Some(Vec::new()); + } + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(gap).await; + stalling.store(0, Ordering::Relaxed); + + let mut wire = writer.lock().await; + let held = wire.hold.take().unwrap_or_default(); + if wire.writer.write_all(&packet[cut..]).await.is_err() { + return; + } + let _ = wire.writer.write_all(&held).await; + }); + } + Script::Flood { duration } => { + let writer = writer.clone(); + tokio::spawn(async move { + let deadline = tokio::time::Instant::now() + duration; + let packet = publish(SCRIPT_TOPIC, b"7", true); + while tokio::time::Instant::now() < deadline { + // The lock is taken and released per packet, so + // PUBACKs and PINGRESPs interleave with the + // flood rather than queueing behind all of it. + if !send(&writer, &packet).await { + return; + } + tokio::task::yield_now().await; + } + }); + } + Script::FloodQos1 { count } => { + let writer = writer.clone(); + let log = log.clone(); + tokio::spawn(async move { + // One write, so the burst reaches the client as + // few large reads rather than one packet per read + // — which is the case the outbox has to survive. + let mut burst = Vec::new(); + for id in 1..=count { + burst.extend_from_slice(&publish_qos1(SCRIPT_TOPIC, b"7", id)); + log.lock().unwrap().pushed_qos1.push(id); + } + send(&writer, &burst).await; + }); + } + } + } + // PUBLISH from the client. + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body, true) else { + return; + }; + { + let mut log = log.lock().unwrap(); + log.publishes.push((topic, payload)); + if in_stall { + log.publishes_during_stall += 1; + } + } + if let Some(id) = packet_id { + match script { + // Acknowledge late, in its own task, with the stall + // window open: a ping arriving meanwhile is the + // assertion, and the read loop has to stay live to see + // it. + Script::SlowPuback { delay } => { + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(delay).await; + stalling.store(0, Ordering::Relaxed); + send(&writer, &[0x40, 0x02, id[0], id[1]]).await; + }); + } + _ => { + if !send(&writer, &[0x40, 0x02, id[0], id[1]]).await { + return; + } + } + } + } + } + // PUBACK from the client, answering a QoS 1 push. + 4 => { + if let (Some(hi), Some(lo)) = (body.first(), body.get(1)) { + log.lock() + .unwrap() + .pubacks + .push(u16::from_be_bytes([*hi, *lo])); + } + } + // PINGREQ -> PINGRESP + 12 => { + { + let mut log = log.lock().unwrap(); + log.pings += 1; + if in_stall { + log.pings_during_stall += 1; + } + } + if !send(&writer, &[0xD0, 0x00]).await { + return; + } + } + 14 => return, + _ => {} + } + } +} + +// =========================================================================== +// A dialer that counts what the session sleeps on. +// =========================================================================== + +/// `TokioNet::tcp()` with a tally of every `Delay::sleep` the connector asks +/// for — the connector takes its clock from the dialer, so this is where the +/// wake cadence is observable. +#[derive(Clone)] +pub struct CountingDialer { + inner: aimdb_tokio_adapter::net::TokioTcpDialer, + sleeps: Arc, +} + +impl CountingDialer { + pub fn new() -> Self { + Self { + inner: aimdb_tokio_adapter::net::TokioNet::tcp(), + sleeps: Arc::new(AtomicUsize::new(0)), + } + } + + /// The running tally, shared with the dialer the connector holds. + pub fn sleeps(&self) -> Arc { + self.sleeps.clone() + } +} + +impl StreamDialer for CountingDialer { + type Stream = ::Stream; + + fn connect<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future> + Send + 'a { + self.inner.connect(host, port) + } +} + +impl Delay for CountingDialer { + fn sleep(&self, d: Duration) -> impl Future + Send { + self.sleeps.fetch_add(1, Ordering::Relaxed); + Delay::sleep(&self.inner, d) + } +} diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs new file mode 100644 index 00000000..0540dcc6 --- /dev/null +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -0,0 +1,354 @@ +//! Host smoke for the Embassy broker session loop (`_test-embassy-broker`). +//! +//! Two `embassy-net` stacks wired by an in-memory driver-channel crossover, +//! against a fake broker speaking 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: `embassy-net` links `embassy-time`, whose +// `defmt-timestamp-uptime` defines `_defmt_timestamp`, so a second definition +// is a duplicate-symbol link error. `tokio_broker` enables the same feature +// but links no `embassy-time`, so it defines its own. + +/// 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 connection, answer CONNECT and SUBSCRIBE, then push a PUBLISH, +/// recording what it saw so the test asserts on the wire. +#[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. +// --------------------------------------------------------------------------- + +/// A broker session completes over the injected transport, with the inbound +/// topics **subscribed on the wire** — losing that kills inbound routing +/// silently. +#[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/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs new file mode 100644 index 00000000..1ed7b4c0 --- /dev/null +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -0,0 +1,432 @@ +//! Liveness under a stalled peer, pings that keep flowing while a QoS 1 publish +//! is outstanding, and an idle session that wakes at the ping cadence. +//! +//! The broker is scripted rather than `common::fake_broker`, because each test +//! controls *when* it answers — mid-packet, late, or not at all. +#![cfg(feature = "_test-tokio-broker")] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; + +mod common; +use common::{scripted_broker, CountingDialer, Log, Script, SCRIPT_TOPIC}; + +// 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); + +/// Real wall-clock time for `embassy-time`, which the test's dependency graph +/// links even though the session loop itself runs on core's `Delay`. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +// --------------------------------------------------------------------------- +// A scripted broker: the same wire format as `common`, but the test decides +// when each answer goes out. +// --------------------------------------------------------------------------- + +/// Accept one plain-TCP connection and serve it under `script`. +async fn serve_one(listener: TcpListener, log: Arc>, script: Script) { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + scripted_broker(socket, log, script).await; +} + +// --------------------------------------------------------------------------- +// The database under test. +// --------------------------------------------------------------------------- + +/// Build an AimDb with one inbound record, and optionally an outbound record +/// that publishes every `publish_every`. +async fn build_db( + port: u16, + dialer: CountingDialer, + publish: Option<(Duration, u8)>, +) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt}; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(dialer) + .with_client_id("session-loop"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(&format!("mqtt://{SCRIPT_TOPIC}")) + .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(); + }); + + if let Some((every, qos)) = publish { + builder.configure::("uptime", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + let mut n = 0u64; + loop { + producer.produce(n); + n += 1; + tokio::time::sleep(every).await; + } + }) + .link_to("mqtt://sensors/uptime") + .with_qos(qos) + .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) + .finish(); + }); + } + + builder.build().await.expect("build db") +} + +// --------------------------------------------------------------------------- +// An idle session wakes at the ping cadence, not at 100 Hz. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_idle_session_wakes_at_the_ping_cadence() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + let dialer = CountingDialer::new(); + let sleeps = dialer.sleeps(); + let (_db, runner) = build_db(port, dialer, None).await; + + // Long enough to span several of the old loop's 10 ms polls, and to cover + // the 2 s ping cadence at least once. + const WINDOW: Duration = Duration::from_secs(3); + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::Idle) => panic!("the broker returned"), + _ = tokio::time::sleep(WINDOW) => {} + } + + let woke = sleeps.load(Ordering::Relaxed); + let pings = log.lock().unwrap().pings; + + assert!(pings >= 1, "the session must still ping; saw {pings}"); + // The old loop slept `poll_interval` (10 ms) every turn: ~300 wakes in this + // window, plus a 1 kHz burst per acknowledgement. The new one arms a timer + // per deadline — ping, liveness, stabilisation — so a generous ceiling is + // still two orders of magnitude below the poll. + assert!( + woke < 30, + "an idle session woke {woke} times in {WINDOW:?}; the polled loop it \ + replaces would have woken ~{}", + WINDOW.as_millis() / 10 + ); +} + +// --------------------------------------------------------------------------- +// A partial packet stops neither pings nor publishes. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_partial_packet_stops_neither_pings_nor_publishes() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Longer than the 2 s ping interval, so a ping falls due while the packet + // is half-delivered — the case the polled loop wedges on. + const GAP: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 0))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::SplitPublish { gap: GAP }) => { + panic!("the broker returned") + } + received = async { inbound.recv().await.expect("inbound record") } => received, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings ({} mid-stall), {} publishes ({} mid-stall)", + log.pings, log.pings_during_stall, log.publishes.len(), log.publishes_during_stall + ); + } + }; + + let log = log.lock().unwrap(); + assert!( + log.pings_during_stall >= 1, + "a ping must go out while a packet is half-delivered; saw {} of {} total", + log.pings_during_stall, + log.pings + ); + assert!( + log.publishes_during_stall >= 1, + "publishes must keep flowing while a packet is half-delivered; saw {} of {} total", + log.publishes_during_stall, + log.publishes.len() + ); + assert_eq!( + received, 21, + "the packet must still be delivered once its tail arrives" + ); +} + +// --------------------------------------------------------------------------- +// A QoS 1 publish survives a slow broker without blocking pings. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_puback_does_not_block_the_ping() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Again longer than the ping interval: the old loop waited for this PUBACK + // inline, at 1 kHz, with the ping behind it. + const ACK_DELAY: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (_db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 1))).await; + + let until_ping_during_stall = async { + loop { + if log.lock().unwrap().pings_during_stall >= 1 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + panic!("the broker returned") + } + _ = until_ping_during_stall => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let log = log.lock().unwrap(); + panic!("watchdog: {} pings, {} publishes", log.pings, log.publishes.len()); + } + } + + let log = log.lock().unwrap(); + assert!( + !log.publishes.is_empty(), + "the publish under acknowledgement must have reached the broker" + ); + assert!( + log.pings_during_stall >= 1, + "the ping must go out while a QoS 1 publish waits for its PUBACK" + ); +} + +// --------------------------------------------------------------------------- +// Neither direction starves the other. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn outbound_keeps_moving_under_an_inbound_flood() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + const FLOOD: Duration = Duration::from_secs(2); + + let dialer = CountingDialer::new(); + // Produce far faster than the flood's own cadence, so the outbound path is + // saturated too and the two are genuinely competing. + let (db, runner) = build_db(port, dialer, Some((Duration::from_millis(2), 1))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let delivered = Arc::new(AtomicUsize::new(0)); + let counting = { + let delivered = delivered.clone(); + async move { + while inbound.recv().await.is_ok() { + delivered.fetch_add(1, Ordering::Relaxed); + } + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::Flood { duration: FLOOD }) => { + panic!("the broker returned") + } + _ = counting => panic!("the inbound record closed"), + _ = tokio::time::sleep(FLOOD + Duration::from_secs(1)) => {} + } + + let log = log.lock().unwrap(); + assert!( + log.publishes.len() >= 50, + "outbound starved under an inbound flood: only {} publishes got through", + log.publishes.len() + ); + assert!( + delivered.load(Ordering::Relaxed) >= 10, + "inbound starved: only {} messages were delivered", + delivered.load(Ordering::Relaxed) + ); +} + +// --------------------------------------------------------------------------- +// Every QoS 1 message the broker pushes must be acknowledged. +// --------------------------------------------------------------------------- + +/// A burst of QoS 1 pushes arriving coalesced must be PUBACKed in full. +/// +/// The session encodes one PUBACK per message into `outbound`, a 4-slot channel +/// drained by the write half — and the write half only runs when the session +/// loop parks. `drain_packets` does not park on its own while the event channel +/// has room, so a burst spanning more packets than the outbox holds is exactly +/// the case where responses have nowhere to go. Queueing them with `.await` is +/// what makes the loop park there, letting the writer drain. +/// +/// This matters because a lost PUBACK is unrecoverable: by the time it would be +/// dropped the client state has already retired the message, so nothing +/// retries, and the session is clean-start so no reconnect replays it. The +/// broker would hold each one against its in-flight window while the connection +/// still looked healthy — pings keep the liveness watchdog satisfied — so the +/// node would go deaf on inbound with no error and no reconnect. +/// +/// Failed at 19 of 40 while every packet went out through `try_send`-and-forget. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn every_qos1_push_is_acknowledged() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Enough to span several 256-byte reads: ~9 of these packets fit per read, + // and the outbox holds 4. + const PUSHED: u16 = 40; + + let dialer = CountingDialer::new(); + let (db, runner) = build_db(port, dialer, None).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let delivered = Arc::new(AtomicUsize::new(0)); + let counting = { + let delivered = delivered.clone(); + async move { + while inbound.recv().await.is_ok() { + delivered.fetch_add(1, Ordering::Relaxed); + } + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::FloodQos1 { count: PUSHED }) => { + panic!("the broker returned") + } + _ = counting => panic!("the inbound record closed"), + // Long enough that anything merely slow has finished. + _ = tokio::time::sleep(Duration::from_secs(3)) => {} + } + + let log = log.lock().unwrap(); + assert_eq!( + log.pushed_qos1.len(), + PUSHED as usize, + "the broker must have pushed the whole burst" + ); + assert_eq!( + log.pubacks.len(), + log.pushed_qos1.len(), + "the client acknowledged {} of {} QoS 1 messages — {} went unacknowledged, \ + and the broker holds each against its in-flight window for the life of \ + the connection (delivered to the app: {})", + log.pubacks.len(), + log.pushed_qos1.len(), + log.pushed_qos1.len() - log.pubacks.len(), + delivered.load(Ordering::Relaxed), + ); +} + +// --------------------------------------------------------------------------- +// The build-time QoS warning can actually see what it warns about. +// --------------------------------------------------------------------------- + +/// `warn_unsupported_qos` scans `collect_outbound_routes("mqtt")` for a `qos` +/// entry in each route's query config. That scan is the part that can silently +/// find nothing — a scheme filter that does not match, or a config key that +/// never lands — leaving a warning that compiles and never fires. This asserts +/// the shape it depends on, mirroring the private function exactly. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_qos2_route_is_visible_to_the_build_time_scan() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let dialer = CountingDialer::new(); + let (db, _runner) = build_db(port, dialer, Some((Duration::from_secs(60), 2))).await; + + let routes = db.collect_outbound_routes("mqtt"); + assert!( + !routes.is_empty(), + "the mqtt scheme must match, or the scan sees no routes at all" + ); + + let flagged: Vec<(&str, &str)> = routes + .iter() + .filter_map(|route| { + route + .config + .iter() + .find(|(k, _)| k == "qos") + .map(|(_, v)| (route.topic.as_str(), v.as_str())) + }) + .collect(); + + assert_eq!( + flagged, + vec![("sensors/uptime", "2")], + "the scan must see the route's topic and its qos option; got {flagged:?}" + ); +} diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs new file mode 100644 index 00000000..b3437e55 --- /dev/null +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -0,0 +1,172 @@ +//! `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 same `embedded-tls` session an MCU runs, over `TokioNet::tcp()`, clocked +//! by the runtime's wall clock with no SNTP task. +#![cfg(feature = "_test-tls-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rand::SeedableRng as _; +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, which `rustpki` matches only through +/// the narrower CN fallback. +const BROKER_HOST: &str = "localhost"; + +/// A self-signed certificate for `localhost`, as (server chain, key, root CA in +/// DER) — the same bytes on both sides, which is what "pinned" 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, verified against +/// the pinned root and clocked by `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() + ); +} diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs new file mode 100644 index 00000000..78c3d872 --- /dev/null +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -0,0 +1,319 @@ +//! `tests/session_loop.rs`'s promises, re-driven over a real TLS 1.3 session +//! against a pinned self-signed root. +//! +//! Also where `DuplexHandle`'s disjointness is exercised for real: while the +//! read half is parked inside `TlsReader`, the write half has to push pings +//! through `TlsWriter`. An `embedded-tls` whose reader took the write lock would +//! hang these tests rather than fail quietly in the field. +#![cfg(feature = "_test-tls-broker")] + +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rand::SeedableRng as _; +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::{scripted_broker, CountingDialer, Log, Script, SCRIPT_TOPIC}; + +// 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); + +/// Real wall-clock time for `embassy-time`, which this binary links. +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 name the certificate is issued for, and the name the client verifies. +const BROKER_HOST: &str = "localhost"; + +/// A self-signed certificate for `localhost`, as (server chain, key, root CA). +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"); + let ca: &'static [u8] = Box::leak(der.clone().into_boxed_slice()); + (CertificateDer::from(der), key, ca) +} + +/// Accept one TLS connection and run the scripted broker over it. +async fn serve_one_tls( + listener: TcpListener, + acceptor: TlsAcceptor, + log: Arc>, + script: Script, +) { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let Ok(stream) = acceptor.accept(socket).await else { + return; + }; + scripted_broker(stream, log, script).await; +} + +/// Everything a `mqtts://` test needs: a listener, its acceptor, and the +/// connector's TLS materials. +fn tls_setup() -> ( + TcpListener, + TlsAcceptor, + aimdb_mqtt_connector::TlsOptions, + u16, +) { + 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 = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + listener.set_nonblocking(true).expect("nonblocking"); + let port = listener.local_addr().unwrap().port(); + let listener = TcpListener::from_std(listener).expect("adopt listener"); + + // 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()); + + ( + listener, + acceptor, + aimdb_mqtt_connector::TlsOptions::new(rng, ca_der, read_buf, write_buf), + port, + ) +} + +/// An AimDb whose MQTT connector speaks `mqtts://` through `dialer`, with one +/// inbound record and optionally an outbound one publishing every `every` at +/// `qos`. +async fn build_tls_db( + port: u16, + dialer: CountingDialer, + options: aimdb_mqtt_connector::TlsOptions, + publish: Option<(Duration, u8)>, +) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt}; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) + .tls(dialer, options) + .with_client_id("tls-session"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(&format!("mqtt://{SCRIPT_TOPIC}")) + .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(); + }); + + if let Some((every, qos)) = publish { + builder.configure::("uptime", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + let mut n = 0u64; + loop { + producer.produce(n); + n += 1; + tokio::time::sleep(every).await; + } + }) + .link_to("mqtt://sensors/uptime") + .with_qos(qos) + .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) + .finish(); + }); + } + + builder.build().await.expect("build db") +} + +// --------------------------------------------------------------------------- +// An idle TLS session wakes at the ping cadence. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_idle_tls_session_wakes_at_the_ping_cadence() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + let dialer = CountingDialer::new(); + let sleeps = dialer.sleeps(); + let (_db, runner) = build_tls_db(port, dialer, options, None).await; + + const WINDOW: Duration = Duration::from_secs(3); + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::Idle) => { + panic!("the broker returned") + } + _ = tokio::time::sleep(WINDOW) => {} + } + + let woke = sleeps.load(Ordering::Relaxed); + let pings = log.lock().unwrap().pings; + + assert!(pings >= 1, "the TLS session must still ping; saw {pings}"); + assert!( + woke < 30, + "an idle TLS session woke {woke} times in {WINDOW:?}; the polled loop \ + it replaces would have woken ~{}", + WINDOW.as_millis() / 10 + ); +} + +// --------------------------------------------------------------------------- +// A partial packet over TLS stops neither pings nor publishes. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_partial_packet_over_tls_stops_neither_pings_nor_publishes() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + // Longer than the 2 s ping interval, so a ping falls due while the MQTT + // packet is half-delivered — here, half of it inside a complete TLS record + // and the rest in a later one. + const GAP: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (db, runner) = + build_tls_db(port, dialer, options, Some((Duration::from_millis(100), 0))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::SplitPublish { gap: GAP }) => { + panic!("the broker returned") + } + received = async { inbound.recv().await.expect("inbound record") } => received, + _ = tokio::time::sleep(Duration::from_secs(60)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings ({} mid-stall), {} publishes ({} mid-stall)", + log.pings, log.pings_during_stall, log.publishes.len(), log.publishes_during_stall + ); + } + }; + + let log = log.lock().unwrap(); + assert!( + log.pings_during_stall >= 1, + "a ping must go out over TLS while a packet is half-delivered; saw {} of {}", + log.pings_during_stall, + log.pings + ); + assert!( + log.publishes_during_stall >= 1, + "publishes must keep flowing over TLS meanwhile; saw {} of {}", + log.publishes_during_stall, + log.publishes.len() + ); + assert_eq!( + received, 21, + "the packet must still be delivered once its tail arrives" + ); +} + +// --------------------------------------------------------------------------- +// A slow PUBACK over TLS does not block the ping — a concurrent read and write. +// --------------------------------------------------------------------------- + +/// A QoS 1 publish waiting on a slow broker must not stop the ping — which over +/// TLS means `TlsWriter` taking the write lock while `TlsReader` is parked. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_puback_over_tls_does_not_block_the_ping() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + const ACK_DELAY: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (_db, runner) = + build_tls_db(port, dialer, options, Some((Duration::from_millis(100), 1))).await; + + let until_ping_during_stall = async { + loop { + if log.lock().unwrap().pings_during_stall >= 1 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + panic!("the broker returned") + } + _ = until_ping_during_stall => {} + _ = tokio::time::sleep(Duration::from_secs(60)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings, {} publishes — a concurrent TLS read and \ + write did not complete", + log.pings, + log.publishes.len() + ); + } + } + + let log = log.lock().unwrap(); + assert!( + !log.publishes.is_empty(), + "the publish under acknowledgement must have reached the broker" + ); + assert!( + log.pings_during_stall >= 1, + "the ping must go out over TLS while a QoS 1 publish waits for its PUBACK" + ); +} diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs new file mode 100644 index 00000000..0a452a7d --- /dev/null +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -0,0 +1,279 @@ +//! Host smoke for the embedded MQTT backend over `TokioNet::tcp()` +//! (`_test-tokio-broker`). +//! +//! The same loop as the Embassy smoke, over a real TCP socket with no network +//! stack to stand up, plus 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") +} +// This binary must define `_defmt_timestamp` itself. `embassy-time` would — +// `defmt-timestamp-uptime` is enabled here, as it is for `embassy_broker` — +// but nothing in this test references `embassy-time`, so its object never +// reaches the link and the symbol would be undefined. `embassy_broker` pulls +// it in through `embassy-net` and therefore must *not* define one. +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 only inbound routing stops. +#[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. +#[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: nothing about a +/// connector's client id is process-global, so the second does not connect +/// under the first's. +#[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 c5c3aa55..839d8b25 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -25,6 +25,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ByteStream::split` for `TokioByteStream`.** Through `tokio::io::split`, + which costs a lock: the halves share the stream behind a mutex taken inside + each `poll`. It is never held across an await, so it cannot deadlock, but it + is a serialisation point the native `TcpStream::split` does not have. The + native one is unreachable here — the type is generic over `S`, so an impl + specialised to `TcpStream` would overlap the blanket one. +- **`Delay` for `TokioTcpDialer`.** The dialer supplies the session clock, + which — together with the `Clone` it already derived — 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 265e0703..db833ba5 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -10,8 +10,8 @@ use std::net::{IpAddr, SocketAddr}; use aimdb_core::session::{ - ByteStream, Datagram, DatagramBinder, Delay, PeerInfo, StreamDialer, StreamListener, - TransportError, TransportResult, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, Delay, PeerInfo, StreamDialer, + StreamListener, TransportError, TransportResult, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream, UdpSocket}; @@ -79,6 +79,48 @@ where async fn flush(&mut self) -> TransportResult<()> { self.0.flush().await.map_err(|_| TransportError::Closed) } + + /// Borrow the stream as halves through `tokio::io::split`. + /// + /// This costs a lock taken inside each `poll` — never held across an await, + /// so it cannot deadlock, but a serialisation point the native + /// `TcpStream::split` does not have. That one is unreachable here: this type + /// is generic over `S`, so a `TcpStream`-specialised impl would overlap it. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + let (rx, tx) = tokio::io::split(&mut self.0); + (TokioReadHalf(rx), TokioWriteHalf(tx)) + } +} + +/// The read half of a split [`TokioByteStream`]. +struct TokioReadHalf(tokio::io::ReadHalf); + +/// The write half of a split [`TokioByteStream`]. +struct TokioWriteHalf(tokio::io::WriteHalf); + +impl ByteRead for TokioReadHalf +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + async fn read(&mut self, buf: &mut [u8]) -> TransportResult { + self.0.read(buf).await.map_err(|_| TransportError::Io) + } +} + +impl ByteWrite for TokioWriteHalf +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + async fn write_all(&mut self, buf: &[u8]) -> TransportResult<()> { + self.0 + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + } + + async fn flush(&mut self) -> TransportResult<()> { + self.0.flush().await.map_err(|_| TransportError::Closed) + } } /// Dials TCP connections. @@ -96,6 +138,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); @@ -119,6 +169,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. // =========================================================================== @@ -335,6 +440,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/docs/design/052-runtime-neutral-connectors.md b/docs/design/052-runtime-neutral-connectors.md index d8db4cef..f8297986 100644 --- a/docs/design/052-runtime-neutral-connectors.md +++ b/docs/design/052-runtime-neutral-connectors.md @@ -588,8 +588,9 @@ use aimdb_serial_connector::SerialServer; // NEW: the adapter owns the socket. The TCP buffers mountain-mqtt used to // allocate internally are now yours, in statics, like the TCP connector -// already does. `EmbassyNet` also resolves hostnames when embassy-net's -// `dns` feature is on. +// already does. `EmbassyNet::tcp` resolves hostnames through `stack`, as +// `TokioNet::tcp()` does through the OS — so give the stack a DNS server and +// one more `StackResources` slot for the resolver socket. static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); let mqtt_net = EmbassyNet::tcp(stack, MQTT_RX.init([0; 4096]), MQTT_TX.init([0; 4096])); diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index cbddd5be..7fd463cc 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -217,7 +217,7 @@ async fn main(spawner: Spawner) { let config = embassy_net::Config::dhcpv4(Default::default()); - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); let (stack_obj, runner) = diff --git a/examples/embassy-mqtt-connector-demo/README.md b/examples/embassy-mqtt-connector-demo/README.md index 40f95000..e60e96cc 100644 --- a/examples/embassy-mqtt-connector-demo/README.md +++ b/examples/embassy-mqtt-connector-demo/README.md @@ -11,40 +11,43 @@ The `aimdb-mqtt-connector` library with Embassy support is fully implemented and ## What's Implemented -The core Embassy MQTT client (`aimdb-mqtt-connector::embassy_client`) provides: +The connector (`aimdb-mqtt-connector`, feature `embassy-runtime`) provides: -- ✅ Async MQTT publishing with mountain-mqtt-embassy +- ✅ Async MQTT publishing with mountain-mqtt - ✅ Channel-based architecture for background task communication - ✅ Automatic reconnection handling -- ✅ QoS 0/1/2 support +- ✅ QoS 0 and 1 (a `qos=2` route publishes at QoS 1 and is warned about at + startup — only the `std`/rumqttc backend implements exactly-once) - ✅ `no_std` compatible (works in embedded environments) ## API Usage Pattern +The connector is registered on the builder and the runner drives it; there is +no pool to hold and no task to spawn by hand. Records publish and subscribe +through their links. + ```rust -use aimdb_mqtt_connector::embassy_client::MqttClientPool; -use embassy_net::Stack; - -// Create MQTT client (requires initialized network stack) -let mqtt_result = MqttClientPool::create( - network_stack, // embassy_net::Stack - "192.168.1.100", // Broker IP - 1883, // Broker port - "my-client-id", // Client ID -).await?; - -// Spawn background task (runs forever, maintains connection) -spawner.spawn(async move { - mqtt_result.task.run().await -}).unwrap(); - -// Use the pool to publish messages -mqtt_result.pool.publish_async( - "sensors/temperature", // Topic - b"{\"value\":23.5}", // Payload - 1, // QoS (0, 1, or 2) - false // Retain flag -).await?; +use aimdb_embassy_adapter::net::EmbassyNet; +use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt, MqttOutboundLinkExt}; + +// The adapter owns the socket; the connector takes a transport from it. +let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector( + MqttConnector::new("mqtt://192.168.1.100:1883") + .transport(EmbassyNet::tcp(*stack, rx_buf, tx_buf)) + .with_client_id("my-client-id"), + ); + +builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(sensor_producer) + .link_to("mqtt://sensors/temperature") // outbound + .with_qos(1) + .with_retain(false) + .with_serializer(|_ctx, v: &Temperature| Ok(v.to_bytes())) + .finish(); +}); ``` ## Hardware Requirements (for full example) @@ -86,16 +89,16 @@ DNS, optional MQTT username/password, and an automatic SNTP time sync that gates the first handshake (certificate validity needs real time — the board has no RTC battery). -1. In `src/main.rs`, set `MQTT_BROKER_HOST` and, if the broker requires it, - `MQTT_CREDENTIALS`. Prefer a DNS name: an IPv4 literal verifies only when - the certificate pins that IP in its CN (the repo's `dev/mosquitto` bench - CA does; public CAs won't issue such certs). IPv6 literals are rejected - at build. -2. Drop the broker's root CA next to `Cargo.toml`, DER-encoded — for the - `dev/mosquitto` bench broker: +1. Mint the bench CA and start the broker. The script writes `ca.der` into + this directory and prints the constants to copy: ```bash - openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der + cd ../../dev/mosquitto && ./gen-certs.sh && docker compose up -d ``` +2. In `src/main.rs`, set `MQTT_BROKER_HOST`, `MQTT_USERNAME` and + `MQTT_PASSWORD` to what the script printed. The host must match the string + the script was given: it is what the certificate is verified against, and + `embedded-tls` reads only `DNS:` SANs, which is why the script puts even an + IPv4 literal in as one. IPv6 literals are rejected at build. 3. Build (and flash) from this directory, so its `.cargo/config.toml` selects the thumbv8m target and probe-rs runner: ```bash @@ -113,7 +116,7 @@ You can test the MQTT connector implementation using the Tokio runtime version: ```bash # In aimdb-mqtt-connector directory -cargo test --features tokio-runtime +cargo test --features std # Check Embassy features compile cargo check --features embassy-runtime @@ -160,7 +163,7 @@ aimdb-mqtt-connector = { path = "../../aimdb-mqtt-connector", features = ["embas ## Resources -- [MQTT Client Implementation](../../aimdb-mqtt-connector/src/embassy_client.rs) +- [MQTT Client Implementation](../../aimdb-mqtt-connector/src/embedded/mod.rs) - [Embassy Documentation](https://embassy.dev/) - [mountain-mqtt](https://github.com/mountainlizard/mountain-mqtt) - [AimDB Core Documentation](../../README.md) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 463feb6b..1e856663 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -28,45 +28,40 @@ //! //! ## Running //! -//! 1. Start an MQTT broker on your network: +//! 1. Start the bench broker on a machine the board can reach over the LAN. +//! It enforces authentication on both listeners, so a CONNECT that lost its +//! credentials is refused rather than quietly accepted: //! ```bash -//! docker run -d -p 1883:1883 eclipse-mosquitto:2 mosquitto -c /mosquitto-no-auth.conf +//! cd ../../dev/mosquitto && ./gen-certs.sh && docker compose up -d //! ``` //! -//! 2. Subscribe to sensor data: -//! ```bash -//! mosquitto_sub -h -t 'sensors/#' -v -//! ``` +//! 2. Put the address and credentials it prints into the constants below. //! -//! 3. Send commands to device: +//! 3. Build and flash from this directory — its `.cargo/config.toml` selects +//! the thumbv8m target and the probe-rs runner: //! ```bash -//! mosquitto_pub -h -t 'commands/temp/indoor' -m '{"action":"read","sensor_id":"indoor-001"}' +//! cargo run --release //! ``` //! -//! 4. Update MQTT_BROKER_IP constant below to match your broker -//! -//! 5. Flash to target: +//! 4. Watch the traffic, and send the board a command: //! ```bash -//! cargo run --example embassy-mqtt-connector-demo --features embassy-runtime,tracing +//! mosquitto_sub -h -p 1883 -u aimdb -P aimdb-bench -t 'sensors/#' -v +//! mosquitto_pub -h -p 1883 -u aimdb -P aimdb-bench \ +//! -t commands/temp/indoor -m '{"action":"read","sensor_id":"indoor-001"}' //! ``` //! //! ## TLS (`mqtts://`) //! -//! Build with `--features tls` to connect to a TLS broker instead: the URL -//! becomes `mqtts://` (hostname, resolved via DNS), the CONNECT authenticates -//! with `MQTT_CREDENTIALS`, and certificate time comes from SNTP -//! automatically. Before building: +//! `--features tls` switches the same demo to port 8883. The dialer resolves +//! the host, `embedded-tls` verifies the broker against the CA compiled in at +//! `ca.der`, and — this board having no RTC — certificate validity is dated by +//! the connector's own SNTP task, so the first handshake waits for a time sync. +//! +//! `gen-certs.sh` writes `ca.der` into this directory. `MQTT_BROKER_HOST` must +//! then be the same string the script was given: it is what the certificate is +//! verified against, and `embedded-tls` reads only `DNS:` SANs (an `IP:` SAN is +//! skipped), which is why the script puts even an IPv4 literal in as one. //! -//! 1. Set `MQTT_BROKER_HOST` (prefer a DNS name; an IPv4 literal needs the -//! certificate to pin that IP in its CN — the `dev/mosquitto` bench CA -//! does) and `MQTT_CREDENTIALS` below. -//! 2. Drop the broker's root CA in DER form at the crate root; for the dev -//! bench: -//! ```bash -//! openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der -//! ``` -//! 3. Build and flash from this directory (its `.cargo/config.toml` selects -//! the thumbv8m target and the probe-rs runner): //! ```bash //! cargo run --release --features tls //! ``` @@ -91,9 +86,10 @@ 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; +use aimdb_mqtt_connector::TlsOptions; // Import shared types, monitors, and compile-time safe keys from the common crate use mqtt_connector_demo_common::{ @@ -122,6 +118,10 @@ async fn net_task(mut runner: embassy_net::Runner<'static, Device>) -> ! { // ============================================================================ // TEMPERATURE PRODUCERS (platform-specific due to embassy-time) +// +// Each cycles its readings endlessly rather than stopping after a fixed +// count: reconnect, re-subscribe and ping cadence only become observable +// while something is still publishing. // ============================================================================ /// Indoor temperature sensor producer @@ -129,7 +129,7 @@ async fn indoor_temp_producer(ctx: RuntimeContext, temperature: Producer = None; // Some(("user", "password")) +/// MQTT CONNECT credentials, which travel in the broker URL below. +const MQTT_USERNAME: &str = "aimdb"; +const MQTT_PASSWORD: &str = "aimdb-bench"; -/// The broker's root CA, DER-encoded (see the TLS section in the module doc). +/// The broker's root CA, DER-encoded. `gen-certs.sh` writes it here. #[cfg(feature = "tls")] static MQTT_CA_DER: &[u8] = include_bytes!("../ca.der"); @@ -315,9 +303,9 @@ async fn main(spawner: Spawner) { // gateway: Some(Ipv4Address::new(192, 168, 1, 1)), // }); - // Initialize network stack (TLS builds carry two extra sockets: DNS + SNTP) + // Initialize network stack (TLS builds carry one extra socket: SNTP) #[cfg(not(feature = "tls"))] - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); #[cfg(feature = "tls")] static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); @@ -353,12 +341,21 @@ async fn main(spawner: Spawner) { // Create AimDB database with Embassy adapter let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); - // Build MQTT broker URL (the scheme selects the transport) + // Build the broker URL. The scheme selects the transport; the authority + // carries the credentials, which both backends read. + // + // Nothing un-escapes this string on the way to the CONNECT, so a password + // needing percent-encoding (`@`, `:`, `/`) belongs in + // `.with_credentials(..)` on the builder below instead. use alloc::format; #[cfg(not(feature = "tls"))] - let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + let scheme = "mqtt"; #[cfg(feature = "tls")] - let broker_url = format!("mqtts://{}:{}", MQTT_BROKER_HOST, MQTT_BROKER_TLS_PORT); + let scheme = "mqtts"; + let broker_url = format!( + "{}://{}:{}@{}:{}", + scheme, MQTT_USERNAME, MQTT_PASSWORD, MQTT_BROKER_HOST, MQTT_BROKER_PORT + ); // ── AimX-over-serial: serve this db over USART3 (ST-LINK VCP, PD8=TX/PD9=RX) ── // A *second* connector alongside MQTT. With no extra cabling on a Nucleo-H563ZI @@ -386,25 +383,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]), - )); - match MQTT_CREDENTIALS { - Some((username, password)) => mqtt.with_credentials(username, password), - None => 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") }; let mut builder = AimDbBuilder::new() @@ -476,7 +493,12 @@ async fn main(spawner: Spawner) { info!("✅ Database configured with multi-sensor MQTT:"); info!(" OUTBOUND: sensors/temp/indoor, outdoor, server_room"); info!(" INBOUND: commands/temp/indoor, outdoor"); - info!(" Broker: {}", broker_url.as_str()); + // Without the authority: the URL carries the password, and this line goes + // to the RTT log. + info!( + " Broker: {}://{}:{}", + scheme, MQTT_BROKER_HOST, MQTT_BROKER_PORT + ); info!(" SERIAL (read-only AimX over USART3 / ST-LINK VCP):"); info!( " aimdb --features transport-serial --connect serial:///dev/ttyACM0?baud=115200 record list" @@ -485,12 +507,12 @@ async fn main(spawner: Spawner) { #[cfg(not(feature = "tls"))] { info!( - "Subscribe: mosquitto_sub -h {} -t 'sensors/#' -v", - MQTT_BROKER_IP + "Subscribe: mosquitto_sub -h {} -u {} -P -t 'sensors/#' -v", + MQTT_BROKER_HOST, MQTT_USERNAME ); info!( - "Command: mosquitto_pub -h {} -t 'commands/temp/indoor' \\", - MQTT_BROKER_IP + "Command: mosquitto_pub -h {} -u {} -P -t 'commands/temp/indoor' \\", + MQTT_BROKER_HOST, MQTT_USERNAME ); info!(" -m '{{\"action\":\"read\",\"sensor_id\":\"test\"}}'"); } 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..e94d88a5 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; @@ -212,7 +213,7 @@ async fn main(spawner: Spawner) { let config = embassy_net::Config::dhcpv4(Default::default()); // Initialize network stack - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); let (stack_obj, runner) = @@ -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