feat(052): runtime-neutral KNX connector - #252
Open
lxsaah wants to merge 14 commits into
Open
Conversation
- Updated the KNX connector usage in examples and tests to utilize the new `KnxConnector::tokio` method instead of the deprecated `KnxConnector::new`. - Removed the `tokio_client.rs` file as it is no longer needed with the new connector structure. - Adjusted the `lib.rs` file to reflect the new connector organization and removed platform-specific implementations. - Updated tests to ensure compatibility with the new connector API. - Added static buffers and channels for the Embassy adapter in the embassy example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the runtime-neutral TCP (#250) and serial (#251) connectors. Conflict was in the Embassy KNX demo's imports, where both sides moved: main rehomed `SerialServer` out of `embassy_transport` and wrapped its halves in the new `EmbassyUart`, while this branch replaced `KnxConnectorBuilder` with the sans-io `KnxConnector::new`. Kept both. Also adds the embassy adapter's `net` feature to the demo. It used to arrive transitively through the serial connector's `embassy-runtime` feature; #251 made that crate runtime-neutral, so nothing pulls the adapter in on its behalf any more and `EmbassyNet::udp`/`EmbassyDelay` have to be requested where they are used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopts the feature split the TCP and serial connectors use. `src/` names no
adapter and no `std::`, so one gate covers every target:
connector = ["aimdb-core/alloc", "aimdb-core/connector-session",
"embassy-sync"]
std = ["connector", "aimdb-core/std", "knx-pico/std"]
A runtime is chosen by *passing* an adapter's `DatagramBinder` and `Delay` to
`KnxConnector::new`, so a FreeRTOS/lwIP caller enables `connector` and brings
its own transports without claiming to be Embassy. `tokio-runtime` and
`embassy-runtime` stay as deprecated aliases for `std` and `connector`.
`embassy-runtime` was the only feature enabling the optional dependencies the
now-deleted `embassy_client.rs` had needed, so retargeting it orphans them:
`aimdb-embassy-adapter`, `embassy-net`, `embassy-time`, `static_cell` and
`dep:defmt` are dropped, along with `futures-core`, `thiserror` and
`embassy-executor`, which no source file had referenced even before that. The
embedded dependency graph goes from 88 crates to 46, and the crate now depends
on neither adapter rather than just not the Tokio one.
`embassy-sync` moves onto `connector`: it is no_std, no_alloc, pulls no
executor, and its `Channel` is this connector's public queue type (`Channels`)
on every runtime. `embassy-futures` stays unconditional.
Call sites follow: the two examples, the five integration tests, both
aimdb-codegen manifest templates, and the Makefile build/test/clippy/doc/
embedded-check legs.
Verified: std tests (67 pass) and clippy --all-targets; clippy and build on
thumbv7em for `connector` and `connector,defmt`; the default (tunnel-only)
leg; both deprecated aliases; both demos (host and thumbv8m); aimdb-codegen
tests; rustdoc warning-free under default, `std` and `connector`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
…tructor `KnxConnector::new(url)` has taken a binder, a clock, and a channel pair since the runtime-neutral rewrite, but three prose docs — none of them compiled by anything — still showed the one-argument form: - aimdb-knx-connector/README.md, the crate's own Quick Start - examples/tokio-knx-connector-demo/README.md, whose main.rs was updated - docs/aimdb-usage-guide.md Each now shows the real call with the adapter's transports and a `static` channel pair, and the installation snippets carry the current feature names (`std` + `critical-section-std-impl` on a host, `connector` on an MCU) with the tokio adapter's `net` feature, which the host leg needs for `TokioNet`. Also in the crate README: the Embassy Quick Start gets the same call rather than only a pointer to the demo, since the whole point of the rewrite is that the two differ only in which transports you pass; the "Dual Runtime Support" bullet becomes "Runtime-Neutral"; and the install snippet's version catches up to the crate's actual 0.5. docs/releases/v0.2.0.md keeps the old form — it is a historical release note, and that API was correct for v0.2.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
Every AimDB version in the guide predated the current crates by one or more
majors (aimdb-core 0.3 vs 1.2, the adapters 0.3 vs 0.6, knx 0.2 vs 0.5, mqtt
0.3 vs 0.6), across the getting-started blocks, the connector sections, and
the version-pinning and migration examples.
Two entries were not merely stale but would fail to build:
- `aimdb-embassy-adapter` was given a feature `embassy-task-pool-16` that
the crate has never had; dropped.
- `embassy-executor`'s `arch-cortex-m` is `platform-cortex-m` as of the
0.10 the workspace pins; embassy-time follows to 0.5.
Every version and feature name here was read off the crate manifests rather
than assumed. `embassy-rp` is left alone — it is a board HAL the reader
chooses and the workspace pins no version to check against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
…ed IP `connection_task` built the CONNECT_REQUEST's HPAI from `local_addr()` with no check on the address, so a socket bound to `0.0.0.0` reported `0.0.0.0:<port>` and that went out verbatim: a real port beside an IP that routes nowhere. It is neither an endpoint a gateway can reach nor the NAT form the spec defines, and a gateway honouring the HPAI would send its tunnel data into the void. `0.0.0.0` is not an exotic case — it is what the two demos, the crate doc example and both aimdb-codegen templates pass, because a binary rarely knows which interface to pick. An unspecified IP now falls through to `LocalEndpoint::Nat` and emits `0.0.0.0:0` (KNXnet/IP 5.2.3), which tells the gateway to reply to the datagram's source address. Binding a real interface address is unaffected and still advertised explicitly. Both pre-rewrite clients had this (the tokio one bound "0.0.0.0:0" and ran the same logic), so the handshake changes for every default deployment. The existing endpoint test binds LOCALHOST and so only ever covered the explicit branch; it now says why, and a second test covers the branch the shipped configuration actually takes. Verified non-vacuous: with the guard removed it fails on 0.0.0.0:35555. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
Two small cleanups in `connector.rs`. `ChannelTelegrams` was a second `TelegramSink` bridge with a body identical to `shared_channel::ChannelSink` — the two differed only in holding a `&Channel` versus a `Sender`, which `Channel::sender()` converts between for free. The outbound half of the same call already used the shared bridge (`ChannelCommands`), so the two halves were asymmetric in adjacent arguments. `ChannelTelegrams` is deleted and the inbound half now passes `ChannelSink::<N>(channels.telegrams.sender())`. This also gives `ChannelSink` a production caller. It is public API in a public module, and its only user was `client`'s own test — a shape that invites a later "unused" deletion, after which the crate would carry two divergent answers to the same question. `TelegramSink::try_send` is non-blocking by contract (a full sink drops rather than stalling the protocol loop), so one implementation is one place for that to stay true. `build_yields_the_connection_task_and_pumps` asserted only `!futures.is_empty()`, which the connection task alone satisfies — a pump silently dropping out of `build` would not have failed it. The count is deterministic, so it is now asserted: a db with one inbound and one outbound route yields 3 (task + pump_source + one publisher), and a new inbound-only case yields 2, covering the `pump_sink`-contributes-nothing half that the routed db cannot show. Routed records need a registered connector or the builder rejects them, so the helper registers one — with its own channel pair, since two connectors sharing one would split the command queue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
…stance Registering two KNX connectors never worked and never said so. `build` collects every `knx://` route regardless of which gateway it was meant for, so each connector claimed all of them: every `link_to` got two publishers. Sharing one `Channels` made it worse — two connection tasks draining one command queue, so a write reached one gateway, or the same one twice, at random. The check cannot live in the KNX crate: `ConnectorBuilder::build` sees only the db, never its siblings, and a crate-level counter would false-positive across independent dbs in one process (this crate's own test suite, for one). So core gets a defaulted trait method, `ConnectorBuilder::owns_scheme`, and `AimDbBuilder::build` rejects a duplicate before building any connector. Opt-in rather than a blanket duplicate-scheme rule, because the constraint is not universal. It follows from collecting routes by scheme, which only some connectors do: `SessionClientConnector` drives `pump_client(db, &self.scheme, …)` and has the same problem, but `SessionServerConnector::build` binds a listener and collects nothing, so two under one scheme are two endpoints onto one dispatch — useful, and a blanket rule would have broken it. Default `false` keeps every such connector working untouched. Only `aimdb-knx-connector` opts in here. MQTT and the session clients look like candidates on the same reasoning, but whether an existing deployment relies on registering two is not mine to assume. Note this forbids a second *gateway*, not a second group address: one connector is one tunnel and carries the whole bus behind it, with as many addresses as records declare. Tested: the core mechanism (duplicate rejected, distinct schemes fine, non-owning connectors still register twice) and the KNX case with both shared and separate channel pairs. Full workspace suite run to confirm nothing registered a duplicate today. A pre-existing link failure in a workspace-wide `cargo test` for two knx test targets reproduces identically with these changes stashed; the per-package `make test` legs it runs in CI are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
…s held
`EmbassyUdpBinder` owns exactly one socket and `Clone` shares the `Arc<UdpSlot>`
holding it, so a clone binding while another handle has the socket got
`TransportError::Io` — the same error a genuinely failed bind returns. A
consumer's retry loop treats that as transient, so a caller mistake that never
resolves on its own read as an endless unexplained bind failure: in
`connection_task`, one identical "KNX bind failed; retrying" line every 5 s,
forever, with nothing pointing at the cause.
`TransportError::Busy` exists for exactly this ("the transport's one endpoint
resource is already in use — a second dial on a single-socket transport while
the first connection is live"), and `EmbassyTcpDialer` — the sibling type in
this same file, from the same design-052 work — already returns it and
documents the whole contract. The UDP binder simply did not follow the pattern
its neighbour set. It now does, and carries the same four things in its docs:
what `Clone` does, why the bound exists, what a second holder gets, and that a
second concurrent socket needs another `EmbassyNet::udp` call with its own
buffers. That last part matters because `EmbassyNet::udp` is documented as
serving KNX/IP *and* SNTP, so two consumers on one stack is an anticipated
case — and clones are the wrong way to get there.
The docs also spell out that `TokioUdpBinder` opens a fresh OS socket per call
and its clones are independent, since the identical `Clone` bound means
different things on the two adapters.
`Clone` stays: `ConnectorBuilder::build` has only `&self` and must hand an
owned binder to a `'static` task, so `KnxConnector` needs `B: Clone`. The
derive was never the problem — the silence around it was.
knx: the bind-retry loop now logs `Busy` distinctly. Recovery is deliberately
unchanged, since a `Busy` binder can free up if the other holder drops.
Tested: a fake binder reporting Busy twice then succeeding proves the task
retries and connects, asserting all three bind attempts; `start_paused` keeps
the two BIND_RETRY sleeps virtual, so it costs no wall time. Verified
non-vacuous — making Busy fatal fails it. The Busy path in the adapter itself
has no automated test: there is no host-runnable embassy-net stack harness in
that crate (the TCP equivalent lives in aimdb-tcp-connector behind
_test-embassy-loopback), and porting one for UDP is well beyond this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
… case The previous commit said this path had no automated test because the crate has no host-runnable embassy-net harness. That was wrong: `tests/udp.rs` drives two crossover-wired stacks on the host and `make test` runs it via `--features "alloc,net"`. The mistaken conclusion came from running `cargo test -p aimdb-embassy-adapter` with default features, where the file's `#![cfg(feature = "net")]` hides every test and the run reports zero. `a_second_bind_fails_while_the_socket_is_held` existed but asserted only `is_err()`, so it passed identically before and after the `Io` -> `Busy` change and could not have caught the regression it looks like it guards. It now asserts the variant, and is renamed to say so. Added the case the finding was actually about: a *clone* binding while the original holds the socket. It also asserts that dropping the holder frees the clone, so the shared slot reads as a live handoff rather than a permanent claim by whoever bound first. Both verified non-vacuous: restoring `TransportError::Io` fails them with `left: Some(Io), right: Some(Busy)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8qsWbwb7DqFribiSX6grv
`ConnectorBuilder` is ungated, but `crate::session` is behind `connector-session`, so the three `pump_*` intra-doc links resolved only on the legs that happen to enable it. `make doc` documents each leg separately under `-D warnings`, and the embedded one (`aimdb-core --no-default-features --features alloc`) has no `session` module, so it failed there with `unresolved link`. The items are still named — they are the reason the rule exists — just as code spans rather than links, with a note saying why, so the links are not restored later. Verified: the failing leg, `std`, and `std,connector-session` all document clean, and the whole `make doc` target passes. `make examples` too, since the doc failure had been masking it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave B of design 052, third and last connector. The adapter owns the UDP socket and the clock; this crate owns the tunnelling protocol.
tokio_client.rs(632) andembassy_client.rs(473) are gone, along with the wholeTokio*/Embassy*alias set. OneKnxConnector<B, D, N>wires both runtimes onto theclient::connection_taskthat landed in wave A.With this, all three connectors carry zero runtime modules and zero
unsafe impls.Breaking
KnxConnector::new(binder, delay, gateway_url, &CHANNELS)— generic over core'sDatagramBinderandDelay.KnxConnector::tokio(gateway_url)on a host: supplies the Tokio transports and its channels, so a caller needs only the URL.with_command_queue_sizebecomes the const genericN. Anembassy_sync::Channelis sized at compile time — an MCU allocates it in astatic, where the size must be a constant.KnxConnectorBuilder,KnxConnectorImpland theTokio*/Embassy*aliases are gone.Updated in-tree:
aimdb-codegen, both KNX demos, andtopic_provider_tests.Worth a look in review
The channels are a caller-supplied
&'static Channels<N>. Both the connection task and the pumps are spawned as'staticfutures, so the channels must outlive the connector. An MCU supplies them from aStaticCell;KnxConnector::tokioleaks one pair at build — bounded, once per process, matching design 037's allocate-at-build model, but a deliberate leak worth seeing.D1 moved into this PR.
codegen-driftruns inmake check, andaimdb-codegenemittedKnxConnector::new(&knx_gateway)— a signature that no longer exists.KnxConnector::tokioexists so the generated line stays one call rather than adapter types plus astatic; codegen changed by one word and no re-baseline was needed (✓ Codegen output compiles against the workspace).aimdb-tokio-adapterbecomes atokio-runtimedependency, the same relaxation taken for the serial connector — the adapter owns the datagram socket on both runtimes rather than the connector duplicating it.Two adapter binders gained
Clone.TokioUdpBinderandEmbassyUdpBindercould not be held by aConnectorBuilder, which clones its transport per build. That is the third wave-A type to need this after the TCP dialers — the traits were right, but their composition into a builder was untested. Worth a compile-time assertion in core.Verification
make checkclean: 2 017 tests, 0 failures, all stages includingcodegen-drift.Note
Touches
examples/embassy-knx-connector-demo/src/main.rs, which #251 also edits (itsSerialServerline). Whichever merges second needs a rebase there.