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

+8
View File
@@ -149,4 +149,12 @@ func applyTelemetry(t *Telemetry, raw map[string]any) {
if v, ok := toFloat(raw["velocityZ"]); ok {
t.VelocityZ = &v
}
// Since the Fly App batches telemetry, the battery rides along as a nested
// group whose top-level percent is the weakest pack. The standalone
// "battery" event is still accepted for older app builds.
if b, ok := raw["battery"].(map[string]any); ok {
if p, ok := toInt(b["percent"]); ok {
t.BatteryPercent = &p
}
}
}
@@ -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)
}
}
+80 -3
View File
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
/// Every method returns cleanly whether or not a product is connected; the
/// native side answers with a typed error the UI can surface. The event stream
/// carries maps keyed by `type`: `registration`, `connection`, `identity`,
/// `telemetry`, `battery`, `camera`, `exposure`, `gimbal`, `mission`,
/// `mediaList`, `mediaDownload`, `djiAccount`, `productProfile`, `database`,
/// `init`.
/// `telemetry` (the batched snapshot — see `TelemetryReducer`), `camera`,
/// `exposure`, `diagnostics`, `mission`, `key`, `mediaList`, `mediaDownload`,
/// `djiAccount`, `productProfile`, `database`, `init`.
class DjiService {
static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
static const EventChannel _events = EventChannel('dji_msdk/events');
@@ -53,8 +53,24 @@ class DjiService {
Future<void> cancelLanding() => _methods.invokeMethod<void>('cancelLanding');
Future<void> startGoHome() => _methods.invokeMethod<void>('startGoHome');
Future<void> cancelGoHome() => _methods.invokeMethod<void>('cancelGoHome');
/// Answers the smart-RTH countdown (`goHome.smartRTHState == COUNTING_DOWN`):
/// true flies home now, false cancels it.
Future<void> confirmSmartRTH(bool goHome) =>
_methods.invokeMethod<void>('confirmSmartRTH', <String, dynamic>{'value': goHome});
Future<void> setHomeToCurrent() => _methods.invokeMethod<void>('setHomeToCurrent');
/// Starts the flight controller's built-in simulator at [latitude]/[longitude]
/// (developer only; propellers off). `telemetry.simulatorActive` reflects it.
Future<void> startSimulator({required double latitude, required double longitude, int satellites = 12}) =>
_methods.invokeMethod<void>('startSimulator', <String, dynamic>{
'latitude': latitude,
'longitude': longitude,
'satelliteCount': satellites,
'updateFrequency': 10,
});
Future<void> stopSimulator() => _methods.invokeMethod<void>('stopSimulator');
// ── Flight settings (Safety / Control) ──────────────────────────────────────
Future<void> setMaxFlightHeight(int m) =>
_methods.invokeMethod<void>('setMaxFlightHeight', <String, dynamic>{'value': m});
@@ -69,6 +85,25 @@ class DjiService {
Future<void> setObstacleAvoidance(bool on) =>
_methods.invokeMethod<void>('setObstacleAvoidance', <String, dynamic>{'value': on});
// ── KeyManager (generic simple values) ──────────────────────────────────────
/// Reads one SDK key. Enums come back as their name, arrays as lists.
Future<Object?> getKey(DjiKey key) => _methods.invokeMethod<Object?>('getKey', key.args);
/// Writes one SDK key; [value] is coerced natively to the key's Java type
/// (an enum by name, a number to the exact width).
Future<void> setKey(DjiKey key, Object? value) =>
_methods.invokeMethod<void>('setKey', <String, dynamic>{...key.args, 'value': value});
Future<bool> isKeySupported(DjiKey key) async =>
await _methods.invokeMethod<bool>('isKeySupported', key.args) ?? false;
/// Subscribes to a key: its current value and every change arrive as `key`
/// events whose `id` is the string returned here. Idempotent per key.
Future<String> listenKey(DjiKey key) async =>
await _methods.invokeMethod<String>('listenKey', key.args) ?? key.id;
Future<void> cancelKey(String id) => _methods.invokeMethod<void>('cancelKey', <String, dynamic>{'id': id});
// ── Camera ──────────────────────────────────────────────────────────────────
/// mode: `photo` | `video` | `mediaDownload` | `playback`
Future<void> setCameraMode(String mode) =>
@@ -157,3 +192,45 @@ class DjiService {
Future<void> djiLogout() => _methods.invokeMethod<void>('djiLogout');
Future<void> refreshDjiAccountState() => _methods.invokeMethod<void>('getDjiAccountState');
}
/// One MSDK V4 KeyManager key, as `KeyBridge.kt` addresses it.
///
/// [key] is the key class's constant name (`LOW_BATTERY_WARNING_THRESHOLD`) or
/// the raw parameter string; [index] picks the camera / gimbal / battery and
/// [subIndex] a camera lens.
class DjiKey {
const DjiKey(this.component, this.key, {this.index = 0, this.subIndex});
const DjiKey.flightController(String key) : this('FlightController', key);
const DjiKey.flightAssistant(String key) : this('FlightAssistant', key);
const DjiKey.camera(String key, {int index = 0, int? lens}) : this('Camera', key, index: index, subIndex: lens);
const DjiKey.gimbal(String key, {int index = 0}) : this('Gimbal', key, index: index);
const DjiKey.battery(String key, {int index = 0}) : this('Battery', key, index: index);
const DjiKey.airLink(String key) : this('AirLink', key);
const DjiKey.remoteController(String key) : this('RemoteController', key);
const DjiKey.product(String key) : this('Product', key);
final String component;
final String key;
final int index;
final int? subIndex;
/// The id `listenKey` answers with and `key` events carry.
String get id => '$component/$key/$index/${subIndex ?? 'null'}';
Map<String, dynamic> get args => <String, dynamic>{
'component': component,
'key': key,
'index': index,
if (subIndex != null) 'subIndex': subIndex,
};
@override
bool operator ==(Object other) => other is DjiKey && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() => id;
}
+133 -42
View File
@@ -1,6 +1,14 @@
import 'package:flutter/foundation.dart';
import 'fly/product_profile.dart';
import 'fly/state/aircraft_state.dart';
import 'fly/state/alert_state.dart';
import 'fly/state/battery_state.dart';
import 'fly/state/camera_state.dart';
import 'fly/state/gimbal_state.dart';
import 'fly/state/link_state.dart';
import 'fly/state/mission_state.dart';
import 'fly/state/vision_state.dart';
import 'uploader.dart';
enum RegistrationState { idle, registering, success, failed }
@@ -60,9 +68,77 @@ class MediaItem {
}
/// Live aircraft/session state, shared across every screen. `_HomePageState`
/// owns the DJI/uploader plumbing and pushes updates here; the screens observe
/// it via [AnimatedBuilder]. Call [bump] after a batch of field writes.
/// owns the DJI/uploader plumbing and feeds bridge events through
/// [TelemetryReducer]; the screens observe it.
///
/// Telemetry lives in per-subsystem sub-models (plan P1) — [aircraft],
/// [battery], [links], [camera], [gimbal], [vision], [alerts], [mission] — each
/// its own [ChangeNotifier], so a HUD widget can listen to just the one it
/// draws (`ListenableBuilder(listenable: model.battery, …)`) instead of
/// rebuilding with every 10 Hz tick. The model itself re-notifies on any
/// sub-model change — once per [batch], not once per sub-model — which keeps
/// the pre-P2 screens (`AnimatedBuilder(animation: model)`) and their flat
/// getters below working unchanged.
class FlightModel extends ChangeNotifier {
FlightModel() {
for (final ChangeNotifier sub in _subs) {
sub.addListener(_onSub);
}
}
bool _batching = false;
bool _dirty = false;
void _onSub() {
if (_batching) {
_dirty = true;
} else {
notifyListeners();
}
}
/// Runs [update] (several sub-model writes) and notifies this model's own
/// listeners once at the end, if anything changed. Sub-model listeners still
/// hear their own change immediately.
void batch(void Function() update) {
if (_batching) return update();
_batching = true;
try {
update();
} finally {
_batching = false;
if (_dirty) {
_dirty = false;
notifyListeners();
}
}
}
// ── Telemetry sub-models ───────────────────────────────────────────────────
final AircraftState aircraft = AircraftState();
final BatteryState battery = BatteryState();
final LinkState links = LinkState();
final CameraState camera = CameraState();
final GimbalState gimbal = GimbalState();
final VisionState vision = VisionState();
final AlertState alerts = AlertState();
final MissionState mission = MissionState();
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
aircraft, battery, links, camera, gimbal, vision, alerts, mission, //
];
/// Wipes the aircraft-bound sub-models on disconnect.
void clearTelemetry() => batch(() {
aircraft.clear();
battery.clear();
links.clear();
camera.clear();
gimbal.clear();
vision.clear();
alerts.clear();
});
// ── SDK / registration / connection ────────────────────────────────────────
String sdkVersion = '…';
RegistrationState registration = RegistrationState.idle;
@@ -82,57 +158,63 @@ class FlightModel extends ChangeNotifier {
/// until the bridge's first `productProfile` event after connect.
ProductProfile profile = ProductProfile.disconnected;
// ── Flight controller telemetry ────────────────────────────────────────────
int? satellites;
int? gpsSignalLevel; // 0..5
bool? isFlying;
bool? motorsOn;
String? flightMode;
double? altitude; // m, relative to home
double? latitude;
double? longitude;
double? homeLatitude;
double? homeLongitude;
double? homeDistance; // m, aircraft ↔ home
double? horizontalSpeed; // m/s
double? verticalSpeed; // m/s (up positive)
double? heading; // deg
int? flightTimeSeconds;
double? goHomeHeight; // m
// ── Flight controller telemetry (pre-P2 flat view of [aircraft]) ───────────
int? get satellites => aircraft.satellites;
int? get gpsSignalLevel => aircraft.gpsSignalLevel; // 0..5
bool? get isFlying => aircraft.isFlying;
bool? get motorsOn => aircraft.motorsOn;
String? get flightMode => aircraft.flightMode;
double? get altitude => aircraft.altitude; // m, relative to home
double? get latitude => aircraft.latitude;
double? get longitude => aircraft.longitude;
double? get homeLatitude => aircraft.homeLatitude;
double? get homeLongitude => aircraft.homeLongitude;
double? get homeDistance => aircraft.homeDistance; // m, aircraft ↔ home
double? get horizontalSpeed => aircraft.horizontalSpeed; // m/s
double? get verticalSpeed => aircraft.verticalSpeed; // m/s (up positive)
double? get heading => aircraft.heading; // deg
int? get flightTimeSeconds => aircraft.flightTimeSeconds;
double? get goHomeHeight => aircraft.goHomeHeight; // m
// Phone's own GPS (independent of the drone's fix); streamed to the server as
// a location fallback for the Web App's automatic bounding box.
double? phoneLatitude;
double? phoneLongitude;
// ── Battery ─────────────────────────────────────────────────────────────────
int? batteryPercent;
double? batteryVoltage; // V
double? batteryTemperature; // °C
// ── Battery (flat view of [battery]) ────────────────────────────────────────
int? get batteryPercent => battery.percent;
double? get batteryVoltage => battery.voltage; // V
double? get batteryTemperature => battery.temperature; // °C
// ── Camera ──────────────────────────────────────────────────────────────────
CaptureMode captureMode = CaptureMode.video;
String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA
bool isRecording = false;
int recordSeconds = 0;
bool sdInserted = false;
int? sdRemainingMB;
int? sdPhotoCount;
int? sdVideoCount;
bool get isRecording => camera.isRecording;
int get recordSeconds => camera.recordSeconds;
bool get sdInserted => camera.storage?.inserted ?? false;
int? get sdRemainingMB => camera.storage?.remainingMB;
int? get sdPhotoCount => camera.storage?.availableCaptureCount;
int? get sdRecordSeconds => camera.storage?.availableRecordingSeconds;
// ── Exposure ────────────────────────────────────────────────────────────────
// Read back into [camera]; the camera settings page writes them optimistically
// ahead of the next `exposure` event.
ExposureProgram exposureProgram = ExposureProgram.auto;
String? iso; // "100", "AUTO", …
String? shutter; // "1/240"
String? aperture; // "f/2.8"
String? ev; // "-0.3"
String? get iso => camera.iso; // "100", "AUTO", …
set iso(String? v) => camera.iso = v;
String? get shutter => camera.shutter; // "1/240"
set shutter(String? v) => camera.shutter = v;
String? get aperture => camera.aperture; // "f/2.8"
set aperture(String? v) => camera.aperture = v;
String? get ev => camera.ev; // "-0.3"
set ev(String? v) => camera.ev = v;
String? whiteBalance; // "5200K" / "AUTO"
List<double>? histogram; // 0..1 normalized bins
// ── Gimbal ──────────────────────────────────────────────────────────────────
double? gimbalPitch; // deg (−90..30-ish)
double? gimbalRoll;
double? gimbalYaw;
// ── Gimbal (flat view of [gimbal]) ──────────────────────────────────────────
double? get gimbalPitch => gimbal.pitch; // deg (−90..30-ish)
double? get gimbalRoll => gimbal.roll;
double? get gimbalYaw => gimbal.yaw;
// ── Flight settings (Safety / Control) ──────────────────────────────────────
int? maxHeight; // m
@@ -143,11 +225,11 @@ class FlightModel extends ChangeNotifier {
bool? noviceMode;
bool arHomePoint = true; // client-side toggle (AR overlay)
// ── Missions ────────────────────────────────────────────────────────────────
String missionState = 'idle'; // idle / ready / uploading / executing / …
bool missionRunning = false;
bool tracking = false; // ActiveTrack engaged
String? missionError;
// ── Missions (flat view of [mission]) ───────────────────────────────────────
String get missionState => mission.state; // idle / ready / uploading / executing / …
bool get missionRunning => mission.running;
bool get tracking => mission.tracking; // ActiveTrack engaged
String? get missionError => mission.error;
// ── On-drone media ──────────────────────────────────────────────────────────
List<MediaItem> media = <MediaItem>[];
@@ -164,4 +246,13 @@ class FlightModel extends ChangeNotifier {
/// Notify observers after a batch of field writes.
void bump() => notifyListeners();
@override
void dispose() {
for (final ChangeNotifier sub in _subs) {
sub.removeListener(_onSub);
sub.dispose();
}
super.dispose();
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// Return-to-home budget from the flight controller (`GoHomeAssessment`):
/// what the smart battery bar draws its markers and remaining time from.
@immutable
class GoHomeAssessment {
const GoHomeAssessment({
this.remainingFlightTime,
this.timeNeededToGoHome,
this.timeNeededToLand,
this.batteryNeededToGoHome,
this.batteryNeededToLand,
this.maxRadiusCanFlyAndGoHome,
this.smartRTHState,
this.smartRTHCountdown,
});
factory GoHomeAssessment.fromMap(Map<String, dynamic> m) => GoHomeAssessment(
remainingFlightTime: asInt(m['remainingFlightTime']),
timeNeededToGoHome: asInt(m['timeNeededToGoHome']),
timeNeededToLand: asInt(m['timeNeededToLand']),
batteryNeededToGoHome: asInt(m['batteryNeededToGoHome']),
batteryNeededToLand: asInt(m['batteryNeededToLand']),
maxRadiusCanFlyAndGoHome: finite(m['maxRadiusCanFlyAndGoHome']),
smartRTHState: asString(m['smartRTHState']),
smartRTHCountdown: asInt(m['smartRTHCountdown']),
);
/// Seconds of flight left on the current battery.
final int? remainingFlightTime;
final int? timeNeededToGoHome; // s
final int? timeNeededToLand; // s, from the current height
final int? batteryNeededToGoHome; // %
final int? batteryNeededToLand; // %
final double? maxRadiusCanFlyAndGoHome; // m
/// `IDLE` / `COUNTING_DOWN` / `EXECUTED` / `CANCELLED`.
final String? smartRTHState;
final int? smartRTHCountdown; // s
bool get smartRTHCountingDown => smartRTHState == 'COUNTING_DOWN';
@override
bool operator ==(Object other) =>
other is GoHomeAssessment &&
other.remainingFlightTime == remainingFlightTime &&
other.timeNeededToGoHome == timeNeededToGoHome &&
other.timeNeededToLand == timeNeededToLand &&
other.batteryNeededToGoHome == batteryNeededToGoHome &&
other.batteryNeededToLand == batteryNeededToLand &&
other.maxRadiusCanFlyAndGoHome == maxRadiusCanFlyAndGoHome &&
other.smartRTHState == smartRTHState &&
other.smartRTHCountdown == smartRTHCountdown;
@override
int get hashCode => Object.hash(remainingFlightTime, timeNeededToGoHome, timeNeededToLand, batteryNeededToGoHome,
batteryNeededToLand, maxRadiusCanFlyAndGoHome, smartRTHState, smartRTHCountdown);
}
/// The flight controller's state: position, motion, attitude, flight mode and
/// the safety flags. Fed from the flat keys of every `telemetry` tick (~10 Hz).
class AircraftState extends ChangeNotifier {
int? satellites;
int? gpsSignalLevel; // 0..5
bool? isFlying;
bool? motorsOn;
/// Human-readable mode from the SDK ("P-GPS", "Sport", "Atti", "GoHome" …).
String? flightMode;
/// The `FlightMode` enum name behind [flightMode] (`GPS_ATTI`, `GO_HOME` …),
/// which is what logic should branch on.
String? flightModeEnum;
double? altitude; // m, relative to take-off
double? latitude;
double? longitude;
double? homeLatitude;
double? homeLongitude;
bool? homeLocationSet;
double? homeDistance; // m, aircraft ↔ home
double? horizontalSpeed; // m/s
double? verticalSpeed; // m/s (up positive)
double? heading; // deg, −180..180 (= yaw)
double? pitch; // deg
double? roll; // deg
double? velocityX; // m/s, north
double? velocityY; // m/s, east
double? velocityZ; // m/s, down
int? flightTimeSeconds;
double? goHomeHeight; // m
/// VPS: downward ultrasonic height, valid only while [ultrasonicBeingUsed].
double? ultrasonicHeight; // m
bool? ultrasonicBeingUsed;
bool? ultrasonicError;
bool? visionPositioningBeingUsed;
bool? landingConfirmationNeeded;
bool? goingHome;
/// `GoHomeExecutionState` name: `NOT_EXECUTING`, `GO_UP_TO_HEIGHT`, `AUTO_FLY_TO_HOME_POINT` …
String? goHomeExecutionState;
GoHomeAssessment? goHome;
bool? lowBattery;
bool? seriousLowBattery;
String? batteryThresholdBehavior; // FLY_NORMALLY / GO_HOME / LAND_IMMEDIATELY
String? windWarning; // LEVEL_0..LEVEL_2
bool? reachedMaxHeight;
bool? reachedMaxRadius;
bool? imuPreheating;
bool? failsafe;
String? orientationMode; // AIRCRAFT_HEADING / COURSE_LOCK / HOME_LOCK
/// The flight controller's built-in simulator is running (developer only).
bool? simulatorActive;
DateTime? updatedAt;
/// VPS height when the sensor is actually in use, else null.
double? get vpsHeight => ultrasonicBeingUsed == true ? ultrasonicHeight : null;
void apply(Map<String, dynamic> t) {
satellites = asInt(t['satelliteCount']);
gpsSignalLevel = asInt(t['gpsSignalLevel']);
isFlying = asBool(t['isFlying']);
motorsOn = asBool(t['areMotorsOn']);
flightMode = asString(t['flightMode']);
flightModeEnum = asString(t['flightModeEnum']);
altitude = finite(t['altitude']);
latitude = finite(t['latitude']);
longitude = finite(t['longitude']);
homeLatitude = finite(t['homeLatitude']);
homeLongitude = finite(t['homeLongitude']);
homeLocationSet = asBool(t['isHomeLocationSet']);
homeDistance = finite(t['homeDistance']);
horizontalSpeed = finite(t['horizontalSpeed']);
verticalSpeed = finite(t['verticalSpeed']);
heading = finite(t['heading']);
pitch = finite(t['pitch']);
roll = finite(t['roll']);
velocityX = finite(t['velocityX']);
velocityY = finite(t['velocityY']);
velocityZ = finite(t['velocityZ']);
flightTimeSeconds = asInt(t['flightTimeSeconds']);
goHomeHeight = finite(t['goHomeHeight']);
ultrasonicHeight = finite(t['ultrasonicHeight']);
ultrasonicBeingUsed = asBool(t['isUltrasonicBeingUsed']);
ultrasonicError = asBool(t['ultrasonicError']);
visionPositioningBeingUsed = asBool(t['isVisionPositioningBeingUsed']);
landingConfirmationNeeded = asBool(t['isLandingConfirmationNeeded']);
goingHome = asBool(t['isGoingHome']);
goHomeExecutionState = asString(t['goHomeExecutionState']);
final Map<String, dynamic>? gh = asMap(t['goHome']);
goHome = gh == null ? null : GoHomeAssessment.fromMap(gh);
lowBattery = asBool(t['isLowBattery']);
seriousLowBattery = asBool(t['isSeriousLowBattery']);
batteryThresholdBehavior = asString(t['batteryThresholdBehavior']);
windWarning = asString(t['windWarning']);
reachedMaxHeight = asBool(t['reachedMaxHeight']);
reachedMaxRadius = asBool(t['reachedMaxRadius']);
imuPreheating = asBool(t['isIMUPreheating']);
failsafe = asBool(t['isFailsafe']);
orientationMode = asString(t['orientationMode']);
simulatorActive = asBool(t['simulatorActive']);
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
satellites = gpsSignalLevel = flightTimeSeconds = null;
isFlying = motorsOn = homeLocationSet = ultrasonicBeingUsed = ultrasonicError = null;
visionPositioningBeingUsed = landingConfirmationNeeded = goingHome = null;
lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = null;
flightMode = flightModeEnum = goHomeExecutionState = batteryThresholdBehavior = windWarning = orientationMode = null;
altitude = latitude = longitude = homeLatitude = homeLongitude = homeDistance = null;
horizontalSpeed = verticalSpeed = heading = pitch = roll = velocityX = velocityY = velocityZ = null;
goHomeHeight = ultrasonicHeight = null;
goHome = null;
updatedAt = null;
notifyListeners();
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
enum AlertSeverity { info, warning, critical }
/// One live diagnostic from the aircraft (`DJIDiagnostics`).
@immutable
class DiagnosticAlert {
const DiagnosticAlert({
required this.component,
required this.code,
this.subCode,
this.componentIndex,
required this.reason,
this.solution,
required this.severity,
required this.firstSeen,
});
/// `FLIGHT_CONTROLLER`, `BATTERY`, `CAMERA`, `GIMBAL`, `VISION`, `REMOTE_CONTROLLER` …
final String component;
final int code;
final int? subCode;
final int? componentIndex;
final String reason;
final String? solution;
final AlertSeverity severity;
final DateTime firstSeen;
/// What de-duplicates an alert: the same fault on the same component.
String get key => '$component/$code/${componentIndex ?? 0}';
}
/// The aircraft's active diagnostics, graded and ordered for the tip marquee,
/// the error stack and the Aircraft Status sheet.
///
/// The bridge sends the *complete* current list on every change, so [apply]
/// replaces the set: what is no longer listed has cleared. An alert keeps its
/// [DiagnosticAlert.firstSeen] for as long as it stays listed, and one the pilot
/// [dismiss]ed stays hidden until it clears and comes back.
class AlertState extends ChangeNotifier {
List<DiagnosticAlert> _all = const <DiagnosticAlert>[];
final Set<String> _dismissed = <String>{};
/// Keys that appeared in the last [apply] — what P3 buzzes and pops a card for.
Set<String> lastAdded = const <String>{};
DateTime? updatedAt;
/// Every listed alert, most severe first, then oldest first.
List<DiagnosticAlert> get all => _all;
/// [all] minus what the pilot dismissed.
List<DiagnosticAlert> get active => <DiagnosticAlert>[for (final DiagnosticAlert a in _all) if (!_dismissed.contains(a.key)) a];
/// The marquee's message: the most severe alert, dismissed or not.
DiagnosticAlert? get top => _all.isEmpty ? null : _all.first;
AlertSeverity? get worst => top?.severity;
void apply(List<Map<String, dynamic>> items, {DateTime? now}) {
final DateTime t = now ?? DateTime.now();
final Map<String, DiagnosticAlert> previous = <String, DiagnosticAlert>{for (final DiagnosticAlert a in _all) a.key: a};
final Map<String, DiagnosticAlert> next = <String, DiagnosticAlert>{};
for (final Map<String, dynamic> m in items) {
final String component = asString(m['component']) ?? 'OTHER';
final int code = asInt(m['code']) ?? 0;
final String reason = (asString(m['reason']) ?? '').trim();
final String? solution = asString(m['solution'])?.trim();
final int? index = asInt(m['componentIndex']);
final String key = '$component/$code/${index ?? 0}';
if (next.containsKey(key)) continue;
next[key] = DiagnosticAlert(
component: component,
code: code,
subCode: asInt(m['subCode']),
componentIndex: index,
reason: reason.isEmpty ? '$component error $code' : reason,
solution: (solution == null || solution.isEmpty) ? null : solution,
severity: grade(component, reason, solution),
firstSeen: previous[key]?.firstSeen ?? t,
);
}
lastAdded = <String>{for (final String k in next.keys) if (!previous.containsKey(k)) k};
// A cleared alert forgets its dismissal, so it pops up again if it returns.
_dismissed.removeWhere((String k) => !next.containsKey(k));
_all = next.values.toList()
..sort((DiagnosticAlert a, DiagnosticAlert b) {
final int s = b.severity.index.compareTo(a.severity.index);
return s != 0 ? s : a.firstSeen.compareTo(b.firstSeen);
});
updatedAt = t;
notifyListeners();
}
void dismiss(String key) {
if (_dismissed.add(key)) notifyListeners();
}
void clear() {
_all = const <DiagnosticAlert>[];
_dismissed.clear();
lastAdded = const <String>{};
updatedAt = null;
notifyListeners();
}
static final RegExp _critical = RegExp(
r'critical|serious|forced? landing|landing immediately|auto[- ]?land|motor|propeller|\besc\b|overheat|'
r'imu (error|abnormal)|compass (error|abnormal)|obstacle|collision',
caseSensitive: false,
);
static final RegExp _info = RegExp(r'calibrat(ing|ion in progress)|updating|initializ|preheat', caseSensitive: false);
/// The SDK attaches no severity, so it is read off the message. A heuristic
/// until runtime capture (plan R2) maps GO 4's own code → colour table.
@visibleForTesting
static AlertSeverity grade(String component, String reason, String? solution) {
final String text = '$reason ${solution ?? ''}';
if (_critical.hasMatch(text)) return AlertSeverity.critical;
if (_info.hasMatch(text)) return AlertSeverity.info;
return AlertSeverity.warning;
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// One physical battery. Volts and amps (the bridge converts from mV / mA).
@immutable
class BatteryPack {
const BatteryPack({
required this.index,
this.percent,
this.voltage,
this.current,
this.temperature,
this.chargeRemainingMah,
this.fullChargeCapacityMah,
this.designCapacityMah,
this.discharges,
this.lifetimePercent,
this.isCharging,
this.cellVoltages = const <double>[],
this.cellVoltageLevel,
this.connectionState,
this.selfHeatingState,
});
factory BatteryPack.fromMap(Map<String, dynamic> m) => BatteryPack(
index: asInt(m['index']) ?? 0,
percent: asInt(m['percent']),
voltage: finite(m['voltage']),
current: finite(m['current']),
temperature: finite(m['temperature']),
chargeRemainingMah: asInt(m['chargeRemainingMah']),
fullChargeCapacityMah: asInt(m['fullChargeCapacityMah']),
designCapacityMah: asInt(m['designCapacityMah']),
discharges: asInt(m['discharges']),
lifetimePercent: asInt(m['lifetimePercent']),
isCharging: asBool(m['isCharging']),
cellVoltages: <double>[
if (m['cellVoltages'] is List)
for (final Object? v in m['cellVoltages'] as List) if (v is num) v.toDouble(),
],
cellVoltageLevel: asString(m['cellVoltageLevel']),
connectionState: asString(m['connectionState']),
selfHeatingState: asString(m['selfHeatingState']),
);
final int index;
final int? percent;
final double? voltage; // V
final double? current; // A, negative while discharging
final double? temperature; // °C
final int? chargeRemainingMah;
final int? fullChargeCapacityMah;
final int? designCapacityMah;
final int? discharges;
final int? lifetimePercent;
final bool? isCharging;
/// Per-cell volts; empty until the `CELL_VOLTAGES` key answers (never, on
/// non-smart batteries).
final List<double> cellVoltages;
final String? cellVoltageLevel; // LEVEL_0..LEVEL_3
final String? connectionState; // NORMAL / INVALID / EXCEPTION
final String? selfHeatingState;
/// Spread between the strongest and weakest cell — the imbalance GO 4 warns on.
double? get cellDeviation {
if (cellVoltages.length < 2) return null;
final List<double> s = List<double>.of(cellVoltages)..sort();
return s.last - s.first;
}
}
/// Every battery the aircraft carries plus the flight controller's warning
/// thresholds. Fed from the `battery` group of the telemetry tick (≤2 Hz).
class BatteryState extends ChangeNotifier {
/// The weakest pack's charge — the figure that ends the flight.
int? percent;
double? voltage; // V
double? temperature; // °C
/// Warning thresholds in %, as set on the flight controller.
int? lowWarningThreshold;
int? seriousLowWarningThreshold;
List<BatteryPack> packs = const <BatteryPack>[];
DateTime? updatedAt;
bool get isMultiPack => packs.length > 1;
void apply(Map<String, dynamic> b) {
percent = asInt(b['percent']);
voltage = finite(b['voltage']);
temperature = finite(b['temperature']);
lowWarningThreshold = asInt(b['lowWarningThreshold']);
seriousLowWarningThreshold = asInt(b['seriousLowWarningThreshold']);
packs = asMapList(b['packs']).map(BatteryPack.fromMap).toList(growable: false);
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
percent = lowWarningThreshold = seriousLowWarningThreshold = null;
voltage = temperature = null;
packs = const <BatteryPack>[];
updatedAt = null;
notifyListeners();
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// The camera's storage (SD card, or internal storage on cameras that have it).
@immutable
class StorageInfo {
const StorageInfo({
this.location,
this.inserted = false,
this.initializing = false,
this.formatting = false,
this.formatted = false,
this.full = false,
this.error = false,
this.readOnly = false,
this.invalidFormat = false,
this.verified = false,
this.totalMB,
this.remainingMB,
this.availableCaptureCount,
this.availableRecordingSeconds,
});
factory StorageInfo.fromMap(Map<String, dynamic> m) => StorageInfo(
location: asString(m['location']),
inserted: asBool(m['inserted']) ?? false,
initializing: asBool(m['initializing']) ?? false,
formatting: asBool(m['formatting']) ?? false,
formatted: asBool(m['formatted']) ?? false,
full: asBool(m['full']) ?? false,
error: asBool(m['error']) ?? false,
readOnly: asBool(m['readOnly']) ?? false,
invalidFormat: asBool(m['invalidFormat']) ?? false,
verified: asBool(m['verified']) ?? false,
totalMB: asInt(m['totalMB']),
remainingMB: asInt(m['remainingMB']),
availableCaptureCount: asInt(m['availableCaptureCount']),
availableRecordingSeconds: asInt(m['availableRecordingSeconds']),
);
final String? location; // SDCARD / INTERNAL_STORAGE
final bool inserted;
final bool initializing;
final bool formatting;
final bool formatted;
final bool full;
final bool error;
final bool readOnly;
final bool invalidFormat;
final bool verified;
final int? totalMB;
final int? remainingMB;
final int? availableCaptureCount;
final int? availableRecordingSeconds;
/// One word for the HUD's storage chip, worst condition first.
String get status {
if (!inserted) return 'No SD';
if (error) return 'SD error';
if (invalidFormat) return 'Format SD';
if (formatting) return 'Formatting';
if (initializing) return 'Initializing';
if (full) return 'SD full';
if (readOnly) return 'Read-only';
return 'OK';
}
bool get usable => inserted && !error && !invalidFormat && !formatting && !initializing && !full;
}
/// Camera state from the bridge: recording, exposure read-back, storage and
/// the watched camera settings. Recording and exposure arrive as their own
/// `camera` / `exposure` events; storage and settings as the `storage` and
/// `cameraSettings` groups of the telemetry tick (≤2 Hz).
class CameraState extends ChangeNotifier {
bool isRecording = false;
int recordSeconds = 0;
/// `SHOOT_PHOTO` / `RECORD_VIDEO` / `PLAYBACK` / `MEDIA_DOWNLOAD`.
String? mode;
// Exposure read-back, in the same labels the setters accept.
String? iso; // "100", "AUTO", …
String? shutter; // "1/240"
String? aperture; // "f/2.8"
String? ev; // "-0.3"
StorageInfo? storage;
bool? aeLock;
String? focusMode; // MANUAL / AUTO / AFC
String? photoAspectRatio; // RATIO_4_3 / RATIO_16_9 / RATIO_3_2
String? photoFileFormat; // JPEG / RAW / RAW_AND_JPEG
String? videoResolution; // RESOLUTION_3840x2160 …
String? videoFrameRate; // FRAME_RATE_29_DOT_970_FPS …
String? videoFileFormat; // MOV / MP4
String? colorProfile; // NONE / D_LOG / …
DateTime? updatedAt;
void applySystem(Map<String, dynamic> e) {
isRecording = asBool(e['isRecording']) ?? false;
recordSeconds = asInt(e['recordingTimeSeconds']) ?? recordSeconds;
mode = asString(e['mode']) ?? mode;
notifyListeners();
}
void applyExposure(Map<String, dynamic> e) {
iso = asString(e['iso']);
shutter = asString(e['shutter']);
aperture = asString(e['aperture']);
ev = asString(e['ev']);
notifyListeners();
}
/// From the telemetry tick; an absent group means the camera never reported it.
void applyTelemetry({Map<String, dynamic>? storage, Map<String, dynamic>? settings}) {
this.storage = storage == null ? null : StorageInfo.fromMap(storage);
aeLock = asBool(settings?['aeLock']);
focusMode = asString(settings?['focusMode']);
photoAspectRatio = asString(settings?['photoAspectRatio']);
photoFileFormat = asString(settings?['photoFileFormat']);
videoResolution = asString(settings?['videoResolution']);
videoFrameRate = asString(settings?['videoFrameRate']);
videoFileFormat = asString(settings?['videoFileFormat']);
colorProfile = asString(settings?['colorProfile']);
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
isRecording = false;
recordSeconds = 0;
mode = iso = shutter = aperture = ev = null;
storage = null;
aeLock = null;
focusMode = photoAspectRatio = photoFileFormat = videoResolution = videoFrameRate = videoFileFormat = colorProfile = null;
updatedAt = null;
notifyListeners();
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// Gimbal attitude in degrees. Fed from the `gimbal` group of the telemetry tick.
class GimbalState extends ChangeNotifier {
double? pitch; // −90..+30-ish
double? roll;
double? yaw;
DateTime? updatedAt;
void apply(Map<String, dynamic> g) {
pitch = finite(g['pitch']);
roll = finite(g['roll']);
yaw = finite(g['yaw']);
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
pitch = roll = yaw = null;
updatedAt = null;
notifyListeners();
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// Radio links and the remote controller's battery. Fed from the `links` and
/// `rc` groups of the telemetry tick (≤2 Hz).
class LinkState extends ChangeNotifier {
/// `ocusync` / `lightbridge` / `wifi` as the air link reports it at runtime.
String? kind;
/// RC → aircraft control-link quality, 0–100.
int? uplinkQuality;
/// Aircraft → RC video-link quality, 0–100.
int? downlinkQuality;
int? rcBatteryPercent;
int? rcBatteryMah;
bool? rcCharging;
DateTime? updatedAt;
/// 0–5 bars, the way the top bar draws a 0–100 quality.
static int bars(int? quality) => quality == null ? 0 : ((quality.clamp(0, 100) + 19) ~/ 20);
void apply({Map<String, dynamic>? links, Map<String, dynamic>? rc}) {
kind = asString(links?['kind']);
uplinkQuality = asInt(links?['uplinkQuality']);
downlinkQuality = asInt(links?['downlinkQuality']);
rcBatteryPercent = asInt(rc?['batteryPercent']);
rcBatteryMah = asInt(rc?['batteryMah']);
rcCharging = asBool(rc?['isCharging']);
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
kind = null;
uplinkQuality = downlinkQuality = rcBatteryPercent = rcBatteryMah = null;
rcCharging = null;
updatedAt = null;
notifyListeners();
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events.
class MissionState extends ChangeNotifier {
String state = 'idle'; // idle / ready / uploading / executing / tracking / quickshot …
String? error;
bool get running => state != 'idle';
bool get tracking => state == 'tracking' || state == 'quickshot';
void apply(Map<String, dynamic> e) {
state = asString(e['state']) ?? 'idle';
error = asString(e['error']);
notifyListeners();
}
void clear() {
state = 'idle';
error = null;
notifyListeners();
}
}
+42
View File
@@ -0,0 +1,42 @@
/// Lenient readers for bridge maps: the platform channel hands numbers over as
/// int or double depending on the Java type, and nested maps as `Map<Object?, Object?>`.
library;
double? asDouble(Object? v) => v is num ? v.toDouble() : null;
int? asInt(Object? v) => v is num ? v.toInt() : null;
bool? asBool(Object? v) => v is bool ? v : null;
String? asString(Object? v) => v is String ? v : null;
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
List<Map<String, dynamic>> asMapList(Object? v) =>
v is List ? <Map<String, dynamic>>[for (final Object? e in v) if (e is Map) Map<String, dynamic>.from(e)] : const <Map<String, dynamic>>[];
/// Doubles that are actually reported: the SDK fills unknown floats with NaN.
double? finite(Object? v) {
final double? d = asDouble(v);
return d == null || d.isNaN || d.isInfinite ? null : d;
}
/// Structural equality for decoded channel values (maps, lists, scalars).
bool deepEquals(Object? a, Object? b) {
if (identical(a, b)) return true;
if (a is Map && b is Map) {
if (a.length != b.length) return false;
for (final Object? k in a.keys) {
if (!b.containsKey(k) || !deepEquals(a[k], b[k])) return false;
}
return true;
}
if (a is List && b is List) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i])) return false;
}
return true;
}
return a == b;
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// One obstacle-detection sector of a sensor, left to right.
@immutable
class VisionSector {
const VisionSector({this.distance, this.warningLevel});
final double? distance; // m
/// `LEVEL_1` (far) … `LEVEL_6` (very close), or `INVALID`.
final String? warningLevel;
/// 0 when invalid or unknown, else 1–6.
int get level {
final String? w = warningLevel;
if (w == null || !w.startsWith('LEVEL_')) return 0;
return int.tryParse(w.substring(6)) ?? 0;
}
}
/// What one vision sensor position (nose, tail, left, right) sees.
@immutable
class VisionReading {
const VisionReading({
required this.position,
this.isSensorBeingUsed = false,
this.isDisabled = false,
this.systemWarning,
this.distance,
this.avoidAlertLevel,
this.sectors = const <VisionSector>[],
});
factory VisionReading.fromMap(String position, Map<String, dynamic> m) => VisionReading(
position: position,
isSensorBeingUsed: asBool(m['isSensorBeingUsed']) ?? false,
isDisabled: asBool(m['isDisabled']) ?? false,
systemWarning: asString(m['systemWarning']),
distance: finite(m['distance']),
avoidAlertLevel: asInt(m['avoidAlertLevel']),
sectors: <VisionSector>[
for (final Map<String, dynamic> s in asMapList(m['sectors']))
VisionSector(distance: finite(s['distance']), warningLevel: asString(s['warningLevel'])),
],
);
final String position; // NOSE / TAIL / LEFT / RIGHT
final bool isSensorBeingUsed;
final bool isDisabled;
final String? systemWarning; // SAFE / DANGEROUS / INVALID
final double? distance; // m, closest obstacle
final int? avoidAlertLevel;
final List<VisionSector> sectors;
bool get dangerous => systemWarning == 'DANGEROUS';
}
/// Obstacle sensing per sensor position. Fed from the `vision` group of the
/// telemetry tick; empty on aircraft without obstacle sensing.
class VisionState extends ChangeNotifier {
Map<String, VisionReading> sensors = const <String, VisionReading>{};
DateTime? updatedAt;
VisionReading? get nose => sensors['NOSE'];
VisionReading? get tail => sensors['TAIL'];
/// The nearest obstacle any sensor reports, in metres.
double? get closest {
double? best;
for (final VisionReading r in sensors.values) {
final double? d = r.distance;
if (d != null && (best == null || d < best)) best = d;
}
return best;
}
void apply(Map<String, dynamic>? vision) {
sensors = <String, VisionReading>{
if (vision != null)
for (final MapEntry<String, dynamic> e in vision.entries)
if (e.value is Map) e.key: VisionReading.fromMap(e.key, Map<String, dynamic>.from(e.value as Map)),
};
if (vision != null) updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
sensors = const <String, VisionReading>{};
updatedAt = null;
notifyListeners();
}
}
+79
View File
@@ -0,0 +1,79 @@
import '../flight_model.dart';
import 'state/parse.dart';
/// Routes the bridge's telemetry-bearing events into [FlightModel]'s sub-models
/// (plan P1). Kept out of the widget tree so it can be unit-tested against
/// recorded events.
///
/// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight
/// controller's flat keys plus one nested group per subsystem (`battery`,
/// `links`, `rc`, `storage`, `cameraSettings`, `gimbal`, `vision`). It arrives at
/// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz
/// refreshes, so each group is compared with the last one seen and its
/// sub-model only notified when it actually moved. A group missing from the
/// snapshot was never reported by this aircraft and leaves its sub-model empty.
class TelemetryReducer {
TelemetryReducer(this.model);
final FlightModel model;
final Map<String, Object?> _lastGroups = <String, Object?>{};
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'cameraSettings', 'gimbal', 'vision'];
/// Applies [event] if it is one of the telemetry types; returns whether it was.
bool apply(Map<String, dynamic> event) {
switch (event['type'] as String?) {
case 'telemetry':
// Stray packets trailing a disconnect would repopulate what was cleared.
if (model.connected) model.batch(() => _telemetry(event));
return true;
case 'camera':
if (model.connected) model.camera.applySystem(event);
return true;
case 'exposure':
if (model.connected) model.camera.applyExposure(event);
return true;
case 'diagnostics':
if (model.connected) model.alerts.apply(asMapList(event['items']));
return true;
case 'mission':
model.mission.apply(event);
return true;
}
return false;
}
/// Forgets the last-seen groups, so the next aircraft's first tick applies in full.
void reset() => _lastGroups.clear();
void _telemetry(Map<String, dynamic> t) {
// Flight-controller keys change every tick; absent until the FC first reports.
if (t.containsKey('satelliteCount')) model.aircraft.apply(t);
final Set<String> changed = <String>{};
for (final String g in _groups) {
final Object? v = t[g];
if (_lastGroups.containsKey(g) && deepEquals(_lastGroups[g], v)) continue;
_lastGroups[g] = v;
changed.add(g);
}
if (changed.isEmpty) return;
if (changed.contains('battery')) {
final Map<String, dynamic>? b = asMap(t['battery']);
b == null ? model.battery.clear() : model.battery.apply(b);
}
if (changed.contains('links') || changed.contains('rc')) {
model.links.apply(links: asMap(t['links']), rc: asMap(t['rc']));
}
if (changed.contains('storage') || changed.contains('cameraSettings')) {
model.camera.applyTelemetry(storage: asMap(t['storage']), settings: asMap(t['cameraSettings']));
}
if (changed.contains('gimbal')) {
final Map<String, dynamic>? g = asMap(t['gimbal']);
g == null ? model.gimbal.clear() : model.gimbal.apply(g);
}
if (changed.contains('vision')) model.vision.apply(asMap(t['vision']));
}
}
+6 -76
View File
@@ -7,6 +7,7 @@ import 'package:geolocator/geolocator.dart';
import 'dji_service.dart';
import 'flight_model.dart';
import 'fly/product_profile.dart';
import 'fly/telemetry_reducer.dart';
import 'login_page.dart';
import 'pb_auth.dart';
import 'theme.dart';
@@ -50,6 +51,7 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> {
final DjiService _dji = DjiService();
final FlightModel _model = FlightModel();
late final TelemetryReducer _telemetry = TelemetryReducer(_model);
StreamSubscription<Map<String, dynamic>>? _sub;
StreamSubscription<AuthStatus>? _authSub;
StreamSubscription<Position>? _phoneLocSub;
@@ -133,6 +135,8 @@ class _HomePageState extends State<HomePage> {
if (!mounted) return;
// Forward every event to the API server (throttled internally).
_uploader.onEvent(event);
// Telemetry, camera, diagnostics and mission state go to the sub-models.
if (_telemetry.apply(event)) return;
switch (event['type'] as String?) {
case 'registration':
switch (event['state'] as String?) {
@@ -181,68 +185,11 @@ class _HomePageState extends State<HomePage> {
event['controllerFirmware'] as String? ?? _model.controllerFirmwareVersion;
_model.bump();
break;
case 'telemetry':
// Ignore stray telemetry that arrives after a disconnect — otherwise it
// repopulates values _clearTelemetry() just wiped, leaving stale readings.
if (!_model.connected) break;
_model.satellites = event['satelliteCount'] as int?;
_model.gpsSignalLevel = event['gpsSignalLevel'] as int?;
_model.isFlying = event['isFlying'] as bool?;
_model.motorsOn = event['areMotorsOn'] as bool?;
_model.flightMode = event['flightMode'] as String?;
_model.altitude = (event['altitude'] as num?)?.toDouble();
_model.latitude = (event['latitude'] as num?)?.toDouble();
_model.longitude = (event['longitude'] as num?)?.toDouble();
_model.homeLatitude = (event['homeLatitude'] as num?)?.toDouble();
_model.homeLongitude = (event['homeLongitude'] as num?)?.toDouble();
_model.homeDistance = (event['homeDistance'] as num?)?.toDouble();
_model.horizontalSpeed = (event['horizontalSpeed'] as num?)?.toDouble();
_model.verticalSpeed = (event['verticalSpeed'] as num?)?.toDouble();
_model.heading = (event['heading'] as num?)?.toDouble();
_model.goHomeHeight = (event['goHomeHeight'] as num?)?.toDouble();
_model.bump();
break;
case 'battery':
// Same guard: a battery packet trailing a disconnect must not revive the
// last percentage (the reported "disconnected but still 42%" bug).
if (!_model.connected) break;
_model.batteryPercent = event['percent'] as int?;
_model.batteryVoltage = (event['voltage'] as num?)?.toDouble();
_model.batteryTemperature = (event['temperature'] as num?)?.toDouble();
_model.bump();
break;
case 'camera':
if (!_model.connected) break;
_model.isRecording = event['isRecording'] as bool? ?? false;
_model.recordSeconds = (event['recordingTimeSeconds'] as num?)?.toInt() ?? _model.recordSeconds;
_model.bump();
break;
case 'exposure':
if (!_model.connected) break;
_model.iso = event['iso'] as String?;
_model.shutter = event['shutter'] as String?;
_model.aperture = event['aperture'] as String?;
_model.ev = event['ev'] as String?;
_model.bump();
break;
case 'gimbal':
if (!_model.connected) break;
_model.gimbalPitch = (event['pitch'] as num?)?.toDouble();
_model.gimbalRoll = (event['roll'] as num?)?.toDouble();
_model.gimbalYaw = (event['yaw'] as num?)?.toDouble();
_model.bump();
break;
case 'djiAccount':
_model.djiAccountState = event['state'] as String? ?? 'unknown';
_model.djiAccountUser = event['user'] as String?;
_model.bump();
break;
case 'mission':
_model.missionState = event['state'] as String? ?? 'idle';
_model.tracking = _model.missionState == 'tracking' || _model.missionState == 'quickshot';
_model.missionRunning = _model.missionState != 'idle';
_model.bump();
break;
case 'mediaList':
final List<dynamic> files = (event['files'] as List<dynamic>?) ?? const <dynamic>[];
_model.media = files
@@ -263,25 +210,8 @@ class _HomePageState extends State<HomePage> {
}
void _clearTelemetry() {
_model.satellites = null;
_model.gpsSignalLevel = null;
_model.isFlying = null;
_model.motorsOn = null;
_model.flightMode = null;
_model.altitude = null;
_model.latitude = null;
_model.longitude = null;
_model.homeLatitude = null;
_model.homeLongitude = null;
_model.homeDistance = null;
_model.horizontalSpeed = null;
_model.verticalSpeed = null;
_model.heading = null;
_model.batteryPercent = null;
_model.batteryVoltage = null;
_model.batteryTemperature = null;
_model.isRecording = false;
_model.recordSeconds = 0;
_model.clearTelemetry();
_telemetry.reset();
_model.firmwareVersion = null;
_model.controllerFirmwareVersion = null;
_model.flightControllerSerial = null;
@@ -0,0 +1,355 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/state/aircraft_state.dart';
import '../../../fly/state/alert_state.dart';
import '../../../fly/state/battery_state.dart';
import '../../../fly/state/camera_state.dart';
import '../../../fly/state/vision_state.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
/// Debug screen for the telemetry foundation (plan P1): every field the bridge
/// now reports, grouped by sub-model, each section rebuilt only by its own
/// sub-model — the same listening pattern the P2 HUD uses — with the age of
/// its last update so a stalled source is obvious. The header starts and stops
/// the flight controller's simulator, where P1's fields are verified.
class TelemetryInspectorPage extends StatelessWidget {
const TelemetryInspectorPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
Future<void> _toggleSimulator(BuildContext context) async {
final bool active = model.aircraft.simulatorActive == true;
if (!active) {
final bool? go = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Start the simulator?'),
content: const Text('Remove the propellers first: the motors can spin while the simulator runs. '
'The aircraft must be on the ground with motors off.'),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Propellers are off — start')),
],
),
);
if (go != true) return;
}
try {
if (active) {
await dji.stopSimulator();
} else {
// Start where the pilot is, so the map and home distance make sense.
await dji.startSimulator(
latitude: model.phoneLatitude ?? model.aircraft.latitude ?? 55.6761,
longitude: model.phoneLongitude ?? model.aircraft.longitude ?? 12.5683,
);
}
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text('Simulator: ${e is PlatformException ? (e.message ?? e.code) : e}')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 10, 6),
child: Row(children: <Widget>[
const Expanded(
child: Text('Telemetry inspector',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
),
ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model, model.aircraft]),
builder: (BuildContext context, _) {
final bool active = model.aircraft.simulatorActive == true;
final bool canStart = model.connected && model.profile.hasSimulator && model.aircraft.motorsOn != true;
return TextButton.icon(
onPressed: active || canStart ? () => _toggleSimulator(context) : null,
icon: Icon(active ? Icons.stop_circle_outlined : Icons.play_circle_outline, size: 18),
label: Text(active ? 'Stop simulator' : 'Simulator'),
style: TextButton.styleFrom(foregroundColor: active ? const Color(0xFFD9852B) : Glass.sat),
);
},
),
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const PVIcon('close', size: 17, color: Glass.ink),
),
]),
),
Expanded(child: TelemetryInspector(model: model)),
]),
),
);
}
}
/// The inspector body, separate from the page so widget tests can pump it.
class TelemetryInspector extends StatefulWidget {
const TelemetryInspector({super.key, required this.model});
final FlightModel model;
@override
State<TelemetryInspector> createState() => _TelemetryInspectorState();
}
class _TelemetryInspectorState extends State<TelemetryInspector> {
/// Ticks the "updated … ago" labels; sections otherwise rebuild on their own data.
final ValueNotifier<DateTime> _clock = ValueNotifier<DateTime>(DateTime.now());
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _clock.value = DateTime.now());
}
@override
void dispose() {
_timer?.cancel();
_clock.dispose();
super.dispose();
}
FlightModel get _m => widget.model;
@override
Widget build(BuildContext context) {
return ListView(padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: <Widget>[
_Section(
title: 'Aircraft · 10 Hz',
listenable: _m.aircraft,
clock: _clock,
updatedAt: () => _m.aircraft.updatedAt,
rows: () => _aircraftRows(_m.aircraft),
),
_Section(
title: 'Return to home',
listenable: _m.aircraft,
clock: _clock,
updatedAt: () => _m.aircraft.updatedAt,
rows: () => _goHomeRows(_m.aircraft),
),
_Section(
title: 'Battery · 2 Hz',
listenable: _m.battery,
clock: _clock,
updatedAt: () => _m.battery.updatedAt,
rows: () => _batteryRows(_m.battery),
),
_Section(
title: 'Links · 2 Hz',
listenable: _m.links,
clock: _clock,
updatedAt: () => _m.links.updatedAt,
rows: () => <(String, String)>[
('Kind', _s(_m.links.kind)),
('Uplink (RC)', _q(_m.links.uplinkQuality)),
('Downlink (HD)', _q(_m.links.downlinkQuality)),
('RC battery', _m.links.rcBatteryPercent == null
? '—'
: '${_m.links.rcBatteryPercent}% · ${_m.links.rcBatteryMah ?? '—'} mAh${_m.links.rcCharging == true ? ' · charging' : ''}'),
],
),
_Section(
title: 'Camera · 2 Hz',
listenable: _m.camera,
clock: _clock,
updatedAt: () => _m.camera.updatedAt,
rows: () => _cameraRows(_m.camera),
),
_Section(
title: 'Gimbal · 10 Hz',
listenable: _m.gimbal,
clock: _clock,
updatedAt: () => _m.gimbal.updatedAt,
rows: () => <(String, String)>[
('Pitch / roll / yaw', '${_deg(_m.gimbal.pitch)} / ${_deg(_m.gimbal.roll)} / ${_deg(_m.gimbal.yaw)}'),
],
),
_Section(
title: 'Vision · 10 Hz',
listenable: _m.vision,
clock: _clock,
updatedAt: () => _m.vision.updatedAt,
rows: () => _visionRows(_m.vision),
),
_Section(
title: 'Diagnostics',
listenable: _m.alerts,
clock: _clock,
updatedAt: () => _m.alerts.updatedAt,
rows: () => _alertRows(_m.alerts),
),
]);
}
static List<(String, String)> _aircraftRows(AircraftState a) => <(String, String)>[
('Flight mode', '${_s(a.flightMode)} (${_s(a.flightModeEnum)})'),
('Flying / motors', '${_b(a.isFlying)} / ${_b(a.motorsOn)}'),
('GPS', '${a.satellites ?? '—'} sats · level ${a.gpsSignalLevel ?? '—'}'),
('Attitude p/r/y', '${_deg(a.pitch)} / ${_deg(a.roll)} / ${_deg(a.heading)}'),
('Height', _m1(a.altitude)),
('VPS', a.vpsHeight == null
? 'not in use${a.ultrasonicError == true ? ' · ERROR' : ''}'
: '${_m1(a.vpsHeight)}${a.ultrasonicError == true ? ' · ERROR' : ''}'),
('Vision positioning', _b(a.visionPositioningBeingUsed)),
('H / V speed', '${_ms(a.horizontalSpeed)} / ${_ms(a.verticalSpeed)}'),
('Position', a.latitude == null ? '—' : '${a.latitude!.toStringAsFixed(6)}, ${a.longitude!.toStringAsFixed(6)}'),
('Home', a.homeLatitude == null ? 'not set' : '${_m1(a.homeDistance)} away'),
('Flight time', _mmss(a.flightTimeSeconds)),
('Landing confirm', _b(a.landingConfirmationNeeded)),
('Low / serious low', '${_b(a.lowBattery)} / ${_b(a.seriousLowBattery)} · ${_s(a.batteryThresholdBehavior)}'),
('Wind', _s(a.windWarning)),
('Max height / radius hit', '${_b(a.reachedMaxHeight)} / ${_b(a.reachedMaxRadius)}'),
('IMU preheating', _b(a.imuPreheating)),
('Orientation', _s(a.orientationMode)),
('Simulator', _b(a.simulatorActive)),
];
static List<(String, String)> _goHomeRows(AircraftState a) {
final GoHomeAssessment? g = a.goHome;
return <(String, String)>[
('Going home', '${_b(a.goingHome)} · ${_s(a.goHomeExecutionState)}'),
('RTH height', _m1(a.goHomeHeight)),
('Remaining flight', _mmss(g?.remainingFlightTime)),
('Needed to go home', g == null ? '—' : '${g.batteryNeededToGoHome ?? '—'}% · ${_mmss(g.timeNeededToGoHome)}'),
('Needed to land', g == null ? '—' : '${g.batteryNeededToLand ?? '—'}% · ${_mmss(g.timeNeededToLand)}'),
('Max radius & return', _m1(g?.maxRadiusCanFlyAndGoHome)),
('Smart RTH', g == null ? '—' : '${_s(g.smartRTHState)} · ${g.smartRTHCountdown ?? '—'} s'),
];
}
static List<(String, String)> _batteryRows(BatteryState b) => <(String, String)>[
('Weakest pack', b.percent == null ? '—' : '${b.percent}% · ${_v(b.voltage)} · ${_c(b.temperature)}'),
('Thresholds', 'low ${b.lowWarningThreshold ?? '—'}% · serious ${b.seriousLowWarningThreshold ?? '—'}%'),
for (final BatteryPack p in b.packs) ...<(String, String)>[
('Pack ${p.index}', '${p.percent ?? '—'}% · ${_v(p.voltage)} · ${p.current?.toStringAsFixed(2) ?? '—'} A · ${_c(p.temperature)}'),
(' charge', '${p.chargeRemainingMah ?? '—'} / ${p.fullChargeCapacityMah ?? '—'} mAh (design ${p.designCapacityMah ?? '—'})'),
(' cells', p.cellVoltages.isEmpty
? '—'
: '${p.cellVoltages.map((double v) => v.toStringAsFixed(2)).join(' ')} V · Δ ${(p.cellDeviation! * 1000).round()} mV'),
(' health', '${p.discharges ?? '—'} cycles · life ${p.lifetimePercent ?? '—'}% · ${_s(p.connectionState)}'),
],
];
static List<(String, String)> _cameraRows(CameraState c) {
final StorageInfo? s = c.storage;
return <(String, String)>[
('Mode', '${_s(c.mode)}${c.isRecording ? ' · REC ${_mmss(c.recordSeconds)}' : ''}'),
('Exposure', 'ISO ${_s(c.iso)} · ${_s(c.shutter)} · ${_s(c.aperture)} · EV ${_s(c.ev)}'),
('Storage', s == null ? '—' : '${s.status} · ${_s(s.location)}'),
('Space', s == null ? '—' : '${s.remainingMB ?? '—'} / ${s.totalMB ?? '—'} MB'),
('Remaining', s == null ? '—' : '${s.availableCaptureCount ?? '—'} shots · ${_mmss(s.availableRecordingSeconds)}'),
('AE lock / focus', '${_b(c.aeLock)} / ${_s(c.focusMode)}'),
('Photo', '${_s(c.photoAspectRatio)} · ${_s(c.photoFileFormat)}'),
('Video', '${_s(c.videoResolution)} · ${_s(c.videoFrameRate)} · ${_s(c.videoFileFormat)}'),
('Colour', _s(c.colorProfile)),
];
}
static List<(String, String)> _visionRows(VisionState v) {
if (v.sensors.isEmpty) return const <(String, String)>[('Sensors', 'none reporting')];
return <(String, String)>[
('Closest', _m1(v.closest)),
for (final VisionReading r in v.sensors.values)
(
r.position,
'${r.isSensorBeingUsed ? 'active' : 'idle'}${r.isDisabled ? ' · disabled' : ''} · ${_s(r.systemWarning)} · '
'${_m1(r.distance)} · [${r.sectors.map((VisionSector s) => s.distance?.toStringAsFixed(1) ?? '·').join(' ')}]'
),
];
}
static List<(String, String)> _alertRows(AlertState a) {
if (a.all.isEmpty) return const <(String, String)>[('Active', 'none')];
return <(String, String)>[
for (final DiagnosticAlert d in a.all) ('${d.severity.name} · ${d.component} ${d.code}', '${d.reason}${d.solution == null ? '' : ' — ${d.solution}'}'),
];
}
static String _s(String? v) => v ?? '—';
static String _b(bool? v) => v == null ? '—' : (v ? 'yes' : 'no');
static String _q(int? v) => v == null ? '—' : '$v / 100';
static String _deg(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)}°';
static String _m1(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} m';
static String _ms(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} m/s';
static String _v(double? v) => v == null ? '—' : '${v.toStringAsFixed(2)} V';
static String _c(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} °C';
static String _mmss(int? s) => s == null || s < 0 ? '—' : '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}';
}
class _Section extends StatelessWidget {
const _Section({
required this.title,
required this.listenable,
required this.clock,
required this.updatedAt,
required this.rows,
});
final String title;
final Listenable listenable;
final ValueNotifier<DateTime> clock;
final DateTime? Function() updatedAt;
final List<(String, String)> Function() rows;
static const TextStyle _label = TextStyle(fontFamily: PV.fontSans, fontSize: 12, color: Glass.ink);
static const TextStyle _value = TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: Color(0xCCEAF0FA));
static const TextStyle _eyebrow =
TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA));
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 18, bottom: 6),
child: Row(children: <Widget>[
Expanded(child: Text(title.toUpperCase(), style: _eyebrow)),
ListenableBuilder(
listenable: Listenable.merge(<Listenable>[listenable, clock]),
builder: (BuildContext context, _) {
final DateTime? at = updatedAt();
final int? ms = at == null ? null : clock.value.difference(at).inMilliseconds;
final bool stale = ms == null || ms > 3000;
return Text(
ms == null ? 'no data' : (ms < 1000 ? 'live' : '${(ms / 1000).toStringAsFixed(0)} s ago'),
style: _eyebrow.copyWith(color: stale ? const Color(0xFFD9852B) : Glass.sat),
);
},
),
]),
),
ListenableBuilder(
listenable: listenable,
builder: (BuildContext context, _) => Column(children: <Widget>[
for (final (String, String) r in rows())
Container(
padding: const EdgeInsets.symmetric(vertical: 5),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
SizedBox(width: 150, child: Text(r.$1, style: _label)),
Expanded(child: Text(r.$2, style: _value)),
]),
),
]),
),
]);
}
}
+8
View File
@@ -5,6 +5,7 @@ import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'fly/debug/product_profile_page.dart';
import 'fly/debug/telemetry_inspector_page.dart';
import 'pv_icons.dart';
/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column
@@ -52,6 +53,12 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
));
}
void _openTelemetry() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => TelemetryInspectorPage(model: _m, dji: _dji),
));
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
@@ -148,6 +155,7 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
('MSDK', _m.sdkVersion),
]),
_infoRow('Capability profile', _m.profile.connected ? 'View' : '—', onTap: _openProfile),
_infoRow('Telemetry inspector', 'View', onTap: _openTelemetry),
]),
};
return Padding(
+165
View File
@@ -0,0 +1,165 @@
/// Synthetic bridge events in the shape `TelemetryHub.kt` and the sub-bridges
/// emit, standing in for a simulator recording until one is captured (plan §4).
library;
/// A Phantom 4-like aircraft hovering at 32 m, all groups reported.
Map<String, dynamic> telemetryTick({
int battery = 64,
double altitude = 32.4,
double gimbalPitch = -30,
int uplink = 88,
double noseDistance = 6.5,
String? smartRTHState = 'IDLE',
}) =>
<String, dynamic>{
'type': 'telemetry',
'satelliteCount': 17,
'gpsSignalLevel': 5,
'isFlying': true,
'areMotorsOn': true,
'flightMode': 'P-GPS',
'flightModeEnum': 'GPS_ATTI',
'altitude': altitude,
'latitude': 55.6761,
'longitude': 12.5683,
'homeLatitude': 55.6760,
'homeLongitude': 12.5680,
'isHomeLocationSet': true,
'homeDistance': 21.3,
'horizontalSpeed': 0.2,
'verticalSpeed': -0.1,
'heading': 87.5,
'pitch': -1.2,
'roll': 0.4,
'yaw': 87.5,
'goHomeHeight': 60,
'velocityX': 0.1,
'velocityY': 0.15,
'velocityZ': 0.1,
'flightTimeSeconds': 245,
'ultrasonicHeight': double.nan,
'isUltrasonicBeingUsed': false,
'ultrasonicError': false,
'isVisionPositioningBeingUsed': false,
'isLandingConfirmationNeeded': false,
'isGoingHome': false,
'goHomeExecutionState': 'NOT_EXECUTING',
'isLowBattery': false,
'isSeriousLowBattery': false,
'batteryThresholdBehavior': 'FLY_NORMALLY',
'windWarning': 'LEVEL_0',
'reachedMaxHeight': false,
'reachedMaxRadius': false,
'isIMUPreheating': false,
'isFailsafe': false,
'orientationMode': 'AIRCRAFT_HEADING',
'goHome': <Object?, Object?>{
'remainingFlightTime': 780,
'timeNeededToGoHome': 40,
'timeNeededToLand': 22,
'batteryNeededToGoHome': 12,
'batteryNeededToLand': 6,
'maxRadiusCanFlyAndGoHome': 2400.0,
'smartRTHState': smartRTHState,
'smartRTHCountdown': 0,
},
'gimbal': <Object?, Object?>{'pitch': gimbalPitch, 'roll': 0.0, 'yaw': 87.0},
'vision': <Object?, Object?>{
'NOSE': <Object?, Object?>{
'isSensorBeingUsed': true,
'isDisabled': false,
'systemWarning': 'SAFE',
'distance': noseDistance,
'avoidAlertLevel': 0,
'sectors': <Object?>[
<Object?, Object?>{'distance': 9.0, 'warningLevel': 'LEVEL_1'},
<Object?, Object?>{'distance': noseDistance, 'warningLevel': 'LEVEL_2'},
<Object?, Object?>{'distance': 12.0, 'warningLevel': 'LEVEL_1'},
<Object?, Object?>{'distance': 15.0, 'warningLevel': 'INVALID'},
],
},
},
'battery': <Object?, Object?>{
'percent': battery,
'voltage': 15.6,
'temperature': 38.5,
'lowWarningThreshold': 30,
'seriousLowWarningThreshold': 15,
'packs': <Object?>[
<Object?, Object?>{
'index': 0,
'percent': battery,
'voltage': 15.6,
'current': -9.8,
'temperature': 38.5,
'chargeRemainingMah': 3500,
'fullChargeCapacityMah': 5350,
'designCapacityMah': 5870,
'discharges': 42,
'lifetimePercent': 91,
'isCharging': false,
'cellVoltages': <Object?>[3.91, 3.90, 3.92, 3.88],
'cellVoltageLevel': 'LEVEL_0',
'connectionState': 'NORMAL',
},
],
},
'links': <Object?, Object?>{'kind': 'lightbridge', 'uplinkQuality': uplink, 'downlinkQuality': 74},
'rc': <Object?, Object?>{'batteryPercent': 81, 'batteryMah': 4200, 'isCharging': false},
'storage': <Object?, Object?>{
'location': 'SDCARD',
'inserted': true,
'initializing': false,
'formatting': false,
'formatted': true,
'full': false,
'error': false,
'readOnly': false,
'invalidFormat': false,
'verified': true,
'totalMB': 61000,
'remainingMB': 48211,
'availableCaptureCount': 5400,
'availableRecordingSeconds': 7260,
},
'cameraSettings': <Object?, Object?>{
'aeLock': false,
'focusMode': 'AFC',
'photoAspectRatio': 'RATIO_3_2',
'photoFileFormat': 'RAW_AND_JPEG',
'videoResolution': 'RESOLUTION_3840x2160',
'videoFrameRate': 'FRAME_RATE_29_DOT_970_FPS',
'videoFileFormat': 'MOV',
'colorProfile': 'NONE',
},
};
Map<String, dynamic> diagnostics(List<Map<String, Object?>> items) => <String, dynamic>{
'type': 'diagnostics',
'items': items,
};
const Map<String, Object?> compassInterference = <String, Object?>{
'component': 'FLIGHT_CONTROLLER',
'code': 1004,
'subCode': 0,
'componentIndex': 0,
'reason': 'Compass interference.',
'solution': 'Move away from metal objects.',
};
const Map<String, Object?> motorBlocked = <String, Object?>{
'component': 'FLIGHT_CONTROLLER',
'code': 1301,
'componentIndex': 0,
'reason': 'Motor blocked.',
'solution': 'Land and check the propellers.',
};
const Map<String, Object?> sdSlow = <String, Object?>{
'component': 'CAMERA',
'code': 2010,
'componentIndex': 0,
'reason': 'SD card write speed slow.',
'solution': null,
};
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/state/alert_state.dart';
import 'package:pilotvault_fly/fly/state/link_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/debug/telemetry_inspector_page.dart';
import 'telemetry_fixtures.dart';
/// Counts notifications from [l].
int Function() counter(Listenable l) {
int n = 0;
l.addListener(() => n++);
return () => n;
}
void main() {
late FlightModel m;
late TelemetryReducer r;
setUp(() {
m = FlightModel()..connected = true;
r = TelemetryReducer(m);
});
group('TelemetryReducer', () {
test('a tick fills every sub-model', () {
expect(r.apply(telemetryTick()), isTrue);
expect(m.aircraft.flightMode, 'P-GPS');
expect(m.aircraft.flightModeEnum, 'GPS_ATTI');
expect(m.aircraft.pitch, -1.2);
expect(m.aircraft.flightTimeSeconds, 245);
expect(m.aircraft.ultrasonicHeight, isNull, reason: 'NaN reads as unreported');
expect(m.aircraft.vpsHeight, isNull);
expect(m.aircraft.goHome!.batteryNeededToGoHome, 12);
expect(m.aircraft.goHome!.remainingFlightTime, 780);
expect(m.battery.percent, 64);
expect(m.battery.lowWarningThreshold, 30);
expect(m.battery.packs.single.cellVoltages, <double>[3.91, 3.90, 3.92, 3.88]);
expect(m.battery.packs.single.cellDeviation, closeTo(0.04, 1e-9));
expect(m.links.uplinkQuality, 88);
expect(m.links.downlinkQuality, 74);
expect(m.links.rcBatteryPercent, 81);
expect(m.camera.storage!.status, 'OK');
expect(m.camera.storage!.availableRecordingSeconds, 7260);
expect(m.camera.focusMode, 'AFC');
expect(m.gimbal.pitch, -30);
expect(m.vision.nose!.sectors, hasLength(4));
expect(m.vision.nose!.sectors[1].level, 2);
expect(m.vision.nose!.sectors[3].level, 0);
expect(m.vision.closest, 6.5);
// Pre-P2 screens read the same values through the flat getters.
expect(m.batteryPercent, 64);
expect(m.altitude, 32.4);
expect(m.gimbalPitch, -30);
expect(m.sdRemainingMB, 48211);
});
test('FlightModel notifies once per tick, not once per sub-model', () {
final int Function() n = counter(m);
r.apply(telemetryTick());
expect(n(), 1);
});
test('an unchanged slow group does not re-notify its sub-model', () {
r.apply(telemetryTick());
final int Function() battery = counter(m.battery);
final int Function() links = counter(m.links);
final int Function() aircraft = counter(m.aircraft);
r.apply(telemetryTick(altitude: 33)); // only flight state moved
expect(aircraft(), 1);
expect(battery(), 0);
expect(links(), 0);
r.apply(telemetryTick(altitude: 33, battery: 63, uplink: 60));
expect(battery(), 1);
expect(links(), 1);
expect(m.battery.percent, 63);
expect(LinkState.bars(m.links.uplinkQuality), 3);
});
test('a group missing from the snapshot leaves its sub-model empty', () {
r.apply(telemetryTick());
final Map<String, dynamic> noVision = telemetryTick()..remove('vision');
r.apply(noVision);
expect(m.vision.sensors, isEmpty);
});
test('telemetry after a disconnect is ignored', () {
m.connected = false;
r.apply(telemetryTick());
expect(m.aircraft.flightMode, isNull);
expect(m.battery.percent, isNull);
});
test('clearTelemetry + reset start the next aircraft from scratch', () {
r.apply(telemetryTick());
m.clearTelemetry();
r.reset();
expect(m.battery.percent, isNull);
expect(m.camera.storage, isNull);
r.apply(telemetryTick());
expect(m.battery.percent, 64, reason: 'same groups as before must re-apply after a reset');
});
test('smart-RTH countdown is visible', () {
r.apply(telemetryTick(smartRTHState: 'COUNTING_DOWN'));
expect(m.aircraft.goHome!.smartRTHCountingDown, isTrue);
});
test('non-telemetry events pass through', () {
expect(r.apply(<String, dynamic>{'type': 'connection', 'connected': true}), isFalse);
expect(r.apply(<String, dynamic>{'type': 'mission', 'state': 'tracking'}), isTrue);
expect(m.tracking, isTrue);
});
});
group('AlertState', () {
test('diagnostics are graded, ordered and de-duplicated', () {
r.apply(diagnostics(<Map<String, Object?>>[compassInterference, sdSlow, motorBlocked, compassInterference]));
final List<DiagnosticAlert> a = m.alerts.all;
expect(a, hasLength(3));
expect(a.first.reason, 'Motor blocked.');
expect(a.first.severity, AlertSeverity.critical);
expect(m.alerts.top!.code, 1301);
expect(a.map((DiagnosticAlert d) => d.severity).skip(1), everyElement(AlertSeverity.warning));
expect(m.alerts.lastAdded, hasLength(3));
});
test('a listed alert keeps its first-seen time; cleared ones drop out', () {
final AlertState s = AlertState();
final DateTime t0 = DateTime(2026, 10, 19, 12);
s.apply(<Map<String, dynamic>>[compassInterference], now: t0);
s.apply(<Map<String, dynamic>>[compassInterference, sdSlow], now: t0.add(const Duration(seconds: 5)));
expect(s.all.firstWhere((DiagnosticAlert d) => d.code == 1004).firstSeen, t0);
expect(s.lastAdded, <String>{'CAMERA/2010/0'});
s.apply(<Map<String, dynamic>>[sdSlow]);
expect(s.all.single.code, 2010);
});
test('a dismissed alert stays hidden until it clears and returns', () {
final AlertState s = AlertState();
s.apply(<Map<String, dynamic>>[sdSlow]);
s.dismiss('CAMERA/2010/0');
expect(s.active, isEmpty);
expect(s.top, isNotNull, reason: 'the marquee still shows it');
s.apply(<Map<String, dynamic>>[sdSlow]);
expect(s.active, isEmpty);
s.apply(const <Map<String, dynamic>>[]);
s.apply(<Map<String, dynamic>>[sdSlow]);
expect(s.active, hasLength(1));
});
test('grading heuristic', () {
expect(AlertState.grade('BATTERY', 'Critically low battery.', null), AlertSeverity.critical);
expect(AlertState.grade('FLIGHT_CONTROLLER', 'IMU preheating.', null), AlertSeverity.info);
expect(AlertState.grade('GIMBAL', 'Gimbal motor overloaded.', null), AlertSeverity.critical);
expect(AlertState.grade('CAMERA', 'SD card write speed slow.', null), AlertSeverity.warning);
});
});
testWidgets('telemetry inspector renders every section from a tick', (WidgetTester tester) async {
r.apply(telemetryTick());
r.apply(diagnostics(<Map<String, Object?>>[motorBlocked]));
await tester.pumpWidget(MaterialApp(home: Scaffold(body: TelemetryInspector(model: m))));
expect(find.text('P-GPS (GPS_ATTI)'), findsOneWidget);
await tester.scrollUntilVisible(find.text('88 / 100'), 300);
expect(find.text('88 / 100'), findsOneWidget);
await tester.scrollUntilVisible(find.text('critical · FLIGHT_CONTROLLER 1301'), 300);
expect(find.text('critical · FLIGHT_CONTROLLER 1301'), findsOneWidget);
await tester.pumpWidget(const SizedBox()); // dispose the timer
});
}