Skip to content

Add phone number addressing proposal - #145

Open
jamesmunns wants to merge 3 commits into
mainfrom
james/propose-pna
Open

Add phone number addressing proposal#145
jamesmunns wants to merge 3 commits into
mainfrom
james/propose-pna

Conversation

@jamesmunns

Copy link
Copy Markdown
Owner

A work in progress proposal to discuss Phone Number Addressing

@hannobraun hannobraun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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`

okhsunrog added a commit to okhsunrog/ergot that referenced this pull request Jun 10, 2026
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
okhsunrog added a commit to okhsunrog/ergot that referenced this pull request Jun 10, 2026
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)
okhsunrog added a commit that referenced this pull request Jun 13, 2026
…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.
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