From 353c9e1ccba0796cf0617a7aea62ced2a666bc3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:09:37 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(netcode):=20on-device=20MeshNode=20bri?= =?UTF-8?q?ng-up=20=E2=80=94=20construct=20+=20start=20from=20band-learned?= =?UTF-8?q?=20peers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the netcode go-live wiring. All inputs now exist and satisfy the netcode interfaces: • NetcodePeerBook — the band's UDP receive loop feeds each peer's source IP (+ root flag) into the pure PeerAddressBook, mapped to the netcode control port; a member reads leaderAddress() to dial startAsMember. Owns one clock so observe()/leaderAddress() share a timebase. • siteLocalIpv4() — this device's LAN IPv4 for the NodeHello datagramHost. • NetcodeBringUp — builds MeshNodeDependencies (live engine, cached trust material, AndroidMeshCryptoProvider/AndroidRaftPersistence/AndroidPlatformClock, the band-learned leader) and MeshNode.build(...).start() by role. Gated by the MeshTrustMaterial readiness check. Idempotent + fail-safe: re-attempted on each band roster update (join-time ordering — a member can't construct until it has heard the root), and when trust isn't ready / no leader is known / no LAN address, the netcode stays down and the band is unaffected. Wired at two seams: BroadcastBand receive loop (feed peers) and AetherApp's existing roster collector (idempotent retry). androidApp is not compilable in the headless env — delivered as a reviewed wiring patch; needs an on-device compile + two-device run to validate join-time retry, clock convergence, and FusedStateSample exchange. The verifiable cores (PeerAddressBook, MeshTrustMaterial) are jvmTest-green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../main/java/com/aether/mofe/AetherApp.kt | 6 + .../mofe/platform/band/BroadcastBand.kt | 12 ++ .../mofe/platform/netcode/NetcodeBringUp.kt | 142 ++++++++++++++++++ .../mofe/platform/netcode/NetcodePeerBook.kt | 41 +++++ 4 files changed, 201 insertions(+) create mode 100644 androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeBringUp.kt create mode 100644 androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodePeerBook.kt diff --git a/androidApp/src/main/java/com/aether/mofe/AetherApp.kt b/androidApp/src/main/java/com/aether/mofe/AetherApp.kt index 7e6bf6f..e91d0ef 100644 --- a/androidApp/src/main/java/com/aether/mofe/AetherApp.kt +++ b/androidApp/src/main/java/com/aether/mofe/AetherApp.kt @@ -194,6 +194,12 @@ class AetherApp : Application() { // engines share one frame. Works offline (no server register needed); // without it the engine rejects every measurement ("Mesh not initialized"). meshRepository.currentAnchorId()?.let { mofeEngineHost.setRootAnchor(DeviceId(it)) } + // Netcode go-live: (re)attempt peer-to-peer netcode bring-up as the roster/root and + // the cached trust material become available. Idempotent, and fails safe — when trust + // isn't ready or no leader is known yet, the netcode stays down and the band is + // unaffected. The band feeds NetcodePeerBook (peer source IPs) from its receive loop. + com.aether.mofe.platform.netcode.NetcodeBringUp.maybeStart( + this@AetherApp, mofeEngineHost, deviceIdentity, meshRepository, appScope) // REAPING: a node that dropped off the roster (dormant timeout or an // instant leave — see MeshRepository.reapDormantNodes / deactivateMesh) // must stop consuming engine cycles and telemetry. Untrack it from the diff --git a/androidApp/src/main/java/com/aether/mofe/platform/band/BroadcastBand.kt b/androidApp/src/main/java/com/aether/mofe/platform/band/BroadcastBand.kt index b1292a5..35a5619 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/band/BroadcastBand.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/band/BroadcastBand.kt @@ -6,6 +6,7 @@ import android.net.NetworkCapabilities import android.net.wifi.WifiManager import android.util.Base64 import android.util.Log +import com.aether.mofe.platform.netcode.NetcodePeerBook import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -173,6 +174,17 @@ class BroadcastBand( // meshId filter; everything else is scoped to our own mesh. if (frame is BandFrame.MeshMetaPut) { onAdvert(frame); continue } if (frame.meshId != meshId()) continue + // Netcode bootstrap: learn each peer's source IP (+ root flag) from the datagram + // the band already delivered — the netcode's peer-discovery seam. The band never + // carried IPs in-payload, but every UDP packet has one; NetcodePeerBook maps it to + // the netcode control port so a member can dial the leader (startAsMember). + if (frame is BandFrame.NodePut) { + NetcodePeerBook.observe( + frame.node.nodeId, + pkt.address?.hostAddress.orEmpty(), + isLeader = "ROOT" in frame.node.meshRoles, + ) + } onFrame(s, frame) } } catch (_: CancellationException) { diff --git a/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeBringUp.kt b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeBringUp.kt new file mode 100644 index 0000000..3bdae3f --- /dev/null +++ b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeBringUp.kt @@ -0,0 +1,142 @@ +package com.aether.mofe.platform.netcode + +import android.content.Context +import android.util.Log +import com.aether.mofe.data.MeshRepository +import com.aether.mofe.messaging.MeshNode +import com.aether.mofe.messaging.MeshNodeDependencies +import com.aether.mofe.messaging.MeshNodeRole +import com.aether.mofe.messaging.MeshTrustMaterial +import com.aether.mofe.messaging.MeshTrustReadiness +import com.aether.mofe.messaging.MessagingConfig +import com.aether.mofe.model.DeviceId +import com.aether.mofe.platform.AndroidMeshCryptoProvider +import com.aether.mofe.platform.AndroidPlatformClock +import com.aether.mofe.platform.AndroidRaftPersistence +import com.aether.mofe.platform.MofeEngineHost +import com.aether.mofe.platform.identity.DeviceIdentityProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.Inet4Address +import java.net.NetworkInterface + +/** + * Constructs and starts the peer-to-peer netcode `MeshNode` on-device — the final go-live step. Every + * input now exists: the live engine ([MofeEngineHost.currentEngine]), the trust material (pinned CA + * key + this device's credential, cached from the last online enrollment — see [NetcodeTrust] and + * `MeshRepository`), the bootstrap leader address ([NetcodePeerBook], learned from band source IPs), + * and the Android platform impls (`AndroidMeshCryptoProvider`/`AndroidRaftPersistence`/ + * `AndroidPlatformClock`, all of which satisfy the netcode interfaces). + * + * [maybeStart] is IDEMPOTENT and safe to call repeatedly: it no-ops once the node is up, and until + * then re-evaluates readiness on each call, so the app calls it after engine start AND on every band + * roster update. That retry is what handles join-time ordering — a non-root member cannot construct + * until the band has heard the root's address, and that sighting usually arrives AFTER startup. It + * fails SAFE: when trust isn't [MeshTrustReadiness.Ready], no leader is known, or the device has no + * LAN address, the netcode stays down and the band keeps operating exactly as before. + * + * NOTE: androidApp is not compilable in the headless authoring env — this is a reviewed wiring patch. + * The join-time retry and the single-clock bootstrap roster want an on-device compile + run to + * validate end to end (two devices exchanging `FusedStateSample`, clock offset/RTT converging). + */ +object NetcodeBringUp { + private const val TAG = "NetcodeBringUp" + + @Volatile private var node: MeshNode? = null + + /** Whether the netcode node is currently up (for diagnostics/UI). */ + val isUp: Boolean get() = node != null + + fun maybeStart( + context: Context, + engineHost: MofeEngineHost, + deviceIdentity: DeviceIdentityProvider, + repo: MeshRepository, + scope: CoroutineScope, + ) { + if (node != null) return + val engine = engineHost.currentEngine() ?: return + val selfDeviceId = deviceIdentity.deviceIdV5() + val credentialJson = repo.gatewayCredential.value?.toString() + + // Gate on cached trust material (cache from last enrollment): CA key pinned, credential + // present, matching THIS device + the active mesh, and unexpired. Offline we can't re-fetch, + // so a mismatch/expiry here means "re-enroll online", not "come up". + val readiness = MeshTrustMaterial.evaluate( + caKeyPresent = NetcodeTrust.meshCaPublicKey() != null, + credentialJson = credentialJson, + selfDeviceId = selfDeviceId, + selfEd25519PubB64 = deviceIdentity.ed25519PubB64, + activeMeshId = repo.currentMeshId ?: "", + nowMicros = System.currentTimeMillis() * 1_000L, // wall-clock epoch micros (credential.notAfter) + ) + if (readiness !is MeshTrustReadiness.Ready) { + Log.i(TAG, "netcode deferred: trust not ready ($readiness)") + return + } + val identity = NetcodeTrust.deviceIdentity(deviceIdentity, credentialJson!!) ?: run { + Log.w(TAG, "netcode deferred: could not build DeviceIdentity (no raw seed?)") + return + } + val caKey = NetcodeTrust.meshCaPublicKey() ?: return + + val role = resolveRole(repo, selfDeviceId) + val leader = if (role == MeshNodeRole.ROOT_ANCHOR) null else NetcodePeerBook.leaderAddress() + if (role != MeshNodeRole.ROOT_ANCHOR && leader == null) { + Log.i(TAG, "netcode deferred: leader address not learned from the band yet") + return + } + val selfHost = siteLocalIpv4() ?: run { + Log.w(TAG, "netcode deferred: no site-local IPv4 (no Wi-Fi/LAN?)") + return + } + + val deps = MeshNodeDependencies( + selfId = DeviceId(selfDeviceId), + role = role, + engine = engine, + clock = AndroidPlatformClock(), + scope = scope, + ioDispatcher = Dispatchers.IO, + crypto = AndroidMeshCryptoProvider(), + identity = identity, + meshCaPublicKey = caKey, + persistence = AndroidRaftPersistence(context.applicationContext), + selfHost = selfHost, + leaderAddress = leader, + uplink = null, // gateway sync stays on GatewayUplinkController + config = MessagingConfig(), + ) + val built = MeshNode.build(deps) + node = built + Log.i(TAG, "netcode up: role=$role selfHost=$selfHost leader=$leader") + scope.launch { + runCatching { built.start() }.onFailure { + Log.e(TAG, "netcode start failed — will retry on next band update", it) + node = null // allow a later maybeStart to retry + } + } + } + + /** ROOT_ANCHOR if this device is the mesh root, else ANCHOR if it holds the anchor role, else LEARNER. */ + private fun resolveRole(repo: MeshRepository, selfId: String): MeshNodeRole = when { + repo.currentAnchorId() == selfId -> MeshNodeRole.ROOT_ANCHOR + repo.isAnchorRole(selfId) -> MeshNodeRole.ANCHOR + else -> MeshNodeRole.LEARNER + } +} + +/** + * This device's site-local IPv4 (192.168.x / 10.x / 172.16–31.x) for `NodeHello`'s `datagramHost` + * advertisement, or null if none (no Wi-Fi/LAN). Skips loopback and down interfaces and prefers a + * site-local address — a link-local (169.254.x) or public address would not be reachable by LAN peers. + */ +fun siteLocalIpv4(): String? = runCatching { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.asSequence() } + .filterIsInstance() + .firstOrNull { it.isSiteLocalAddress && !it.isLoopbackAddress } + ?.hostAddress +}.getOrNull() diff --git a/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodePeerBook.kt b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodePeerBook.kt new file mode 100644 index 0000000..8cbbea3 --- /dev/null +++ b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodePeerBook.kt @@ -0,0 +1,41 @@ +package com.aether.mofe.platform.netcode + +import com.aether.mofe.messaging.MessagingConfig +import com.aether.mofe.messaging.NodeAddress +import com.aether.mofe.messaging.PeerAddressBook +import com.aether.mofe.model.DeviceId +import com.aether.mofe.platform.AndroidPlatformClock + +/** + * Process-wide netcode bootstrap roster. The band's UDP receive loop feeds every peer sighting here — + * the sender's SOURCE IP (which the band never carried in-payload, but every datagram delivers for + * free) plus whether that sender is the mesh root — and the netcode bring-up reads [leaderAddress] to + * open the member control-plane link (`MeshCoordinator.startAsMember`). This bridges the join-time + * chicken-and-egg gap (the band advertises UWB addresses, not IPs); once connected, peers exchange + * their advertised datagram addresses via `NodeHello`/the registry. + * + * Wraps the pure, clock-injected [PeerAddressBook] and owns ONE clock so `observe`/[leaderAddress] + * share a timebase — staleness is meaningless across two different clocks. The port is the netcode + * CONTROL port (47475): `startAsMember(leader, …)` dials the leader's control plane, and the returned + * [NodeAddress] carries that port. + */ +object NetcodePeerBook { + private val clock = AndroidPlatformClock() + private val book = PeerAddressBook(netcodePort = MessagingConfig().controlPort) + + /** A band sighting: [nodeId] was heard from [sourceHost] (its UDP source IP); it is the mesh + * root/leader iff [isLeader] (the band node record carries the ROOT role). Blank hosts ignored. */ + fun observe(nodeId: String, sourceHost: String, isLeader: Boolean) { + if (nodeId.isBlank() || sourceHost.isBlank()) return + book.observe(DeviceId(nodeId), sourceHost, isLeader, clock.now().microseconds) + } + + /** The freshest non-stale root/leader control address, or null — the `startAsMember` bootstrap. */ + fun leaderAddress(): NodeAddress? = book.leaderAddress(clock.now().microseconds) + + /** A specific peer's control address if a fresh sighting exists, else null. */ + fun address(nodeId: String): NodeAddress? = book.address(DeviceId(nodeId), clock.now().microseconds) + + /** Drop all sightings — call on a mesh switch so old-mesh peers can't bootstrap the new mesh. */ + fun clear() = book.clear() +} -- 2.43.0