User Story #82 » androidApp-overlay-72.patch
| androidApp/src/main/java/com/aether/mofe/AetherApp.kt | ||
|---|---|---|
|
lateinit var mofeEngineHost: MofeEngineHost
|
||
|
private set
|
||
|
/** Debug black-box telemetry overlay opt-in (#72). Set true ONLY when the `mofe-trace.on`
|
||
|
* sentinel is present — the SAME gate that starts the NDJSON pipeline recorder — so a single
|
||
|
* opt-in turns BOTH on together for a screen-record capture. Read by the UI (AppShell) to draw
|
||
|
* [com.aether.mofe.ui.BlackBoxOverlay]. Default OFF (absent in normal installs). */
|
||
|
@Volatile var blackBoxOverlayEnabled: Boolean = false
|
||
|
private set
|
||
|
/** Offline → server fold for admin role decisions. Inject into
|
||
|
* MemberRolesViewModel so an approve/deny made while disconnected from the
|
||
|
* server is signed + queued for the leader uplink to carry on reconnect. */
|
||
| ... | ... | |
|
val dir = getExternalFilesDir("mofe-trace") ?: filesDir
|
||
|
val path = mofeEngineHost.startPipelineRecording(dir, selfId, android.os.Build.MODEL ?: "device")
|
||
|
android.util.Log.i("AetherApp", "MOFE pipeline recording ENABLED → $path")
|
||
|
// The SAME sentinel also arms the on-screen black-box telemetry overlay (#72), so a
|
||
|
// screen recording of this capture carries the raw IMU buffer + engine-clock sync key
|
||
|
// the NDJSON trace does NOT log — decodable offline and aligned to the trace by tEngineMicros.
|
||
|
blackBoxOverlayEnabled = true
|
||
|
android.util.Log.i("AetherApp", "MOFE black-box overlay ENABLED (screen-record capture)")
|
||
|
}
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt | ||
|---|---|---|
|
)
|
||
|
private val mutex = Mutex()
|
||
|
// The engine's monotonic clock. HOISTED to a field (was inline in the builder below) so the
|
||
|
// black-box diagnostic overlay can read the SAME timeline the engine stamps into the pipeline
|
||
|
// recorder — MofePipelineRecorder's `t` values are `clock.now().microseconds` (see
|
||
|
// MultiObserverFusionEngine.processRanging). Embedding that shared key per overlay frame is
|
||
|
// what lets an offline decoder align a screen recording to the on-device NDJSON trace (#72).
|
||
|
private val engineClock = AndroidPlatformClock()
|
||
|
// SINGLE-THREADED engine dispatcher: MofeRuntime feeds the engine from several
|
||
|
// collectors (local UWB ranging, peer-anchor observations, IMU, maintenance). The
|
||
|
// engine is NOT concurrency-safe (its ObservationCollector mutates a LinkedHashMap
|
||
| ... | ... | |
|
val mgr = frameManager ?: CoordinateFrameManager(MultilaterationSolver()).also { frameManager = it }
|
||
|
val eng = engine ?: MofeBuilder()
|
||
|
.frameManager(mgr)
|
||
|
.clock(AndroidPlatformClock())
|
||
|
.clock(engineClock)
|
||
|
.telemetryEmitter(HubForwardingTelemetryEmitter())
|
||
|
// Real-device retention window: intermittent per-link UWB means the 4
|
||
|
// anchors rarely all range a target within 500 ms (the sim default), so
|
||
| ... | ... | |
|
@Volatile private var _selfPose: FusedState? = null
|
||
|
fun latestSelfPose(): FusedState? = _selfPose
|
||
|
/** Engine clock "now" in microseconds — the SAME monotonic timeline MofePipelineRecorder
|
||
|
* stamps its `t` values with (both come from [engineClock].now()). The black-box overlay
|
||
|
* embeds this as each frame's `tEngineMicros` sync key, so an offline decoder can align a
|
||
|
* screen recording to the NDJSON trace. Read lock-free (the clock is monotonic + stateless). */
|
||
|
fun engineNowMicros(): Long = engineClock.now().microseconds
|
||
|
// ── F2: view-layer self-yaw drift reconcile ────────────────────────────────
|
||
|
// A magnetometer-less phone has no absolute yaw reference, so its gyro-integrated heading
|
||
|
// drifts and the FPV view slides under motion. This walks the drift out SLOWLY at the VIEW
|
||
| androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt | ||
|---|---|---|
|
.align(Alignment.BottomCenter)
|
||
|
.padding(bottom = 24.dp, start = 8.dp, end = 8.dp),
|
||
|
)
|
||
|
// Debug black-box telemetry overlay (#72): a full-screen, machine-readable data grid
|
||
|
// (raw IMU buffer + engine-clock sync key + frameIdx + fused pose) for SCREEN-RECORD
|
||
|
// capture. Gated by the SAME `mofe-trace.on` sentinel that starts the NDJSON pipeline
|
||
|
// recorder, so one opt-in arms both together; default OFF in normal installs. Drawn last
|
||
|
// so it covers the scene (an opaque data channel) while the recording runs.
|
||
|
if (app.blackBoxOverlayEnabled) {
|
||
|
BlackBoxOverlay(viewModel = hud, modifier = Modifier.fillMaxSize())
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/ui/BlackBoxOverlay.kt | ||
|---|---|---|
|
package com.aether.mofe.ui
|
||
|
import androidx.compose.foundation.Canvas
|
||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||
|
import androidx.compose.runtime.Composable
|
||
|
import androidx.compose.runtime.LaunchedEffect
|
||
|
import androidx.compose.runtime.getValue
|
||
|
import androidx.compose.runtime.mutableStateOf
|
||
|
import androidx.compose.runtime.remember
|
||
|
import androidx.compose.runtime.setValue
|
||
|
import androidx.compose.runtime.withFrameNanos
|
||
|
import androidx.compose.ui.Modifier
|
||
|
import androidx.compose.ui.geometry.Offset
|
||
|
import androidx.compose.ui.geometry.Size
|
||
|
import androidx.compose.ui.graphics.Color
|
||
|
import com.aether.mofe.viewmodel.HudViewModel
|
||
|
/**
|
||
|
* Debug-gated, full-screen "black-box" telemetry overlay (#72) — turns the phone screen into a
|
||
|
* machine-readable data channel so a plain SCREEN RECORDING of a field walk carries MORE than the
|
||
|
* on-device NDJSON trace: notably the RAW IMU buffer (which [com.aether.mofe.platform.MofePipelineRecorder]
|
||
|
* does not log), plus a per-frame `tEngineMicros` sync key (the SAME engine clock the recorder stamps)
|
||
|
* and a monotonic `frameIdx`, so an offline decoder can align the video to the NDJSON. The fused self
|
||
|
* pose rides along as a cheap cross-check.
|
||
|
*
|
||
|
* Not human-readable by design — its only consumer is the agent's offline decoder ([BlackBoxCodec]).
|
||
|
* It is drawn LAST over the whole app (opaque), effectively replacing the visible scene while a
|
||
|
* capture runs, so no scene pixels bleed into the data cells. Gated by the SAME `mofe-trace.on`
|
||
|
* sentinel that starts the NDJSON recorder (see [HudViewModel.blackBoxOverlayEnabled]); default OFF.
|
||
|
*
|
||
|
* Drawing (matches the transport tuning in [com.aether.mofe.diag.BlackBoxCodec]):
|
||
|
* - The [HudViewModel.BB_COLS]×[HudViewModel.BB_ROWS] luma grid fills the screen inside a thin
|
||
|
* uniform-black QUIET border, each cell a solid black(0)/white(255) block (~13–18 px @1080p).
|
||
|
* - Three solid white CORNER FIDUCIALS (top-left, top-right, bottom-left — bottom-right left empty
|
||
|
* so orientation is unambiguous) sit in the quiet margin, one fiducial-gap outside the data grid,
|
||
|
* so the decoder can register the grid and sample each cell centre.
|
||
|
* - Redrawn every DISPLAY frame via [withFrameNanos] (the app's redraw convention, cf. Mesh3DCanvas),
|
||
|
* which is also when the frame is built — so `frameIdx` advances and the IMU ring drains per frame.
|
||
|
*/
|
||
|
@Composable
|
||
|
fun BlackBoxOverlay(viewModel: HudViewModel, modifier: Modifier = Modifier) {
|
||
|
val cols = HudViewModel.BB_COLS
|
||
|
val rows = HudViewModel.BB_ROWS
|
||
|
// Rebuild + encode one frame per display frame; the luma state write drives the Canvas redraw.
|
||
|
var luma by remember { mutableStateOf(IntArray(cols * rows)) }
|
||
|
LaunchedEffect(Unit) {
|
||
|
while (true) {
|
||
|
withFrameNanos { /* pace to the display; the work is the encode below */ }
|
||
|
luma = viewModel.nextBlackBoxLuma()
|
||
|
}
|
||
|
}
|
||
|
Canvas(modifier = modifier.fillMaxSize()) {
|
||
|
val w = size.width
|
||
|
val h = size.height
|
||
|
// Quiet border: a uniform black margin so the grid never touches the screen edge (the decoder
|
||
|
// needs a clean boundary). ~4% of the short side leaves room for the corner fiducials too.
|
||
|
val margin = minOf(w, h) * 0.04f
|
||
|
// Solid black background = the quiet zone AND every 0-cell (we only paint the white cells).
|
||
|
drawRect(Color.Black, topLeft = Offset.Zero, size = Size(w, h))
|
||
|
val gridLeft = margin
|
||
|
val gridTop = margin
|
||
|
val gridW = w - 2f * margin
|
||
|
val gridH = h - 2f * margin
|
||
|
if (gridW <= 0f || gridH <= 0f) return@Canvas
|
||
|
val cellW = gridW / cols
|
||
|
val cellH = gridH / rows
|
||
|
// Data cells: paint only the white (255) cells; 0-cells are the black background already.
|
||
|
val g = luma
|
||
|
if (g.size == cols * rows) {
|
||
|
var r = 0
|
||
|
while (r < rows) {
|
||
|
val top = gridTop + r * cellH
|
||
|
val base = r * cols
|
||
|
var c = 0
|
||
|
while (c < cols) {
|
||
|
if (g[base + c] != 0) {
|
||
|
drawRect(
|
||
|
Color.White,
|
||
|
topLeft = Offset(gridLeft + c * cellW, top),
|
||
|
size = Size(cellW, cellH),
|
||
|
)
|
||
|
}
|
||
|
c++
|
||
|
}
|
||
|
r++
|
||
|
}
|
||
|
}
|
||
|
// Three corner fiducials as solid white squares in the quiet margin, one gap outside the grid.
|
||
|
val fid = margin * 0.72f
|
||
|
val gap = margin * 0.14f
|
||
|
// Top-left (its bottom-right corner points at the grid's top-left corner):
|
||
|
drawRect(Color.White, topLeft = Offset(gridLeft - gap - fid, gridTop - gap - fid), size = Size(fid, fid))
|
||
|
// Top-right (bottom-left corner points at the grid's top-right corner):
|
||
|
drawRect(Color.White, topLeft = Offset(gridLeft + gridW + gap, gridTop - gap - fid), size = Size(fid, fid))
|
||
|
// Bottom-left (top-right corner points at the grid's bottom-left corner):
|
||
|
drawRect(Color.White, topLeft = Offset(gridLeft - gap - fid, gridTop + gridH + gap), size = Size(fid, fid))
|
||
|
}
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt | ||
|---|---|---|
|
import androidx.lifecycle.viewModelScope
|
||
|
import com.aether.mofe.AetherApp
|
||
|
import com.aether.mofe.data.*
|
||
|
import com.aether.mofe.diag.BbImu
|
||
|
import com.aether.mofe.diag.BbPose
|
||
|
import com.aether.mofe.diag.BlackBoxCodec
|
||
|
import com.aether.mofe.diag.BlackBoxState
|
||
|
import com.aether.mofe.engine.netcode.MeshAimTracker
|
||
|
import com.aether.mofe.engine.netcode.NetcodeSession
|
||
|
import com.aether.mofe.engine.netcode.RenderClock
|
||
| ... | ... | |
|
import kotlinx.coroutines.flow.*
|
||
|
import kotlinx.coroutines.launch
|
||
|
import java.util.UUID
|
||
|
import kotlin.math.roundToInt
|
||
|
class HudViewModel(application: Application) : AndroidViewModel(application) {
|
||
| ... | ... | |
|
private val _imuSample = MutableStateFlow<ImuSample?>(null)
|
||
|
val imuSample: StateFlow<ImuSample?> = _imuSample.asStateFlow()
|
||
|
// ── Black-box telemetry overlay (#72, debug screen-record capture) ─────────────────────────
|
||
|
// A raw-IMU ring buffer TEED off the SAME [imuFlow] the HUD already collects (a second
|
||
|
// SensorManager listener on the same physical sensors — it never touches the engine's own
|
||
|
// IMU→EKF path). Drained once per rendered overlay frame into a BlackBoxState the overlay
|
||
|
// encodes and draws, so a plain screen recording carries the raw IMU stream the NDJSON
|
||
|
// recorder does NOT log, keyed to the engine clock for offline alignment. See BlackBoxOverlay.
|
||
|
private val bbImuBuf = ArrayDeque<BbImu>() // guarded by bbImuLock
|
||
|
private val bbImuLock = Any()
|
||
|
private var bbLastImuMicros = 0L // inter-sample dt source (persists across frames)
|
||
|
private var bbFrameIdx = 0 // monotonic render-frame index
|
||
|
/** Mirrors the pipeline-recorder opt-in: true only when the `mofe-trace.on` sentinel is present
|
||
|
* (see [AetherApp.blackBoxOverlayEnabled]), so a capture turns on BOTH the NDJSON recorder and
|
||
|
* this overlay together. Default OFF. */
|
||
|
val blackBoxOverlayEnabled: Boolean get() = app.blackBoxOverlayEnabled
|
||
|
/** Live self-attitude "spin" diagnostic (see MofeEngineHost.SelfAttitudeDiag),
|
||
|
* surfaced for the on-screen debug overlay so a screenshot captures the exact
|
||
|
* yaw / drift-rate / rest-gate / gyro-bias state at a moment. */
|
||
| ... | ... | |
|
viewModelScope.launch {
|
||
|
imuService.imuFlow()
|
||
|
.catch { /* sensor absent — handled via hasGyroscope */ }
|
||
|
.collect { sample -> _imuSample.value = sample }
|
||
|
.collect { sample ->
|
||
|
_imuSample.value = sample
|
||
|
// Tee into the black-box ring buffer only when the overlay is armed, so normal
|
||
|
// installs (sentinel absent) pay nothing beyond the existing _imuSample update.
|
||
|
if (app.blackBoxOverlayEnabled) captureBlackBoxImu(sample)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
/** Append one raw IMU sample to the black-box ring buffer, quantized to [BbImu] scales.
|
||
|
* `dtMicros` is the gap since the PREVIOUS sample (continuous across frame boundaries, so the
|
||
|
* offline decoder can reconstruct the true sample cadence). Runs on the IMU collector coroutine. */
|
||
|
private fun captureBlackBoxImu(s: ImuSample) {
|
||
|
val tMicros = s.timestamp.microseconds
|
||
|
val dt = if (bbLastImuMicros == 0L) 0 else (tMicros - bbLastImuMicros).coerceIn(0L, 65_535L).toInt()
|
||
|
bbLastImuMicros = tMicros
|
||
|
val a = s.acceleration; val g = s.angularVelocity
|
||
|
val sample = BbImu(
|
||
|
dtMicros = dt,
|
||
|
axMilli = qMilli(a.x), ayMilli = qMilli(a.y), azMilli = qMilli(a.z), // m/s²·1000
|
||
|
gxMilli = qMilli(g.x), gyMilli = qMilli(g.y), gzMilli = qMilli(g.z), // rad/s·1000
|
||
|
)
|
||
|
synchronized(bbImuLock) {
|
||
|
bbImuBuf.addLast(sample)
|
||
|
// Bound memory if a drain is delayed (or the overlay armed but not yet drawing): keep newest.
|
||
|
while (bbImuBuf.size > BB_IMU_BUFFER_CAP) bbImuBuf.removeFirst()
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Build + encode ONE black-box telemetry frame for the overlay to draw. Carries what the NDJSON
|
||
|
* trace does NOT: the raw-IMU buffer drained since the previous frame, plus the engine-clock sync
|
||
|
* key ([MofeEngineHost.engineNowMicros] — the SAME timeline MofePipelineRecorder stamps), a
|
||
|
* monotonic `frameIdx`, and the fused self pose (cheap cross-check). Ranges are left EMPTY — the
|
||
|
* NDJSON trace is authoritative for ranges/solve. Returns a row-major 0/255 luma grid
|
||
|
* ([BB_COLS]×[BB_ROWS]) encoded at [BB_REPEAT]-way redundancy. Called once per rendered frame.
|
||
|
*/
|
||
|
fun nextBlackBoxLuma(): IntArray {
|
||
|
val idx = bbFrameIdx++
|
||
|
val tEngine = app.mofeEngineHost.engineNowMicros()
|
||
|
val pose = app.mofeEngineHost.latestSelfPose()
|
||
|
?.takeIf { it.position.x.isFinite() && it.position.y.isFinite() && it.position.z.isFinite() }
|
||
|
?.let { st ->
|
||
|
val q = st.orientation
|
||
|
BbPose(
|
||
|
xMm = qMilli(st.position.x), yMm = qMilli(st.position.y), zMm = qMilli(st.position.z),
|
||
|
qwE4 = qE4(q.w), qxE4 = qE4(q.x), qyE4 = qE4(q.y), qzE4 = qE4(q.z),
|
||
|
)
|
||
|
}
|
||
|
val drained = synchronized(bbImuLock) { val copy = ArrayList(bbImuBuf); bbImuBuf.clear(); copy }
|
||
|
// Cap the IMU block to what one frame's payload can hold (keeping the NEWEST samples, closest
|
||
|
// to this frame's tEngineMicros) so BlackBoxCodec.encodeToLuma never exceeds grid capacity. At
|
||
|
// normal frame rates (IMU ~100–200 Hz, video ~30–60 fps ⇒ a handful/frame) nothing is dropped.
|
||
|
val cap = BlackBoxCodec.capacityBytes(BB_COLS, BB_ROWS, BB_REPEAT)
|
||
|
val fixed = BB_HEADER_BYTES + 1 + (if (pose != null) BB_POSE_BYTES else 0) // +1 = imuCount byte
|
||
|
val maxImu = ((cap - fixed) / BB_IMU_BYTES).coerceAtLeast(0)
|
||
|
val imu = if (drained.size > maxImu) drained.subList(drained.size - maxImu, drained.size) else drained
|
||
|
val payload = BlackBoxState.encode(
|
||
|
BlackBoxState(frameIdx = idx, tEngineMicros = tEngine, ranges = emptyList(), imu = imu, pose = pose)
|
||
|
)
|
||
|
return BlackBoxCodec.encodeToLuma(payload, BB_COLS, BB_ROWS, BB_REPEAT)
|
||
|
}
|
||
|
/** m/s² or rad/s → ·1000 i16 (BbImu), or m → mm i16 (BbPose position); NaN/∞ → 0, clamped to i16. */
|
||
|
private fun qMilli(v: Double): Int = if (!v.isFinite()) 0 else (v * 1000.0).roundToInt().coerceIn(-32768, 32767)
|
||
|
/** Unit-quaternion component → ·10000 i16 (BbPose); NaN/∞ → 0, clamped to i16. */
|
||
|
private fun qE4(v: Double): Int = if (!v.isFinite()) 0 else (v * 10000.0).roundToInt().coerceIn(-32768, 32767)
|
||
|
fun selectNode(node: SelectedNode?) { _selectedNode.value = node }
|
||
|
fun clearSelection() { _selectedNode.value = null }
|
||
| ... | ... | |
|
val current = predicates.value.find { it.id == predicateId } ?: return
|
||
|
repo.publishPredicate(current.copy(isActive = !current.isActive).toBandRecord())
|
||
|
}
|
||
|
companion object {
|
||
|
// Field black-box grid: 80×130 cells @5-way repetition ECC (~251 B/frame). Read by
|
||
|
// BlackBoxOverlay to lay out the drawn grid; MUST stay in sync with the encode call above.
|
||
|
const val BB_COLS = 80
|
||
|
const val BB_REPEAT = 5
|
||
|
const val BB_ROWS = 130
|
||
|
private const val BB_IMU_BUFFER_CAP = 256 // ring cap between drains (memory bound)
|
||
|
// BlackBoxSchema wire sizes (big-endian) for the per-frame IMU capacity budget:
|
||
|
private const val BB_HEADER_BYTES = 15 // schemaVer1 + frameIdx4 + tEngineMicros8 + flags1 + rangeCount1
|
||
|
private const val BB_POSE_BYTES = 14 // i16 × 7 (xyz + wxyz)
|
||
|
private const val BB_IMU_BYTES = 14 // u16 dt + i16 × 6 (accel xyz + gyro xyz)
|
||
|
}
|
||
|
}
|
||
|
data class PredicateEventLog(val timestampMs: Long, val message: String)
|
||