User Story #42 » 0003-feat-netcode-on-device-MeshNode-bring-up-construct-s.patch
| androidApp/src/main/java/com/aether/mofe/AetherApp.kt | ||
|---|---|---|
|
// 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
|
||
| androidApp/src/main/java/com/aether/mofe/platform/band/BroadcastBand.kt | ||
|---|---|---|
|
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
|
||
| ... | ... | |
|
// 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) {
|
||
| androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeBringUp.kt | ||
|---|---|---|
|
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<Inet4Address>()
|
||
|
.firstOrNull { it.isSiteLocalAddress && !it.isLoopbackAddress }
|
||
|
?.hostAddress
|
||
|
}.getOrNull()
|
||
| androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodePeerBook.kt | ||
|---|---|---|
|
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()
|
||
|
}
|
||