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:
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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+100
-27
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user