Skip to content

Bus address claim: dynamic node_id assignment on shared-medium segments - #209

Merged
okhsunrog merged 25 commits into
jamesmunns:mainfrom
okhsunrog:bus-address-claim
Jun 13, 2026
Merged

Bus address claim: dynamic node_id assignment on shared-medium segments#209
okhsunrog merged 25 commits into
jamesmunns:mainfrom
okhsunrog:bus-address-claim

Conversation

@okhsunrog

Copy link
Copy Markdown
Collaborator

ergot assigns node_ids by fixed role on point-to-point links (controller = 1, target = 2), but a shared-medium segment — ESP-NOW, CAN FD, RS-485, a simple radio — can carry many devices on one net_id, each needing a unique node_id negotiated at runtime. This adds a router-arbitrated address-claim protocol (DHCP-like, not AppleTalk-style probing, since an ergot tree always has exactly one router per segment): a device boots link-local, proposes a candidate node_id, and the segment's router grants it as a lease or reports a conflict so the device retries. The router reuses the seed-router lease lifecycle (lease + refresh + tombstone + GC), so node_ids are reclaimed automatically as devices come and go. It is a defense against accidental collisions on a trusted bus, not a security boundary (no authentication). See notes/2026-06-01-bus-address-claim.md for the full design and threat model.

  • Add the bus address claim protocol on the Router profile: request_node_claim / refresh_node_claim / is_node_claimed, two well-known endpoints (address/claim, address/refresh), the Services::address_claim_handler server, and bus_claim / bus_claim_refresh client helpers (mirroring bridge_seed_assign).
  • Factor the seed-route and node-claim lease lifecycles into a shared, representation-agnostic LeaseTable<K, X> (active → tombstone → GC, token-checked refresh with extend + rotate). K is the seam for phone-number addressing (Add phone number addressing proposal #145): a node_id today, an address range later.
  • Validate received frames against the claim table: a router drops frames whose source node_id hasn't claimed an address on that segment, except wildcard-port (claim) frames and transit frames originating on another net.
  • Reuse net_ids: alloc_net_id now hands out the lowest free net_id (skipping in-use slots, seed routes, tombstones, and the upstream), so a long-running router under device churn no longer climbs the u16 space to exhaustion.
  • Fix EdgeFrameProcessor re-discovering its net_id from transit frames (a bridge upstream's net_id now stays stable), plus link-local routing fixes (skip pending slots in routing, rewrite src.network_id = 0 regardless of node_id, start a bridge upstream link-local).
  • Add register_bridge_downstream (the missing sibling of register_bridge_upstream) for bringing up a pending downstream interface, and stop the eusb transports clobbering an already-Active interface state on USB connect.
  • The Router profile gains an optional const C: usize = 0 (max bus claims); existing Router<I, R, N, S> usages and toolkit aliases compile unchanged, and the register_router transport helpers thread C through via inference.
  • Supersedes fix router's gc function #204 (router gc fix, @tommasoclini): its fix — tombstoning expired seed routes so they stop occupying table slots — is included here, generalized into LeaseTable's GC. This goes further by replacing the monotonic net_id counter with lowest-free allocation, so net_id values are reclaimed too; fix router's gc function #204 alone frees slots but the counter still climbs to exhaustion under sustained churn.
  • Tests: shared-medium claim + conflict (e2e_bus_segment), claim/idempotent/refresh logic (e2e_bus_claim), frame-validation incl. transit and deregister-purge (bus_claim_validation); plus a book section.

Supersedes #204.

okhsunrog added 25 commits June 10, 2026 16:30
Adds a protocol for claiming unique node_ids on bus-style network
segments (CAN FD, ESP-NOW, RS-485) where multiple devices share a
single interface.

- Add AddressClaimEndpoint and AddressRefreshEndpoint with lease-based
  mechanics matching the existing seed router pattern
- Add NodeBitmap ([u32; 8]) for O(1) per-frame validation of claimed
  node_ids in router process_frame
- Add NodeClaim table with new const generic C on Router<I,R,N,S,C>
  (defaults to 0 for backwards compat)
- Relax process_frame node_id check to accept any non-zero,
  non-CENTRAL node_id (bus-style support)
- Add bitmap validation exception for wildcard port 0 (claim requests)
- Relax EdgePort::set_state to allow changing node_id (bus devices
  start with a random candidate then switch to the granted id)
- Add address_claim_handler() service
- Add Profile trait methods: request_node_claim, refresh_node_claim,
  is_node_claimed
- Add 4 e2e tests: claim+ping, two edges claiming, conflict detection,
  duplicate claim idempotency
Bridge upstream now starts as Active{net_id:0} like edge targets,
allowing the bridge to initiate contact with the root router without
waiting for an incoming frame first.
Pending downstream slots with net_id=0 were intercepting link-local
frames (dst.network_id=0) meant for the upstream, causing
DestinationLocal errors on bridges before seed assignment.
Previously only frames with both network_id=0 AND node_id=0 got src
rewritten. Forwarded frames with src=(0, N, port) — e.g. from an
upstream with link-local net_id — escaped with network_id=0, causing
receivers to drop them as spoofed.

Now any frame with src.network_id=0 gets the network_id rewritten to
the outgoing interface's net_id. node_id is only rewritten if also 0
(locally originated).
The eusb RxWorker was unconditionally setting Active{net_id:0} on
every USB connection, overwriting a real net_id that was already
assigned (e.g. via seed routing before USB physically connected).

Now the handler skips the state update if the interface is already
Active, preventing the race where seed_assign sets net_id=3 and then
USB connect resets it to net_id=0.
On a bridge upstream, frames with various dst.network_ids pass through
for routing to downstream interfaces. EdgeFrameProcessor was updating
its net_id from every such frame, corrupting the upstream's identity.

Now net_id is only discovered once (when None) from the first incoming
frame. After liveness timeout, reset() clears it to allow re-discovery.
Tests the full ESP-NOW demo topology: Host ← Bridge ← Root with
link-local upstream, seed routing, device discovery through the bridge,
and upstream net_id stability after transit frames (regression test for
EdgeFrameProcessor re-discovery bug).
Bus mock simulates a shared medium (ESP-NOW, CAN FD, RS-485) using
tokio::sync::broadcast. Each BusTap can send frames to all other taps.

Tests:
- two_edges_on_shared_bus_claim_and_ping: two edges claim unique
  node_ids on the same net_id, router pings both
- bus_claim_conflict_same_segment: two edges try to claim the same
  node_id on the same bus, second gets Conflict
The router allocated net_ids from a strictly monotonic counter that was
never reused, so a long-running router with device churn would eventually
exhaust the u16 space. Switch to lowest-free allocation that reclaims a
net_id once its slot is removed or its seed-route tombstone clears, and
make the tombstone grace period genuinely reserve the net_id until reuse
is safe.

- Replace the monotonic net_id_ctr with net_id_in_use + a lowest-free
  alloc_net_id (skips direct slots, seed routes incl. tombstones, and the
  upstream net_id — the last also fixes a latent bridge collision)
- gc_seed_routes now tombstones expired active routes before removing them
  (adapted from jamesmunns#204 by @tommasoclini), the prerequisite
  for reclaiming a net_id
- Anchor the tombstone grace to expiration + GRACE at every lease-expiry
  site (find, seed/node refresh, gc) so the reservation window starts when
  authority is lost; deregister keeps now + GRACE (no expiration exists)
- register_interface runs gc_seed_routes first so cleared tombstones
  release their net_ids
- Tests: reuse a freed direct net_id; tombstone reserves a seed net_id
  while a freed net_id is reused
tokio_serial::register_router gained the CC node-claim const generic in
its own signature but did not forward it when delegating to
tokio_cobs_stream::register_router, so any build enabling the tokio-serial
transport failed with E0107 (7 of 8 generic arguments supplied). This
broke `cargo clippy/doc --all-features`.
The router validated bus address claims with a single global NodeBitmap
keyed only by node_id, but claims are per-(node_id, net_id). On a router
with multiple bus segments this leaked across them: a node_id claimed on
one bus validated frames bearing the same node_id on another, and GC
clearing a tombstone could clear a bit still in use on a different bus or
even the permanent CENTRAL/EDGE bits. request_node_claim also accepted
reserved candidates (0/CENTRAL/EDGE/255).

- Drop NodeBitmap; is_node_claimed now scans node_claims scoped to the
  frame's net_id, and treats an expired-but-not-GC'd claim as invalid so a
  quiet bus can't keep a stale node_id alive
- request_node_claim rejects reserved node_ids with a new
  AddressClaimError::InvalidNodeId
- gc_node_claims collapses to a single-pass retain_mut mirroring
  gc_seed_routes
- Tests: claim is net_id-scoped (CENTRAL/EDGE always valid, no cross-bus
  leak); reserved node_ids are rejected
The refresh half of the claim protocol and its error paths had no test
coverage. Add unit tests exercising refresh (lease extension + token
rotation, TooSoon, BadRequest, UnknownNodeId) and the claim error paths
(UnknownSource, Exhausted, duplicate-nonce idempotency, conflict). These
run without a clock mock because the initial 30s lease is below
MIN_SEED_REFRESH (62s), so a refresh is immediately permitted.
The bus address claim protocol had a server handler but no client API —
callers had to hand-build the request and update interface state, and
nothing refreshed a lease (so claims silently expired after 30s). Add
bus_claim and bus_claim_refresh, mirroring bridge_seed_assign/refresh:
bus_claim sends the link-local request and, on grant, sets the interface
to Active with the granted address, returning a NodeClaimLease;
bus_claim_refresh renews it.

- New NodeClaimLease + ClaimClientError + bus_claim + bus_claim_refresh in
  services.rs
- Refactor bus_edge_claims_and_pings to use bus_claim
- New e2e test bus_edge_claims_then_refreshes covers the refresh endpoint,
  handler, and refresh_node_claim end-to-end (the previously untested half)
Seed routes and bus node_id claims had independent but near-identical lease
lifecycles (active/tombstone state, expiry→tombstone GC, token-checked
refresh with extend+rotate, grace anchored to expiration). Extract that
lifecycle into a representation-agnostic LeaseKind + LeaseTable<K, X>, so it
is written once and both tables reuse it.

The key type K is the leased value: an assigned net_id (u16) for seed
routes, a node_id (u8) for claims. This is also the seam for phone-number
addressing (jamesmunns#145): the tree's parent→child allocation is the
same lease lifecycle with K becoming an address range, so only K and the
wire format change, not the machinery.

- LeaseKind::{active,is_active,gc_retain,refresh}; LeaseTable::{gc,
  contains_key,by_key,get,get_mut,push}
- seed_routes: LeaseTable<u16,u8,S> (extra=via_ident);
  node_claims: LeaseTable<u8,u64,C> (extra=nonce)
- Unify refresh lookup to (key, scope) for both. Behaviour change: a seed
  refresh from the wrong source_net now returns UnknownNetId rather than
  BadRequest (the lease simply isn't found for that requester); test updated
- No functional change otherwise; all 115 tests pass
The SinkEvent variants (SendTy/SendRaw/SendErr) mirror the sink methods
send_ty/send_raw/send_err, so the shared prefix is intentional. This was the
only clippy warning left on the branch; allow it locally for a clean build.
Mirror how seed routing is documented (book feature + notes design record):

- notes/2026-06-01-bus-address-claim.md: design record — the central-arbiter
  choice and why it beats AppleTalk-style probing given a router, the
  one-router-per-segment assumption, the claim/lease/refresh/tombstone
  lifecycle, net_id reuse, the shared LeaseTable<K>, threat model, and the
  phone-number-addressing (jamesmunns#145) seam
- book/_02 Major Concepts: note how Node IDs are claimed on a bus segment
- book/_03 Feature Overview: new "Shared bus segment" topology under
  Connectivity-Now, and update the "Soon" RS-485/Radio bullet (Node ID
  addressing is now provided by the claim protocol)
Covers the validation drop path end-to-end at the process_frame level: a
frame from an unclaimed node_id (to a non-zero port) on a bus is dropped,
and the same frame routes normally once that node_id claims an address.
The duplicate-claim branch computed remaining lease time as
`expiration - now`, which is safe today (gc tombstones expired actives
before this branch is reached) but would panic on std if that invariant
ever broke. saturating_duration_since is defensive and available on both
std and embassy_time Instant.
After rebasing onto main, the bridge-discovery test no longer compiled:
jamesmunns#207 made the COBS stream transport runtime-agnostic, replacing the
tokio-specific CobsStreamRxWorker with the generic futures_io::RxWorker
(constructed via `RxWorker::new(...).with_closer(...)` over a `.compat()`
reader, driven by `run(&mut frame, &mut scratch)`). This mirrors the
sibling e2e_bridge_seed test, which jamesmunns#207 already migrated.

- Rewrite the manual pending-downstream worker setup to use
  futures_io::RxWorker instead of the removed CobsStreamRxWorker; pass the
  tokio duplex reader through `.compat()` and drop the boxed trait objects.
- Use tokio_util::compat in the test directly; it is already an active
  dependency under the tokio-std feature (no new dev-dependency needed).
Bringing up a bridge's downstream interface required hand-rolling the
transport: register the interface pending, wire a closer, build a
futures_io::RxWorker over a .compat() reader with a placeholder net_id,
allocate the COBS buffers, spawn both workers, and deregister on exit.
That ~40-line dance was duplicated verbatim across two tests, and there
was no downstream counterpart to register_bridge_upstream.

Add tokio_cobs_stream::register_bridge_downstream, mirroring
register_bridge_upstream: it creates the outgoing queue, registers the
interface via register_interface_pending, spawns the RX/TX workers, and
returns the pending ident so the caller can drive bridge_seed_assign. The
processor starts at net_id 0 and adopts the assigned net_id once the lease
lands.

- Add register_bridge_downstream + BridgeDownstreamRegistrationError.
- Refactor e2e_bridge_discovery and e2e_bridge_seed onto it, dropping the
  manual worker setup (and the direct futures_io::RxWorker / tokio_util
  usage it required).
Correctness (red-green, tests in bus_claim_validation.rs):
- Claim validation now applies only to frames *originating* on the arrival
  segment (src.network_id == net_id after the zero-rewrite), not to transit
  frames. Previously a bus-claimed node_id (3..254) routed through a second
  Router was dropped, breaking multi-hop routing of bus-originated traffic.
  Test: transit_frame_from_foreign_net_is_not_dropped.
- deregister_interface now purges node_id claims scoped to the removed
  interface's net_id. They were lingering until lease expiry and, on a reused
  net_id, would validate frames / block re-claims on the new bus.
  Test: claims_are_purged_when_interface_deregistered (added LeaseTable::drop_scope).

Cleanups:
- Fix is_node_claimed default-impl doc (it accepts only CENTRAL/EDGE, not "all
  node_ids" as the comment claimed).
- Rename the shared lease constants INITIAL/MAX_SEED_ASSIGN_TIMEOUT and
  MIN_SEED_REFRESH to INITIAL_LEASE_SECS / MAX_LEASE_SECS / MIN_REFRESH_SECS
  now that LeaseTable shares them between seed routes and node claims.
- Document that LeaseKind::refresh checks the token before expiry on purpose
  (a wrong token learns nothing about lease existence/expiry).
- Rename the misleading e2e_bus_claim "two_edges...same bus" test to reflect
  that it uses two separate point-to-point links (different net_ids); the real
  shared-medium contention test lives in e2e_bus_segment.

Deferred: skipping pending (net_id=0) slots in the port-255 broadcast loops
(a marginal consistency guard) — left out rather than ship a behavior change
without a dedicated broadcast-routing test.
jamesmunns#204 (router_gc_fix, by @tommasoclini) is an open PR, not a merged upstream
fix, and it only tombstones expired seed routes (freeing table slots) — it
does not reclaim net_id values, since the monotonic counter remains. State
that accurately: the gc fix is adapted/generalized here, and this work
supersedes jamesmunns#204 by also reusing net_ids via lowest-free allocation.
- Reframe the design note's "Open questions": keep only candidate-selection/
  retry (the one genuine open question); move router redundancy and routerless/
  mesh modes to a "Non-goals" section, since both break the strict-tree,
  one-arbiter-per-segment invariant this design relies on, and fix the
  dangling cross-reference in the assumption paragraph.
- Update the Validation section: validation applies only to frames originating
  on the arrival segment (src.network_id == net_id); transit frames are exempt
  so bus node_ids route across the tree (matches the implementation).
- Update Expiry/tombstones: a deregistered bus interface drops its claims
  outright (not tombstones), since its net_id may be reused; note this on
  Router::deregister_interface too.
- Note the transit-exemption bypass in the threat model.
- Clean up the shared-bus ASCII diagram in the feature-overview book section.
The claim protocol leaves candidate selection to the device, which meant every
caller hand-rolled the conflict-retry loop. Provide it: `bus_claim_with_retry`
walks a caller-supplied candidate iterator (a range like `3..=254` for
sequential probing, or an RNG-driven iterator for randomized probing), calling
`bus_claim` for each and advancing on `Conflict`. A grant or any other error
stops immediately; exhausting the candidates returns the new
`ClaimClientError::NoFreeCandidate`. The same nonce is reused across attempts so
a lost response to a granted claim is recovered idempotently.

- Add `bus_claim_with_retry` + `ClaimClientError::NoFreeCandidate`.
- Tests: skip a taken candidate and claim the next; exhaust to NoFreeCandidate.
- Docs: resolve the design note's candidate-selection open question (only the
  candidate ordering is now left to the device) and list the helper in the book.
@okhsunrog
okhsunrog merged commit c3b31bf into jamesmunns:main Jun 13, 2026
13 checks passed
@okhsunrog
okhsunrog deleted the bus-address-claim branch June 13, 2026 09:24
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.

1 participant