Give the Fly App a GO 4-style flight HUD

The new flight screen (lib/ui/fly/fly_screen.dart) is built from layers.
Each readout listens to its own telemetry sub-model, so a 10 Hz tick only
repaints what changed. It adds a status bar with a status chip and tip
marquee, a smart battery bar with land/RTH markers, a camera info strip,
an attitude ball, an H/D/VS/HS/VPS strip, and a minimap that swaps places
with the video when tapped. The HUD is laid out in design pixels and
scaled to phone or tablet inside the safe area.

What the HUD shows depends on the product: dual or multi battery bars,
SSD status, a thermal spot reading, a master/slave RC badge, and Wi-Fi
bars in place of RC/HD. The bridges now report the data for these: the
camera's ssd/thermal groups and the RC's master/slave mode.

The old FlightControlPage can still be opened from Settings > About >
Classic flight screen until the P3 safety flows land. The same tab also
gets a toggle for the performance overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 18:54:09 +02:00
1 parent f6bbb1cfee
commit 590f2a6152
26 files changed
+2822 -11

No files matched your search

@@ -13,11 +13,15 @@ import io.flutter.plugin.common.MethodChannel
*
* ```
* links: {kind: ocusync|lightbridge|wifi, uplinkQuality, downlinkQuality}
* rc: {batteryPercent, batteryMah, isCharging}
* rc: {batteryPercent, batteryMah, isCharging, mode, masterSlaveConnected}
* ```
*
* Wi-Fi products (Spark, Mavic Air on Wi-Fi) report quality through their
* `WiFiLink`; the RC links report through `AirLink` itself.
*
* `rc.mode` is the `RCMode` name (`MASTER` / `SLAVE` / `NORMAL` …) and only
* appears on remotes that support dual-operator flying, where the HUD shows a
* master / slave badge.
*/
class AirLinkBridge(private val ctx: BridgeCtx) : SubBridge {
@@ -62,7 +66,15 @@ class AirLinkBridge(private val ctx: BridgeCtx) : SubBridge {
"batteryMah" to s.remainingChargeInmAh,
"isCharging" to s.isCharging,
)
ctx.mainHandler.post { ctx.hub.putSlow("rc", values) }
ctx.mainHandler.post { ctx.hub.patchSlow("rc", values) }
}
}
if (safe { rc.isMasterSlaveModeSupported } == true) {
safe {
rc.setMasterSlaveStateCallback { s ->
val values = mapOf("mode" to s.rcMode?.name, "masterSlaveConnected" to s.isConnected)
ctx.mainHandler.post { ctx.hub.patchSlow("rc", values) }
}
}
}
}
@@ -2,6 +2,7 @@ package com.pilotvault.flyapp
import dji.common.camera.ExposureSettings
import dji.common.camera.ResolutionAndFrameRate
import dji.common.camera.SSDState
import dji.common.camera.SettingsDefinitions
import dji.common.camera.StorageState
import dji.common.camera.SystemState
@@ -26,7 +27,12 @@ import io.flutter.plugin.common.MethodChannel
* availableCaptureCount, availableRecordingSeconds}
* cameraSettings: {aeLock, focusMode, photoAspectRatio, photoFileFormat,
* videoResolution, videoFrameRate, videoFileFormat, colorProfile}
* ssd: {connected, state, capacity, remainingMB, availableRecordingSeconds}
* thermal: {spotTemperature}
* ```
* `ssd` only on cameras with an SSD slot (X5S / X7 on Inspire 2), `thermal` only
* on thermal cameras (XT / XT2 / Mavic 2 Enterprise Dual); the HUD's SSD and
* thermal readouts (plan P2) are hidden on everything else.
* The settings are watched keys, so a change made from the RC's dial or the
* settings sheet shows up without a re-read.
*/
@@ -112,6 +118,22 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
} catch (_: Throwable) {
// A camera without removable or internal storage reports nothing.
}
if (safe { camera.isSSDSupported } == true) {
safe {
camera.setSSDStateCallback { s: SSDState ->
val values = ssdMap(s)
ctx.mainHandler.post { ctx.hub.putSlow("ssd", values) }
}
}
}
if (safe { camera.isThermalCamera } == true) {
safe {
camera.setThermalTemperatureCallback { celsius: Float ->
val t = if (celsius.isNaN()) null else celsius.toDouble()
ctx.mainHandler.post { ctx.hub.putSlow("thermal", mapOf("spotTemperature" to t)) }
}
}
}
val index = try {
camera.index
@@ -156,6 +178,14 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
"availableRecordingSeconds" to s.availableRecordingTimeInSeconds,
)
private fun ssdMap(s: SSDState): Map<String, Any?> = mapOf(
"connected" to s.isConnected,
"state" to s.ssdOperationState?.name,
"capacity" to s.capacity?.name,
"remainingMB" to s.remainingSpaceInMB,
"availableRecordingSeconds" to s.availableRecordingTimeInSeconds,
)
private fun emitSystem(state: SystemState) {
ctx.emit(
mapOf(
@@ -241,4 +271,10 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
null
}
}
private inline fun <T> safe(block: () -> T): T? = try {
block()
} catch (_: Throwable) {
null
}
}
@@ -13,7 +13,8 @@ import android.os.SystemClock
*
* - **fast** sources ([putFast]: flight-controller state, vision) at up to
* [FAST_MS] (10 Hz), only when one of them changed;
* - **slow** sources ([putSlow]: battery, links, RC, storage, camera settings)
* - **slow** sources ([putSlow]: battery, links, RC, storage, SSD, thermal,
* camera settings)
* folded in at most every [SLOW_MS] (2 Hz), so a chatty battery callback
* cannot drive the tick rate on its own.
*