B1+B2+B3: bearer-neutral peer discovery (AGW UI + UDP multicast) - #23
Merged
Conversation
The plan sketched an IDappsUiTransport interface that was AGW-flavoured.
After the A0 backhaul-seam work it made more sense to lift discovery to
the same level -- bearer-neutral. New types (in dapps.client/Discovery/):
IDiscoveryBearer -- StartAsync / AnnounceAsync / ListenAsync.
Two impls: AGW UI frames, UDP multicast.
BeaconFrame -- callsign + hops + ttl + bearer hint stamped by
the receive bearer (NOT carried on wire, so a
peer can't claim a route it doesn't have).
BeaconCodec -- "DAPPS v1 callsign=M0LTE-9 hops=0 ttl=300".
KV form rather than positional; future fields
slot in without breaking parsers.
AGW path:
AgwUiDiscoveryBearer holds a dedicated AGW TCP socket, registers our
callsign with X, enables monitor mode with m, sends beacons via M,
filters incoming U frames for DAPPS-prefixed payloads. Self-echoes
filtered by callsign. RHP variant stays a future stub when BPQ
catches up.
UDP path:
UdpMulticastDiscoveryBearer joins a configurable group (e.g.
239.42.42.42:1881). Useful for LAN dev/testing without a BPQ stack.
Off by default -- operators opt in by setting DAPPS_MULTICAST_GROUP.
DiscoveryService is the hosted background service. Constructs its
bearers from options inside ExecuteAsync (not via DI factory, because
the options-config callback queries the systemoptions table which
DbStartup hasn't created at the moment hosted services are first
materialised). Per bearer: emits our beacon every
DiscoveryBeaconIntervalSeconds, concurrently iterates the listen
stream, upserts DbDiscoveredPeer rows. Sweeper drops rows whose
freshness window (the beacon's advertised ttl) has elapsed.
DbDiscoveredPeer is a (Callsign, Bearer) composite-keyed table -- the
same peer reachable via two bearers occupies two rows. Database has
UpsertDiscoveredPeer, GetDiscoveredPeers, AgeOutDiscoveredPeers.
Dashboard surfaces discovered peers (callsign + bearer + hops + source
+ age + ttl) so a sysop can verify discovery in real time.
Tests:
BeaconCodecTests (10) encode + decode round-trip,
trailing-newline tolerance,
malformed inputs rejected,
unknown keys forward-compat.
UdpMulticastDiscoveryTests (8) loopback announce + hear,
own-beacon filter, bad-group
parse paths.
AgwUiDiscoveryTests (3) FakeAgwServer over loopback TCP
captures the X / m / M sequence,
pushes a U frame back, asserts
the bearer yields the parsed
beacon and ignores wrong kinds /
own echoes.
DiscoveryStorageTests (4) composite-key upsert idempotent,
same callsign different bearer ->
two rows, age-out by ttl.
232 unit tests pass. Smoke-tested end-to-end with two DAPPS instances
on the same multicast group: each discovered the other within ~50ms;
dashboard surfaced the peer.
Drive-by: AgwOutboundTransport.AgwConnection.DisposeAsync now sends a
'd' (disconnect) frame so BPQ tears down the AX.25 session promptly
rather than leaving it for the link's idle timeout. Best-effort with
a 2s budget.
Plan.md B1+B2+B3 marked done; B4 (resolver consulting DbDiscoveredPeer)
and B5 (MeshCore-inspired flood-and-learn) remain.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
M0LTE
added a commit
that referenced
this pull request
Jul 1, 2026
The shared zstd dictionary is baked from a fixed corpus, so today every node derives byte-identical bytes - but nothing carried the dictionary version on the wire. The moment two nodes ran different dictionaries (a retrained corpus), the receiver would feed zstd a mismatched dictionary and either throw or, worse, silently produce corrupt bytes. This makes the scheme versioned and safe. - Wire: a compressed frame now carries the dictionary version in header byte1 ([flags][version][fragment]); uncompressed frames are unchanged ([flags][fragment]). Worst case 2 + 160 = 162 B, inside the 165 B firmware limit, so no extra fragmentation. - DappsCompression: a version -> dictionary registry that retains superseded versions (so a node can still decompress peers that haven't upgraded), CurrentDictionaryVersion for outbound, version-keyed Decompress, and IsKnownVersion. Retraining = add a registry entry + bump the current version; never mutate an existing version's bytes. - Receiver: a fully-reassembled frame whose dictionary version we don't hold is returned as Kind.Unsupported and dropped (never decompressed with the wrong dictionary) - MeshCoreInbound logs it so an operator sees the version gap, and doesn't ACK, so the sender keeps trying. A mixed-version fleet degrades to "can't read newer peers yet" instead of corrupting payloads. Validated on air (radio1/radio2, compression on): 6/6 delivered, 0% loss, reliability confirmed - compressed frames with the version byte round-trip on real hardware. Tests: MeshCoreCompressionVersionTests (version stamped, unknown-version dropped-not-delivered, round-trip, Decompress/IsKnownVersion); existing 20 transport tests still green. Claude-Session: https://claude.ai/code/session_01KLbwvhE2cKCe8WPZNg8k17 Co-authored-by: Claude Opus 4.8 (1M context) <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.
The plan sketched an `IDappsUiTransport` interface that was AGW-flavoured. After the A0 backhaul-seam work it makes more sense to lift discovery to the same level — bearer-neutral. Implemented two bearers in this PR; daemon runs whichever are configured.
Seam (`dapps.client/Discovery/`)
```csharp
public interface IDiscoveryBearer : IAsyncDisposable
{
string Name { get; }
Task StartAsync(CancellationToken ct);
Task AnnounceAsync(BeaconFrame beacon, CancellationToken ct);
IAsyncEnumerable ListenAsync(CancellationToken ct);
}
```
`BeaconFrame` carries callsign + hops + ttl + a bearer hint that's stamped by the receiver (never on wire, so a peer can't claim a route it doesn't have).
`BeaconCodec` wire form: `DAPPS v1 callsign=M0LTE-9 hops=0 ttl=300`. KV style rather than positional so future fields slot in without breaking parsers.
Bearers
Daemon
`DiscoveryService` is the hosted background service. Constructs bearers from options inside `ExecuteAsync` (not via a DI factory, because the options-config callback queries the systemoptions table which `DbStartup` hasn't created at the moment hosted services are first materialised).
Per bearer: emits our beacon every `DiscoveryBeaconIntervalSeconds` (default 300), concurrently iterates the listen stream, upserts `DbDiscoveredPeer` rows. Sweeper drops rows whose freshness window has elapsed.
`DbDiscoveredPeer` is keyed on `(Callsign, Bearer)` so the same peer reachable via two bearers occupies two rows; routing-resolver work (B4) can pick.
The dashboard now surfaces discovered peers (callsign + bearer + hops + source + age + ttl).
Smoke test
Two DAPPS instances on the same loopback multicast group (`239.42.42.42:41999`):
```
A: heard N0BBB-9 via udp (hops=0, ttl=60s)
B: heard N0AAA-9 via udp (hops=0, ttl=60s)
```
Within ~50ms of startup. Dashboard rows confirmed.
Drive-by
`AgwOutboundTransport.AgwConnection.DisposeAsync` now sends a `'d'` (disconnect) frame so BPQ tears down the AX.25 session promptly rather than leaving it for the link's idle timeout. Best-effort with a 2s budget.
Test plan
Plan.md
B1+B2+B3 marked done; B4 (resolver consulting `DbDiscoveredPeer`) and B5 (MeshCore-inspired flood-and-learn) remain.
🤖 Generated with Claude Code