Skip to content

B1+B2+B3: bearer-neutral peer discovery (AGW UI + UDP multicast) - #23

Merged
M0LTE merged 1 commit into
masterfrom
b1-b3/discovery
Apr 30, 2026
Merged

B1+B2+B3: bearer-neutral peer discovery (AGW UI + UDP multicast)#23
M0LTE merged 1 commit into
masterfrom
b1-b3/discovery

Conversation

@M0LTE

@M0LTE M0LTE commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

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

  • `AgwUiDiscoveryBearer` — dedicated AGW TCP socket; registers our callsign with `X`, enables monitor mode with `m`, sends beacons via `M`, filters incoming `U` frames for DAPPS payloads. Self-echoes filtered by callsign. Off by default; opt in via `DAPPS_AGW_DISCOVERY=true`.
  • `UdpMulticastDiscoveryBearer` — joins a configurable IP multicast group (e.g. `239.42.42.42:1881`). Useful for LAN dev/testing without a BPQ stack — every DAPPS instance on the same subnet sees every other instance's beacons within seconds. Off by default; opt in via `DAPPS_MULTICAST_GROUP`.

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

  • 232 unit tests pass.
  • `BeaconCodecTests` (10) — encode + decode round-trip, trailing-newline tolerance, malformed-input rejection, forward-compat unknown keys.
  • `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; 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.
  • Existing two-instance integration smoke (`TwoInstanceAgwSmokeTests`) flakes locally when sequenced after `TtlForwardingIntegrationTests` (state leak in the shared docker fixture, AX.25 link from prior test still up). Passes in isolation. Watching CI.

Plan.md

B1+B2+B3 marked done; B4 (resolver consulting `DbDiscoveredPeer`) and B5 (MeshCore-inspired flood-and-learn) remain.

🤖 Generated with Claude Code

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
M0LTE merged commit 3bd14cd into master Apr 30, 2026
1 check passed
@M0LTE
M0LTE deleted the b1-b3/discovery branch April 30, 2026 08:17
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>
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