Batch the Fly App's telemetry and split it per subsystem

The GO 4-style HUD needs far more live data than the bridge sent, and
pushing every SDK callback straight to Dart would rebuild the screen
dozens of times a second. This is the telemetry foundation (plan P1).

On the Kotlin side, every source now writes into a TelemetryHub that
emits one telemetry event per tick: flight-controller state, gimbal and
vision at up to 10 Hz, battery, links, RC, storage and camera settings
folded in at up to 2 Hz. Each event carries the full latest snapshot, so
a consumer that drops packets never loses a slow group. New bridges
cover batteries (every pack, cell voltages, warning thresholds), link
quality and RC battery, diagnostics, and obstacle sensing per sensor.
KeyBridge adds generic getKey/setKey/listenKey over KeyManager for the
settings rows to come. The flight controller also gains smart-RTH
confirmation and simulator start/stop.

On the Dart side, FlightModel now owns one ChangeNotifier per subsystem
(aircraft, battery, links, camera, gimbal, vision, alerts, mission), so
a HUD widget can listen to just the one it draws. TelemetryReducer
routes events into them, only notifies groups that changed, and gives
FlightModel one notification per tick. Flat getters keep the existing
screens working. Diagnostics become graded, de-duplicated alerts.
Settings > About > Telemetry inspector shows every field live and can
start the simulator.

Battery no longer arrives as its own event, so the API Server reads the
percentage from the telemetry snapshot (the old event is still accepted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 18:34:56 +02:00
1 parent a8483ecf45
commit f6bbb1cfee
28 files changed
+2716 -187

No files matched your search

@@ -0,0 +1,76 @@
package com.pilotvault.flyapp
import dji.common.airlink.SignalQualityCallback
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The signal cluster of the HUD's top bar: uplink (RC → aircraft) and downlink
* (video → RC) quality 0–100, and the remote controller's own battery. Published
* as the `links` and `rc` slow groups of the [TelemetryHub]:
*
* ```
* links: {kind: ocusync|lightbridge|wifi, uplinkQuality, downlinkQuality}
* rc: {batteryPercent, batteryMah, isCharging}
* ```
*
* Wi-Fi products (Spark, Mavic Air on Wi-Fi) report quality through their
* `WiFiLink`; the RC links report through `AirLink` itself.
*/
class AirLinkBridge(private val ctx: BridgeCtx) : SubBridge {
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
override fun bind(product: BaseProduct?) {
if (product == null) return
val link = try {
product.airLink
} catch (_: Throwable) {
null
}
if (link != null) {
val ocusync = safe { link.isOcuSyncLinkSupported } == true
val lightbridge = safe { link.isLightbridgeLinkSupported } == true
val wifi = !ocusync && !lightbridge && safe { link.isWiFiLinkSupported } == true
val kind = when {
ocusync -> "ocusync"
lightbridge -> "lightbridge"
wifi -> "wifi"
else -> null
}
ctx.mainHandler.post { ctx.hub.patchSlow("links", mapOf("kind" to kind)) }
val up = SignalQualityCallback { q -> ctx.mainHandler.post { ctx.hub.patchSlow("links", mapOf("uplinkQuality" to q)) } }
val down = SignalQualityCallback { q -> ctx.mainHandler.post { ctx.hub.patchSlow("links", mapOf("downlinkQuality" to q)) } }
val wifiLink = if (wifi) safe { link.wiFiLink } else null
if (wifiLink != null) {
safe { wifiLink.setUplinkSignalQualityCallback(up) }
safe { wifiLink.setDownlinkSignalQualityCallback(down) }
} else {
safe { link.setUplinkSignalQualityCallback(up) }
safe { link.setDownlinkSignalQualityCallback(down) }
}
}
(product as? Aircraft)?.remoteController?.let { rc ->
safe {
rc.setChargeRemainingCallback { s ->
val values = mapOf(
"batteryPercent" to s.remainingChargeInPercent,
"batteryMah" to s.remainingChargeInmAh,
"isCharging" to s.isCharging,
)
ctx.mainHandler.post { ctx.hub.putSlow("rc", values) }
}
}
}
}
private inline fun <T> safe(block: () -> T): T? = try {
block()
} catch (_: Throwable) {
null
}
}
@@ -0,0 +1,135 @@
package com.pilotvault.flyapp
import dji.common.battery.BatteryState
import dji.keysdk.BatteryKey
import dji.keysdk.FlightControllerKey
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Every battery the product carries (one on most aircraft, two on an Inspire 2 or
* M200, six on an M600) plus the flight controller's low / serious-low warning
* thresholds, published as the `battery` slow group of the [TelemetryHub]:
*
* ```
* battery: {
* percent, voltage, temperature, // the lowest pack — what the HUD shows
* lowWarningThreshold, seriousLowWarningThreshold,
* packs: [{index, percent, voltage, current, temperature, chargeRemainingMah,
* fullChargeCapacityMah, designCapacityMah, discharges, lifetimePercent,
* isCharging, cellVoltages, cellVoltageLevel, connectionState,
* selfHeatingState}]
* }
* ```
*
* Volts throughout (the SDK reports mV). Cell voltages come from the
* `CELL_VOLTAGES` key, which pushes on change; non-smart batteries never answer it.
*/
class BatteryBridge(private val ctx: BridgeCtx) : SubBridge {
private val keys = KeyWatch(ctx)
/** Main thread only. */
private val packs = sortedMapOf<Int, Map<String, Any?>>()
private val cells = HashMap<Int, List<Double>>()
private var lowThreshold: Int? = null
private var seriousLowThreshold: Int? = null
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
override fun bind(product: BaseProduct?) {
ctx.mainHandler.post { keys.clear() }
if (product !is Aircraft) return
val batteries = try {
product.batteries.orEmpty().filterNotNull()
} catch (_: Throwable) {
emptyList()
}.ifEmpty { listOfNotNull(product.battery) }
batteries.forEachIndexed { position, battery ->
val index = try {
battery.index
} catch (_: Throwable) {
position
}
battery.setStateCallback { state: BatteryState ->
val pack = packMap(index, state)
ctx.mainHandler.post {
packs[index] = pack
publish()
}
}
}
ctx.mainHandler.post {
for (position in batteries.indices) {
val index = try {
batteries[position].index
} catch (_: Throwable) {
position
}
keys.watch(BatteryKey.create(BatteryKey.CELL_VOLTAGES, index)) { v ->
val mv = (v as? Array<*>)?.mapNotNull { (it as? Number)?.toDouble() }
if (mv == null) cells.remove(index) else cells[index] = mv.map { it / 1000.0 }
publish()
}
}
keys.watch(FlightControllerKey.create(FlightControllerKey.LOW_BATTERY_WARNING_THRESHOLD)) { v ->
lowThreshold = (v as? Number)?.toInt()
publish()
}
keys.watch(FlightControllerKey.create(FlightControllerKey.SERIOUS_LOW_BATTERY_WARNING_THRESHOLD)) { v ->
seriousLowThreshold = (v as? Number)?.toInt()
publish()
}
}
}
override fun reset() {
packs.clear()
cells.clear()
lowThreshold = null
seriousLowThreshold = null
}
private fun packMap(index: Int, s: BatteryState): Map<String, Any?> = mapOf(
"index" to index,
"percent" to s.chargeRemainingInPercent,
"voltage" to s.voltage / 1000.0,
"current" to s.current / 1000.0,
"temperature" to s.temperature,
"chargeRemainingMah" to s.chargeRemaining,
"fullChargeCapacityMah" to s.fullChargeCapacity,
"designCapacityMah" to s.designCapacity,
"discharges" to s.numberOfDischarges,
"lifetimePercent" to s.lifetimeRemaining,
"isCharging" to s.isBeingCharged,
"cellVoltageLevel" to s.cellVoltageLevel?.name,
"connectionState" to s.connectionState?.name,
"selfHeatingState" to s.selfHeatingState?.name,
)
/** Main thread only. */
private fun publish() {
val list = packs.values.map { p ->
val index = p["index"] as Int
if (cells[index] == null) p else p + ("cellVoltages" to cells[index])
}
// The HUD's single figure is the weakest pack: that is the one that ends the flight.
val lowest = list.minByOrNull { (it["percent"] as? Int) ?: Int.MAX_VALUE }
ctx.hub.putSlow(
"battery",
mapOf(
"percent" to lowest?.get("percent"),
"voltage" to lowest?.get("voltage"),
"temperature" to lowest?.get("temperature"),
"lowWarningThreshold" to lowThreshold,
"seriousLowWarningThreshold" to seriousLowThreshold,
"packs" to list,
),
)
}
}
@@ -1,9 +1,12 @@
package com.pilotvault.flyapp
import dji.common.camera.ExposureSettings
import dji.common.camera.ResolutionAndFrameRate
import dji.common.camera.SettingsDefinitions
import dji.common.camera.StorageState
import dji.common.camera.SystemState
import dji.common.camera.WhiteBalance
import dji.keysdk.CameraKey
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.products.Aircraft
@@ -15,9 +18,22 @@ import io.flutter.plugin.common.MethodChannel
* (ISO / shutter / aperture / EV / WB). Emits `camera` (system state) and
* `exposure` events. String↔enum mapping mirrors the v2 kit's scrubber labels;
* unsupported values fail cleanly rather than crashing.
*
* Also feeds two slow groups of the [TelemetryHub]:
* ```
* storage: {location, inserted, initializing, formatting, formatted, full, error,
* readOnly, invalidFormat, verified, totalMB, remainingMB,
* availableCaptureCount, availableRecordingSeconds}
* cameraSettings: {aeLock, focusMode, photoAspectRatio, photoFileFormat,
* videoResolution, videoFrameRate, videoFileFormat, colorProfile}
* ```
* The settings are watched keys, so a change made from the RC's dial or the
* settings sheet shows up without a re-read.
*/
class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
private val keys = KeyWatch(ctx)
private fun cam(): Camera? = ctx.aircraft()?.camera
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
@@ -88,8 +104,58 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
val camera = product.camera ?: return
camera.setSystemStateCallback { state: SystemState -> emitSystem(state) }
camera.setExposureSettingsCallback { settings: ExposureSettings -> emitExposure(settings) }
try {
camera.setStorageStateCallBack { s: StorageState ->
val values = storageMap(s)
ctx.mainHandler.post { ctx.hub.putSlow("storage", values) }
}
} catch (_: Throwable) {
// A camera without removable or internal storage reports nothing.
}
val index = try {
camera.index
} catch (_: Throwable) {
0
}
ctx.mainHandler.post {
keys.clear()
fun setting(name: String, field: String) = keys.watch(CameraKey.create(name, index)) { v ->
ctx.hub.patchSlow("cameraSettings", mapOf(field to KeyValues.toDart(v)))
}
setting(CameraKey.AE_LOCK, "aeLock")
setting(CameraKey.FOCUS_MODE, "focusMode")
setting(CameraKey.PHOTO_ASPECT_RATIO, "photoAspectRatio")
setting(CameraKey.PHOTO_FILE_FORMAT, "photoFileFormat")
setting(CameraKey.VIDEO_FILE_FORMAT, "videoFileFormat")
setting(CameraKey.CAMERA_COLOR, "colorProfile")
keys.watch(CameraKey.create(CameraKey.RESOLUTION_FRAME_RATE, index)) { v ->
val r = v as? ResolutionAndFrameRate
ctx.hub.patchSlow(
"cameraSettings",
mapOf("videoResolution" to r?.resolution?.name, "videoFrameRate" to r?.frameRate?.name),
)
}
}
}
private fun storageMap(s: StorageState): Map<String, Any?> = mapOf(
"location" to s.storageLocation?.name,
"inserted" to s.isInserted,
"initializing" to s.isInitializing,
"formatting" to s.isFormatting,
"formatted" to s.isFormatted,
"full" to s.isFull,
"error" to s.hasError(),
"readOnly" to s.isReadOnly,
"invalidFormat" to s.isInvalidFormat,
"verified" to s.isVerified,
"totalMB" to s.totalSpaceInMB,
"remainingMB" to s.remainingSpaceInMB,
"availableCaptureCount" to s.availableCaptureCount,
"availableRecordingSeconds" to s.availableRecordingTimeInSeconds,
)
private fun emitSystem(state: SystemState) {
ctx.emit(
mapOf(
@@ -0,0 +1,56 @@
package com.pilotvault.flyapp
import dji.sdk.base.BaseProduct
import dji.sdk.base.DJIDiagnostics
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The product's live diagnostics — the messages behind GO 4's tip marquee, error
* stack and Aircraft Status sheet. The SDK hands over the *complete* current list
* on every update (an empty list once everything has cleared), so it is forwarded
* as-is, only when it changed, as a `diagnostics` event:
*
* ```
* {type: "diagnostics", items: [{component, code, subCode, componentIndex, reason, solution}]}
* ```
*
* Not part of the telemetry tick: the list changes rarely and every change matters.
* The SDK attaches no severity; Dart's `AlertState` grades them.
*/
class DiagnosticsBridge(private val ctx: BridgeCtx) : SubBridge {
/** Main thread only. */
private var last: List<Map<String, Any?>>? = null
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
override fun bind(product: BaseProduct?) {
if (product == null) return
try {
product.setDiagnosticsInformationCallback { list ->
val items = list.orEmpty().filterNotNull().map(::itemMap)
ctx.mainHandler.post {
if (items == last) return@post
last = items
ctx.emit(mapOf("type" to "diagnostics", "items" to items))
}
}
} catch (_: Throwable) {
// Some handhelds have no diagnostics source; nothing to report.
}
}
override fun reset() {
last = null
}
private fun itemMap(d: DJIDiagnostics): Map<String, Any?> = mapOf(
"component" to d.type?.name,
"code" to d.code,
"subCode" to d.subCode,
"componentIndex" to d.componentIndex,
"reason" to d.reason,
"solution" to d.solution,
)
}
@@ -3,7 +3,6 @@ package com.pilotvault.flyapp
import android.content.Context
import android.os.Handler
import android.os.Looper
import dji.common.battery.BatteryState
import dji.common.error.DJIError
import dji.common.error.DJISDKError
import dji.common.util.CommonCallbacks
@@ -21,10 +20,13 @@ import io.flutter.plugin.common.MethodChannel
* Bridges the DJI Mobile SDK V4 to Flutter.
*
* - [METHOD_CHANNEL] handles imperative calls from Dart, routed to per-subsystem
* helpers ([FlightControllerBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge]).
* helpers ([FlightControllerBridge], [BatteryBridge], [AirLinkBridge],
* [FlightAssistantBridge], [DiagnosticsBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge],
* [KeyBridge]).
* - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event
* is a map carrying a `type` key.
* is a map carrying a `type` key. Telemetry from every subsystem is batched by
* the shared [TelemetryHub] into one `telemetry` event per tick.
*
* All SDK callbacks arrive on arbitrary threads, so every event is marshalled to
* the main thread before being pushed into the Flutter [EventChannel.EventSink].
@@ -95,19 +97,25 @@ class DjiSdkBridge(
product = { DJISDKManager.getInstance().product },
)
private val flight = FlightControllerBridge(ctx)
private val camera = CameraBridge(ctx)
private val gimbal = GimbalBridge(ctx)
private val mission = MissionBridge(ctx)
private val media = MediaBridge(ctx)
private val account = AccountBridge(ctx)
private val profile = ProductProfileBridge(ctx)
private val subBridges = listOf(flight, camera, gimbal, mission, media, account, profile)
private val subBridges = listOf(
FlightControllerBridge(ctx),
BatteryBridge(ctx),
AirLinkBridge(ctx),
FlightAssistantBridge(ctx),
DiagnosticsBridge(ctx),
CameraBridge(ctx),
GimbalBridge(ctx),
MissionBridge(ctx),
MediaBridge(ctx),
AccountBridge(ctx),
ProductProfileBridge(ctx),
KeyBridge(ctx),
)
init {
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
ctx.hub.start()
}
// ── MethodChannel ──────────────────────────────────────────────────────────
@@ -198,6 +206,10 @@ class DjiSdkBridge(
override fun onProductDisconnect() {
clearIdentity()
mainHandler.post {
ctx.hub.clear()
for (sub in subBridges) sub.reset()
}
emit(mapOf("type" to "connection", "connected" to false, "model" to null))
}
@@ -368,28 +380,21 @@ class DjiSdkBridge(
/** (Re)attaches every subsystem's state listeners when a component appears. */
private fun bindComponentCallbacks(product: BaseProduct?) {
for (sub in subBridges) sub.bind(product)
if (product !is Aircraft) return
@Suppress("DEPRECATION")
product.battery?.setStateCallback { batteryState: BatteryState ->
emit(
mapOf(
"type" to "battery",
"percent" to batteryState.chargeRemainingInPercent,
"voltage" to batteryState.voltage / 1000.0,
"temperature" to batteryState.temperature,
)
)
for (sub in subBridges) {
// One subsystem's SDK quirk must not cost the pilot the others' telemetry.
try {
sub.bind(product)
} catch (_: Throwable) {
}
}
}
}
/**
* Shared services handed to each subsystem helper: the emit sink, main-thread
* handler, app context, and a live accessor for the connected product. Also
* provides the boilerplate completion callback that answers a Flutter [result].
* handler, app context, the [TelemetryHub] every telemetry source writes into,
* and a live accessor for the connected product. Also provides the boilerplate
* completion callback that answers a Flutter [result].
*/
class BridgeCtx(
val appContext: Context,
@@ -397,6 +402,8 @@ class BridgeCtx(
val emit: (Map<String, Any?>) -> Unit,
val product: () -> BaseProduct?,
) {
val hub = TelemetryHub(mainHandler, emit)
fun aircraft(): Aircraft? = product() as? Aircraft
/** A one-shot SDK completion callback that resolves the Flutter [result]. */
@@ -423,4 +430,7 @@ interface SubBridge {
/** (Re)attach any component state listeners for the connected product. */
fun bind(product: BaseProduct?) {}
/** Forget cached state on disconnect. Main thread. */
fun reset() {}
}
@@ -0,0 +1,62 @@
package com.pilotvault.flyapp
import dji.common.flightcontroller.VisionDetectionState
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Obstacle sensing from the FlightAssistant: one `VisionDetectionState` per sensor
* position (nose, tail, left, right — whichever the aircraft has), merged into the
* `vision` fast group of the [TelemetryHub], keyed by position:
*
* ```
* vision: {NOSE: {isSensorBeingUsed, isDisabled, systemWarning, distance,
* avoidAlertLevel, sectors: [{distance, warningLevel}]}, …}
* ```
*
* Aircraft without obstacle sensing never call back, so `vision` stays absent.
*/
class FlightAssistantBridge(private val ctx: BridgeCtx) : SubBridge {
/** Main thread only. */
private val sensors = sortedMapOf<String, Map<String, Any?>>()
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
val assistant = try {
product.flightController?.flightAssistant
} catch (_: Throwable) {
null
} ?: return
assistant.setVisionDetectionStateUpdatedCallback { state ->
val position = state.position?.name ?: "UNKNOWN"
val reading = readingMap(state)
ctx.mainHandler.post {
sensors[position] = reading
ctx.hub.putFast(mapOf("vision" to sensors.toMap()))
}
}
}
override fun reset() {
sensors.clear()
}
private fun readingMap(s: VisionDetectionState): Map<String, Any?> = mapOf(
"isSensorBeingUsed" to s.isSensorBeingUsed,
"isDisabled" to s.isDisabled,
"systemWarning" to s.systemWarning?.name,
"distance" to s.obstacleDistanceInMeters,
"avoidAlertLevel" to s.avoidAlertLevel,
"sectors" to s.detectionSectors.orEmpty().filterNotNull().map { sector ->
mapOf(
"distance" to sector.obstacleDistanceInMeters,
"warningLevel" to sector.warningLevel?.name,
)
},
)
}
@@ -1,6 +1,8 @@
package com.pilotvault.flyapp
import dji.common.flightcontroller.FlightControllerState
import dji.common.flightcontroller.simulator.InitializationData
import dji.common.model.LocationCoordinate2D
import dji.sdk.base.BaseProduct
import dji.sdk.flightcontroller.FlightController
import dji.sdk.products.Aircraft
@@ -14,7 +16,9 @@ import kotlin.math.sqrt
/**
* Flight-controller commands (takeoff / land / RTH), safety & control settings,
* and the enriched `telemetry` stream. Delegated to by [DjiSdkBridge].
* and the flight-state half of the `telemetry` stream: the flat keys of every
* [TelemetryHub] tick, plus the nested `goHome` assessment. Delegated to by
* [DjiSdkBridge].
*/
class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
@@ -29,6 +33,34 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
"cancelLanding" -> guard(controller, result) { it.cancelLanding(ctx.completion(result)) }
"startGoHome" -> guard(controller, result) { it.startGoHome(ctx.completion(result)) }
"cancelGoHome" -> guard(controller, result) { it.cancelGoHome(ctx.completion(result)) }
// Answers the smart-RTH countdown in `goHome.smartRTHState` (true = go home now).
"confirmSmartRTH" -> guard(controller, result) {
it.confirmSmartReturnToHomeRequest(call.argument<Boolean>("value") ?: true, ctx.completion(result))
}
// Developer-only (plan §1.6): every flight flow is proven here before a real
// flight. The motors can still spin while it runs, so DJI's guidance is to
// remove the propellers first; the UI says so before starting it.
"startSimulator" -> guard(controller, result) {
val sim = it.simulator
if (sim == null) {
ctx.fail(result, "Simulator unavailable on this aircraft")
} else {
val data = InitializationData.createInstance(
LocationCoordinate2D(
call.argument<Double>("latitude") ?: 55.6761,
call.argument<Double>("longitude") ?: 12.5683,
),
call.argument<Int>("updateFrequency") ?: 10,
call.argument<Int>("satelliteCount") ?: 12,
)
sim.start(data, ctx.completion(result))
}
}
"stopSimulator" -> guard(controller, result) {
val sim = it.simulator
if (sim == null) ctx.fail(result, "Simulator unavailable on this aircraft")
else sim.stop(ctx.completion(result))
}
"setHomeToCurrent" -> guard(controller, result) {
it.setHomeLocationUsingAircraftCurrentLocation(ctx.completion(result))
}
@@ -74,46 +106,87 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
product.flightController?.setStateCallback { state -> emitTelemetry(state) }
product.flightController?.setStateCallback { state ->
val values = telemetry(state) +
("simulatorActive" to (try { product.flightController?.simulator?.isSimulatorActive } catch (_: Throwable) { null }))
ctx.mainHandler.post { ctx.hub.putFast(values) }
}
}
private fun emitTelemetry(state: FlightControllerState) {
private fun telemetry(state: FlightControllerState): Map<String, Any?> {
val loc = state.aircraftLocation
val home = state.homeLocation
val attitude = state.attitude
val vx = state.velocityX
val vy = state.velocityY
val vz = state.velocityZ
val horizontalSpeed = hypot(vx.toDouble(), vy.toDouble())
// An unset home point reads as 0,0 (or NaN) rather than null.
val homeKnown = home != null && state.isHomeLocationSet &&
!home.latitude.isNaN() && !(home.latitude == 0.0 && home.longitude == 0.0)
var homeDistance: Double? = null
if (loc != null && home != null) {
if (loc != null && homeKnown) {
homeDistance = haversine(
loc.latitude, loc.longitude, home.latitude, home.longitude
loc.latitude, loc.longitude, home!!.latitude, home.longitude
)
}
val assessment = state.goHomeAssessment
ctx.emit(
mapOf(
"type" to "telemetry",
"satelliteCount" to state.satelliteCount,
"gpsSignalLevel" to (state.gpsSignalLevel?.value()),
"isFlying" to state.isFlying,
"areMotorsOn" to state.areMotorsOn(),
"flightMode" to state.flightModeString,
"altitude" to loc?.altitude,
"latitude" to loc?.latitude,
"longitude" to loc?.longitude,
"homeLatitude" to home?.latitude,
"homeLongitude" to home?.longitude,
"homeDistance" to homeDistance,
"horizontalSpeed" to horizontalSpeed,
"verticalSpeed" to -vz.toDouble(),
"heading" to state.attitude?.yaw,
"goHomeHeight" to state.goHomeHeight,
"velocityX" to vx,
"velocityY" to vy,
"velocityZ" to vz,
)
// Keys the API Server reads (satelliteCount … velocityZ) keep their names.
return mapOf(
"satelliteCount" to state.satelliteCount,
"gpsSignalLevel" to (state.gpsSignalLevel?.value()),
"isFlying" to state.isFlying,
"areMotorsOn" to state.areMotorsOn(),
"flightMode" to state.flightModeString,
"flightModeEnum" to state.flightMode?.name,
"altitude" to loc?.altitude,
"latitude" to loc?.latitude,
"longitude" to loc?.longitude,
"homeLatitude" to (if (homeKnown) home?.latitude else null),
"homeLongitude" to (if (homeKnown) home?.longitude else null),
"isHomeLocationSet" to state.isHomeLocationSet,
"homeDistance" to homeDistance,
"horizontalSpeed" to horizontalSpeed,
"verticalSpeed" to -vz.toDouble(),
"heading" to attitude?.yaw,
"pitch" to attitude?.pitch,
"roll" to attitude?.roll,
"yaw" to attitude?.yaw,
"goHomeHeight" to state.goHomeHeight,
"velocityX" to vx,
"velocityY" to vy,
"velocityZ" to vz,
"flightTimeSeconds" to state.flightTimeInSeconds,
"ultrasonicHeight" to state.ultrasonicHeightInMeters,
"isUltrasonicBeingUsed" to state.isUltrasonicBeingUsed,
"ultrasonicError" to state.doesUltrasonicHaveError(),
"isVisionPositioningBeingUsed" to state.isVisionPositioningSensorBeingUsed,
"isLandingConfirmationNeeded" to state.isLandingConfirmationNeeded,
"isGoingHome" to state.isGoingHome,
"goHomeExecutionState" to state.goHomeExecutionState?.name,
"isLowBattery" to state.isLowerThanBatteryWarningThreshold,
"isSeriousLowBattery" to state.isLowerThanSeriousBatteryWarningThreshold,
"batteryThresholdBehavior" to state.batteryThresholdBehavior?.name,
"windWarning" to state.flightWindWarning?.name,
"reachedMaxHeight" to state.hasReachedMaxFlightHeight(),
"reachedMaxRadius" to state.hasReachedMaxFlightRadius(),
"isIMUPreheating" to state.isIMUPreheating,
"isFailsafe" to state.isFailsafeEnabled,
"orientationMode" to state.orientationMode?.name,
"goHome" to assessment?.let { a ->
mapOf(
"remainingFlightTime" to a.remainingFlightTime,
"timeNeededToGoHome" to a.timeNeededToGoHome,
"timeNeededToLand" to a.timeNeededToLandFromCurrentHeight,
"batteryNeededToGoHome" to a.batteryPercentageNeededToGoHome,
"batteryNeededToLand" to a.batteryPercentageNeededToLandFromCurrentHeight,
"maxRadiusCanFlyAndGoHome" to a.maxRadiusAircraftCanFlyAndGoHome,
"smartRTHState" to a.smartRTHState?.name,
"smartRTHCountdown" to a.smartRTHCountdown,
)
},
)
}
@@ -9,8 +9,8 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Gimbal pitch control (from the flight HUD's pitch slider) and gimbal-attitude
* telemetry (`gimbal` event).
* Gimbal pitch control (from the flight HUD's pitch slider) and gimbal attitude,
* published as the `gimbal` fast group of the [TelemetryHub] (`{pitch, roll, yaw}`).
*/
class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
@@ -42,14 +42,8 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
if (product !is Aircraft) return
product.gimbal?.setStateCallback { state ->
val a = state.attitudeInDegrees
ctx.emit(
mapOf(
"type" to "gimbal",
"pitch" to a?.pitch,
"roll" to a?.roll,
"yaw" to a?.yaw,
)
)
val values = mapOf("pitch" to a?.pitch, "roll" to a?.roll, "yaw" to a?.yaw)
ctx.mainHandler.post { ctx.hub.putFast(mapOf("gimbal" to values)) }
}
}
}
@@ -0,0 +1,250 @@
package com.pilotvault.flyapp
import dji.common.camera.ResolutionAndFrameRate
import dji.common.error.DJIError
import dji.common.flightcontroller.LocationCoordinate3D
import dji.common.model.LocationCoordinate2D
import dji.keysdk.AirLinkKey
import dji.keysdk.BatteryKey
import dji.keysdk.CameraKey
import dji.keysdk.DJIKey
import dji.keysdk.FlightControllerKey
import dji.keysdk.GimbalKey
import dji.keysdk.KeyManager
import dji.keysdk.ProductKey
import dji.keysdk.RemoteControllerKey
import dji.keysdk.callback.GetCallback
import dji.keysdk.callback.KeyListener
import dji.keysdk.callback.SetCallback
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Generic MSDK V4 `KeyManager` access (plan §1.3): one `getKey` / `setKey` /
* `listenKey` / `cancelKey` / `isKeySupported` method set instead of a bespoke
* method per simple value, so a settings row (P6) is one key name, not new Kotlin.
*
* A key is addressed as `{component, key, index?, subIndex?}`:
* - `component`: `FlightController`, `FlightAssistant`, `RTK`, `Camera`, `Gimbal`,
* `Battery`, `BatteryAggregation`, `AirLink`, `WiFiLink`, `LightbridgeLink`,
* `OcuSyncLink`, `RemoteController` or `Product`;
* - `key`: either the key class's constant name (`LOW_BATTERY_WARNING_THRESHOLD`)
* or the raw parameter string it stands for (`LowBatteryWarningThreshold`);
* - `index` selects the camera / gimbal / battery; `subIndex` a camera lens.
*
* Values cross the channel through [KeyValues.toDart]: enums as their name,
* arrays as lists, a few SDK value types as maps, anything else as `toString()`.
* Listened keys stream as `{type: "key", id, value}` events, where `id` is the
* one `listenKey` returned.
*/
class KeyBridge(private val ctx: BridgeCtx) : SubBridge {
/** Active `listenKey` subscriptions by id. They survive reconnects, like KeyManager's own. */
private val listeners = HashMap<String, KeyListener>()
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"getKey", "setKey", "listenKey", "isKeySupported" -> Unit
"cancelKey" -> {
val id = call.argument<String>("id")
ctx.mainHandler.post {
listeners.remove(id)?.let { l -> KeyManager.getInstance()?.removeListener(l) }
result.success(null)
}
return true
}
else -> return false
}
val km = KeyManager.getInstance()
if (km == null) {
ctx.fail(result, "SDK not registered")
return true
}
val key = keyFrom(call)
if (key == null) {
ctx.fail(result, "Unknown key ${call.argument<String>("component")}.${call.argument<String>("key")}")
return true
}
when (call.method) {
"isKeySupported" -> ctx.mainHandler.post { result.success(km.isKeySupported(key)) }
"getKey" -> km.getValue(key, object : GetCallback {
override fun onSuccess(value: Any) {
ctx.mainHandler.post { result.success(KeyValues.toDart(value)) }
}
override fun onFailure(error: DJIError) {
ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) }
}
})
"setKey" -> setKey(km, key, call.argument<Any>("value"), result)
"listenKey" -> ctx.mainHandler.post {
val id = idOf(call)
if (!listeners.containsKey(id)) {
val l = KeyListener { _, new ->
ctx.emit(mapOf("type" to "key", "id" to id, "value" to KeyValues.toDart(new)))
}
listeners[id] = l
km.addListener(key, l)
}
// The listener only fires on change, so seed the subscriber with the
// current value (cache or a fresh read).
km.getValue(key, object : GetCallback {
override fun onSuccess(value: Any) {
ctx.emit(mapOf("type" to "key", "id" to id, "value" to KeyValues.toDart(value)))
}
override fun onFailure(error: DJIError) = Unit
})
result.success(id)
}
}
return true
}
/**
* The SDK wants the exact Java type the key holds (an `Integer`, a specific
* enum), and Dart only has num/bool/String. The cached value tells us the type;
* with nothing cached yet, one read fetches it first.
*/
private fun setKey(km: KeyManager, key: DJIKey, raw: Any?, result: MethodChannel.Result) {
val done = object : SetCallback {
override fun onSuccess() {
ctx.mainHandler.post { result.success(null) }
}
override fun onFailure(error: DJIError) {
ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) }
}
}
if (raw == null) {
ctx.fail(result, "setKey needs a value")
return
}
val cached = km.getValue(key)
if (cached != null) {
km.setValue(key, KeyValues.coerce(raw, cached), done)
return
}
km.getValue(key, object : GetCallback {
override fun onSuccess(value: Any) = km.setValue(key, KeyValues.coerce(raw, value), done)
override fun onFailure(error: DJIError) = km.setValue(key, raw, done)
})
}
private fun idOf(call: MethodCall): String = listOf(
call.argument<String>("component"),
call.argument<String>("key"),
call.argument<Int>("index") ?: 0,
call.argument<Int>("subIndex"),
).joinToString("/")
private fun keyFrom(call: MethodCall): DJIKey? {
val component = call.argument<String>("component") ?: return null
val name = call.argument<String>("key") ?: return null
val index = call.argument<Int>("index") ?: 0
val subIndex = call.argument<Int>("subIndex")
return try {
when (component) {
"FlightController" -> FlightControllerKey.create(param(FlightControllerKey::class.java, name))
"FlightAssistant" -> FlightControllerKey.createFlightAssistantKey(param(FlightControllerKey::class.java, name))
"RTK" -> FlightControllerKey.createRTKKey(param(FlightControllerKey::class.java, name))
"Camera" -> {
val p = param(CameraKey::class.java, name)
if (subIndex != null) CameraKey.createLensKey(p, index, subIndex) else CameraKey.create(p, index)
}
"Gimbal" -> GimbalKey.create(param(GimbalKey::class.java, name), index)
"Battery" -> BatteryKey.create(param(BatteryKey::class.java, name), index)
"BatteryAggregation" -> BatteryKey.createBatteryAggregationKey(param(BatteryKey::class.java, name))
"AirLink" -> AirLinkKey.create(param(AirLinkKey::class.java, name))
"WiFiLink" -> AirLinkKey.createWiFiLinkKey(param(AirLinkKey::class.java, name))
"LightbridgeLink" -> AirLinkKey.createLightbridgeLinkKey(param(AirLinkKey::class.java, name))
"OcuSyncLink" -> AirLinkKey.createOcuSyncLinkKey(param(AirLinkKey::class.java, name))
"RemoteController" -> RemoteControllerKey.create(param(RemoteControllerKey::class.java, name))
"Product" -> ProductKey.create(param(ProductKey::class.java, name))
else -> null
}
} catch (_: Throwable) {
null
}
}
/** `LOW_BATTERY_WARNING_THRESHOLD` → the constant's value; anything else passes through. */
private fun param(cls: Class<*>, name: String): String = try {
cls.getField(name).get(null) as? String ?: name
} catch (_: Throwable) {
name
}
}
/**
* Watches a fixed set of keys for a sub-bridge, re-subscribing cleanly on rebind:
* [DjiSdkBridge] calls `bind` on every component change, and KeyManager listeners
* — unlike `set…Callback` slots — stack instead of replacing each other.
*/
class KeyWatch(private val ctx: BridgeCtx) {
private val active = ArrayList<KeyListener>()
/** Calls [onValue] on the main thread with the current value and every change. */
fun watch(key: DJIKey?, onValue: (Any?) -> Unit) {
val km = KeyManager.getInstance() ?: return
if (key == null) return
val l = KeyListener { _, new -> ctx.mainHandler.post { onValue(new) } }
active += l
km.addListener(key, l)
km.getValue(key, object : GetCallback {
override fun onSuccess(value: Any) {
ctx.mainHandler.post { onValue(value) }
}
override fun onFailure(error: DJIError) = Unit
})
}
fun clear() {
val km = KeyManager.getInstance()
for (l in active) km?.removeListener(l)
active.clear()
}
}
/** SDK value ⇄ Flutter channel value. */
object KeyValues {
fun toDart(v: Any?): Any? = when (v) {
null, is Boolean, is String, is Int, is Long, is Double -> v
is Float -> v.toDouble()
is Number -> v.toDouble()
is Enum<*> -> v.name
is Array<*> -> v.map(::toDart)
is IntArray -> v.toList()
is FloatArray -> v.map { it.toDouble() }
is DoubleArray -> v.toList()
is BooleanArray -> v.toList()
is Collection<*> -> v.map(::toDart)
is Map<*, *> -> v.entries.associate { (k, x) -> k.toString() to toDart(x) }
is ResolutionAndFrameRate -> mapOf("resolution" to v.resolution?.name, "frameRate" to v.frameRate?.name)
is LocationCoordinate3D -> mapOf("latitude" to v.latitude, "longitude" to v.longitude, "altitude" to v.altitude.toDouble())
is LocationCoordinate2D -> mapOf("latitude" to v.latitude, "longitude" to v.longitude)
else -> v.toString()
}
/** Converts a Dart value to the Java type of [sample], the key's current value. */
fun coerce(raw: Any, sample: Any?): Any {
if (sample == null) return raw
if (sample is Enum<*>) {
val cls = sample.javaClass.let { if (it.isEnum) it else it.superclass }
return cls.enumConstants?.firstOrNull { (it as Enum<*>).name == raw.toString() } ?: raw
}
val n = raw as? Number
return when (sample) {
is Int -> n?.toInt() ?: raw
is Long -> n?.toLong() ?: raw
is Float -> n?.toFloat() ?: raw
is Double -> n?.toDouble() ?: raw
is Short -> n?.toShort() ?: raw
is Byte -> n?.toByte() ?: raw
else -> raw
}
}
}
@@ -0,0 +1,118 @@
package com.pilotvault.flyapp
import android.os.Handler
import android.os.SystemClock
/**
* Batches every telemetry source into one `telemetry` event per tick (plan P1).
*
* The SDK calls back on its own schedule — the flight controller at ~10 Hz, the
* vision system per sensor, battery and link callbacks whenever they like — and
* pushing each of those straight to Dart would rebuild the HUD dozens of times a
* second. Sources instead write into the hub, and a main-thread ticker publishes:
*
* - **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)
* folded in at most every [SLOW_MS] (2 Hz), so a chatty battery callback
* cannot drive the tick rate on its own.
*
* Every event carries the whole latest-known snapshot — flat flight-controller
* keys plus one nested map per slow group — so a consumer that drops packets
* (the API Server uploader throttles to 4 Hz) never loses a slow group, and Dart
* can treat a missing group as "not reported by this aircraft" rather than "no
* change this tick".
*
* Main thread only: sources hop to [handler] before calling in.
*/
class TelemetryHub(private val handler: Handler, private val emit: (Map<String, Any?>) -> Unit) {
companion object {
const val FAST_MS = 100L
const val SLOW_MS = 500L
}
/** Flat flight-controller keys plus fast nested groups (`vision`). */
private val fast = LinkedHashMap<String, Any?>()
/** Slow groups as last written by their sources. */
private val slowPending = LinkedHashMap<String, Any?>()
/** Slow groups as last published, so a fast tick re-sends them unchanged. */
private val slowPublished = LinkedHashMap<String, Any?>()
private var fastDirty = false
private var slowDirty = false
private var lastSlowPublishMs = 0L
private var running = false
private val tick = object : Runnable {
override fun run() {
if (!running) return
publish()
handler.postDelayed(this, FAST_MS)
}
}
fun start() {
if (running) return
running = true
handler.postDelayed(tick, FAST_MS)
}
/** Merges flat keys (or a fast nested group, as one key) into the snapshot. */
fun putFast(values: Map<String, Any?>) {
for ((k, v) in values) {
if (fast[k] != v || !fast.containsKey(k)) {
fast[k] = v
fastDirty = true
}
}
}
/** Replaces one slow group, e.g. `battery` or `links`. */
fun putSlow(group: String, value: Any?) {
if (slowPending[group] == value && slowPending.containsKey(group)) return
slowPending[group] = value
slowDirty = true
}
/** Patches keys inside one slow group, for groups fed by several callbacks. */
fun patchSlow(group: String, values: Map<String, Any?>) {
@Suppress("UNCHECKED_CAST")
val current = (slowPending[group] as? Map<String, Any?>).orEmpty()
putSlow(group, current + values)
}
/** Current value of a slow group as written, for sources that patch nested lists. */
fun slow(group: String): Any? = slowPending[group]
/** Drops everything on disconnect, so the next aircraft starts from a blank snapshot. */
fun clear() {
fast.clear()
slowPending.clear()
slowPublished.clear()
fastDirty = false
slowDirty = false
}
private fun publish() {
val now = SystemClock.elapsedRealtime()
val slowDue = slowDirty && now - lastSlowPublishMs >= SLOW_MS
if (slowDue) {
slowPublished.clear()
slowPublished.putAll(slowPending)
slowDirty = false
lastSlowPublishMs = now
}
if (!fastDirty && !slowDue) return
fastDirty = false
if (fast.isEmpty() && slowPublished.isEmpty()) return
val event = LinkedHashMap<String, Any?>(fast.size + slowPublished.size + 1)
event["type"] = "telemetry"
event.putAll(fast)
event.putAll(slowPublished)
emit(event)
}
}