Skip to content

feat(052): runtime-neutral KNX connector - #252

Open
lxsaah wants to merge 14 commits into
mainfrom
feat/platform-agnostic-knx-connector
Open

feat(052): runtime-neutral KNX connector#252
lxsaah wants to merge 14 commits into
mainfrom
feat/platform-agnostic-knx-connector

Conversation

@lxsaah

@lxsaah lxsaah commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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) and embassy_client.rs (473) are gone, along with the whole Tokio*/Embassy* alias set. One KnxConnector<B, D, N> wires both runtimes onto the client::connection_task that 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's DatagramBinder and Delay.
  • KnxConnector::tokio(gateway_url) on a host: supplies the Tokio transports and its channels, so a caller needs only the URL.
  • with_command_queue_size becomes the const generic N. An embassy_sync::Channel is sized at compile time — an MCU allocates it in a static, where the size must be a constant.
  • KnxConnectorBuilder, KnxConnectorImpl and the Tokio*/Embassy* aliases are gone.

Updated in-tree: aimdb-codegen, both KNX demos, and topic_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 'static futures, so the channels must outlive the connector. An MCU supplies them from a StaticCell; KnxConnector::tokio leaks 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-drift runs in make check, and aimdb-codegen emitted KnxConnector::new(&knx_gateway) — a signature that no longer exists. KnxConnector::tokio exists so the generated line stays one call rather than adapter types plus a static; codegen changed by one word and no re-baseline was needed (✓ Codegen output compiles against the workspace).

aimdb-tokio-adapter becomes a tokio-runtime dependency, 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. TokioUdpBinder and EmbassyUdpBinder could not be held by a ConnectorBuilder, 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 check clean: 2 017 tests, 0 failures, all stages including codegen-drift.

Note

Touches examples/embassy-knx-connector-demo/src/main.rs, which #251 also edits (its SerialServer line). Whichever merges second needs a rebase there.

lxsaah and others added 14 commits September 6, 2026 10:23
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants