Add phone number addressing proposal - #145
Conversation
hannobraun
left a comment
There was a problem hiding this comment.
Just gave the proposal a quick read, and I think it's a stroke of genius! Having networks that can move to other networks is really neat, and this
Under Phone Number Addressing, these concepts are all merged, meaning that a specific address now always refers to a socket.
blew my mind! 🤯
Should work for IPv6 too, though I'm sure that would break all kinds of assumptions at all the layers.
(Despite my enthusiastic response, I'd like to note that I haven't understood this at a deep enough level yet, so I can't say how well it would work in practice.)
Anyway, I found a few minor issues in the document and left comments.
| An Apex entity is able to use the entire 32-bit address space as it sees fit, as it has no upstream network that it needs to "fit within". However, Apex entities may still want to be conservative in how they use this space for two reasons: | ||
|
|
||
| 1. The existence as an "Apex" may be temporary, if they later join a network as a "downstream" or "child" entity | ||
| 2. The smaller addresses uses, the more compact they will be when transmitting. |
There was a problem hiding this comment.
| 2. The smaller addresses uses, the more compact they will be when transmitting. | |
| 2. The smaller the addresses they use, the more compact those will be when transmitting. |
| 1. The existence as an "Apex" may be temporary, if they later join a network as a "downstream" or "child" entity | ||
| 2. The smaller addresses uses, the more compact they will be when transmitting. | ||
|
|
||
| Currently, it is suggested that Apex entity make a best guess at the maximum address space needed for all downstream entities, including its own local sockets. |
There was a problem hiding this comment.
| Currently, it is suggested that Apex entity make a best guess at the maximum address space needed for all downstream entities, including its own local sockets. | |
| Currently, it is suggested that Apex entities make a best guess at the maximum address space needed for all downstream entities, including their own local sockets. |
|
|
||
| ### Downstream Entities | ||
|
|
||
| Devices that are connected to another device are referred to as "Downstream" entities. They may be directly connected to an Apex entity, or to another Downstream entity with or more "hops" to the Apex entity. |
There was a problem hiding this comment.
| Devices that are connected to another device are referred to as "Downstream" entities. They may be directly connected to an Apex entity, or to another Downstream entity with or more "hops" to the Apex entity. | |
| Devices that are connected to another device are referred to as "Downstream" entities. They may be directly connected to an Apex entity, or to another Downstream entity with one or more "hops" to the Apex entity. |
|
|
||
| ## Any/All messages | ||
|
|
||
| In the previous system, port `0` was reserved as the "Any" port, which ergot would attempt to find a single port that matched the requested characteristics. Port `255` was reserved as the "All" port, which ergot would flood to all sockets and interfaces, except for the source interface, until the TTL was consumed. |
There was a problem hiding this comment.
which ergot would attempt to find a single port that matched the requested characteristics.
I can tell that the grammar here is not right, but not knowing how the previous system works, I'm not sure how to correct it.
| As descrived, this generally requires: | ||
|
|
||
| 1. All devices know their own range (e.g. E's socket knows it is `03^6`) | ||
| 2. The profile of a device knows it's parents range, and it's own offset in that range (e.g. E's profile knows it is 180^9, so its socket is `183^9` |
There was a problem hiding this comment.
| 2. The profile of a device knows it's parents range, and it's own offset in that range (e.g. E's profile knows it is 180^9, so its socket is `183^9` | |
| 2. The profile of a device knows its parent's range, and its own offset in that range (e.g. E's profile knows it is 180^9, so its socket is `183^9` |
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
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)
…ts (#209) * Add bus address claim protocol for dynamic node_id assignment 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 * Start bridge upstream with link-local addressing instead of Inactive 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. * Skip pending slots (net_id=0) in router find() to fix link-local routing 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. * Rewrite src.network_id=0 in common_send regardless of node_id 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). * Don't override Active state on USB connection in eusb transports 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. * Fix EdgeFrameProcessor re-discovering net_id from transit frames 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. * Add e2e test for bridge with link-local upstream and device 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). * Add Bus mock and shared-segment e2e tests 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 * Collapse nested if into let-chain in gc_node_claims * Reuse net_ids in router via lowest-free allocation 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 #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 * Pass CC node-claim generic through tokio_serial register_router 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`. * Scope bus node_id claims by net_id and reject reserved candidates 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 * Add unit tests for bus node_id claim refresh and error paths 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. * Add bus_claim/bus_claim_refresh client helpers 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) * Extract LeaseTable<K> shared by seed routes and node claims 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 (#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 * Silence intentional enum_variant_names lint on test SinkEvent 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. * Document the bus address claim protocol 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 (#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) * Add test: process_frame drops frames from unclaimed node_ids 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. * Use saturating_duration_since for duplicate-claim remaining lease 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. * Port e2e_bridge_discovery to the futures-io transport API After rebasing onto main, the bridge-discovery test no longer compiled: #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 #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). * Add register_bridge_downstream helper for pending bridge interfaces 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). * Address bus-address-claim review findings 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. * Correct #204 attribution in the bus address claim design note #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 #204 by also reusing net_ids via lowest-free allocation. * Tidy bus address claim docs and align them with the implementation - 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. * Add bus_claim_with_retry helper for candidate probing 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.
A work in progress proposal to discuss Phone Number Addressing