From f6bbb1cfee9ad0f10a144c408aa52cc8c28b2faa Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:34:56 +0200 Subject: [PATCH] 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 --- API Server/internal/hub/models.go | 8 + .../com/pilotvault/flyapp/AirLinkBridge.kt | 76 ++++ .../com/pilotvault/flyapp/BatteryBridge.kt | 135 +++++++ .../com/pilotvault/flyapp/CameraBridge.kt | 66 ++++ .../pilotvault/flyapp/DiagnosticsBridge.kt | 56 +++ .../com/pilotvault/flyapp/DjiSdkBridge.kt | 68 ++-- .../flyapp/FlightAssistantBridge.kt | 62 +++ .../flyapp/FlightControllerBridge.kt | 127 +++++-- .../com/pilotvault/flyapp/GimbalBridge.kt | 14 +- .../kotlin/com/pilotvault/flyapp/KeyBridge.kt | 250 ++++++++++++ .../com/pilotvault/flyapp/TelemetryHub.kt | 118 ++++++ Fly App/lib/dji_service.dart | 83 +++- Fly App/lib/flight_model.dart | 175 ++++++--- Fly App/lib/fly/state/aircraft_state.dart | 186 +++++++++ Fly App/lib/fly/state/alert_state.dart | 125 ++++++ Fly App/lib/fly/state/battery_state.dart | 110 ++++++ Fly App/lib/fly/state/camera_state.dart | 142 +++++++ Fly App/lib/fly/state/gimbal_state.dart | 26 ++ Fly App/lib/fly/state/link_state.dart | 44 +++ Fly App/lib/fly/state/mission_state.dart | 24 ++ Fly App/lib/fly/state/parse.dart | 42 +++ Fly App/lib/fly/state/vision_state.dart | 94 +++++ Fly App/lib/fly/telemetry_reducer.dart | 79 ++++ Fly App/lib/main.dart | 82 +--- .../fly/debug/telemetry_inspector_page.dart | 355 ++++++++++++++++++ Fly App/lib/ui/settings_menu_page.dart | 8 + Fly App/test/fly/telemetry_fixtures.dart | 165 ++++++++ Fly App/test/fly/telemetry_test.dart | 183 +++++++++ 28 files changed, 2716 insertions(+), 187 deletions(-) create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/BatteryBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DiagnosticsBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt create mode 100644 Fly App/lib/fly/state/aircraft_state.dart create mode 100644 Fly App/lib/fly/state/alert_state.dart create mode 100644 Fly App/lib/fly/state/battery_state.dart create mode 100644 Fly App/lib/fly/state/camera_state.dart create mode 100644 Fly App/lib/fly/state/gimbal_state.dart create mode 100644 Fly App/lib/fly/state/link_state.dart create mode 100644 Fly App/lib/fly/state/mission_state.dart create mode 100644 Fly App/lib/fly/state/parse.dart create mode 100644 Fly App/lib/fly/state/vision_state.dart create mode 100644 Fly App/lib/fly/telemetry_reducer.dart create mode 100644 Fly App/lib/ui/fly/debug/telemetry_inspector_page.dart create mode 100644 Fly App/test/fly/telemetry_fixtures.dart create mode 100644 Fly App/test/fly/telemetry_test.dart diff --git a/API Server/internal/hub/models.go b/API Server/internal/hub/models.go index f2b46a8..d38974e 100644 --- a/API Server/internal/hub/models.go +++ b/API Server/internal/hub/models.go @@ -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 + } + } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt new file mode 100644 index 0000000..3241725 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt @@ -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 safe(block: () -> T): T? = try { + block() + } catch (_: Throwable) { + null + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/BatteryBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/BatteryBridge.kt new file mode 100644 index 0000000..5e4053b --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/BatteryBridge.kt @@ -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>() + private val cells = HashMap>() + 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 = 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, + ), + ) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt index d9fe2bb..65492a5 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt @@ -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 = 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( diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DiagnosticsBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DiagnosticsBridge.kt new file mode 100644 index 0000000..19cd714 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DiagnosticsBridge.kt @@ -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>? = 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 = 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, + ) +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt index f01fe1e..3237062 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt @@ -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) -> 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() {} } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt new file mode 100644 index 0000000..b76b756 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt @@ -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>() + + 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 = 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, + ) + }, + ) +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt index da44e10..5fdd174 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt @@ -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("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("latitude") ?: 55.6761, + call.argument("longitude") ?: 12.5683, + ), + call.argument("updateFrequency") ?: 10, + call.argument("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 { 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, + ) + }, ) } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt index 886bad0..e09f877 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt @@ -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)) } } } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt new file mode 100644 index 0000000..d416ba4 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt @@ -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() + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "getKey", "setKey", "listenKey", "isKeySupported" -> Unit + "cancelKey" -> { + val id = call.argument("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("component")}.${call.argument("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("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("component"), + call.argument("key"), + call.argument("index") ?: 0, + call.argument("subIndex"), + ).joinToString("/") + + private fun keyFrom(call: MethodCall): DJIKey? { + val component = call.argument("component") ?: return null + val name = call.argument("key") ?: return null + val index = call.argument("index") ?: 0 + val subIndex = call.argument("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() + + /** 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 + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt new file mode 100644 index 0000000..3c7ffa3 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt @@ -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) -> 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() + + /** Slow groups as last written by their sources. */ + private val slowPending = LinkedHashMap() + + /** Slow groups as last published, so a fast tick re-sends them unchanged. */ + private val slowPublished = LinkedHashMap() + + 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) { + 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) { + @Suppress("UNCHECKED_CAST") + val current = (slowPending[group] as? Map).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(fast.size + slowPublished.size + 1) + event["type"] = "telemetry" + event.putAll(fast) + event.putAll(slowPublished) + emit(event) + } +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index dc3732d..6f6bc2c 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -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 cancelLanding() => _methods.invokeMethod('cancelLanding'); Future startGoHome() => _methods.invokeMethod('startGoHome'); Future cancelGoHome() => _methods.invokeMethod('cancelGoHome'); + + /// Answers the smart-RTH countdown (`goHome.smartRTHState == COUNTING_DOWN`): + /// true flies home now, false cancels it. + Future confirmSmartRTH(bool goHome) => + _methods.invokeMethod('confirmSmartRTH', {'value': goHome}); Future setHomeToCurrent() => _methods.invokeMethod('setHomeToCurrent'); + /// Starts the flight controller's built-in simulator at [latitude]/[longitude] + /// (developer only; propellers off). `telemetry.simulatorActive` reflects it. + Future startSimulator({required double latitude, required double longitude, int satellites = 12}) => + _methods.invokeMethod('startSimulator', { + 'latitude': latitude, + 'longitude': longitude, + 'satelliteCount': satellites, + 'updateFrequency': 10, + }); + Future stopSimulator() => _methods.invokeMethod('stopSimulator'); + // ── Flight settings (Safety / Control) ────────────────────────────────────── Future setMaxFlightHeight(int m) => _methods.invokeMethod('setMaxFlightHeight', {'value': m}); @@ -69,6 +85,25 @@ class DjiService { Future setObstacleAvoidance(bool on) => _methods.invokeMethod('setObstacleAvoidance', {'value': on}); + // ── KeyManager (generic simple values) ────────────────────────────────────── + /// Reads one SDK key. Enums come back as their name, arrays as lists. + Future getKey(DjiKey key) => _methods.invokeMethod('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 setKey(DjiKey key, Object? value) => + _methods.invokeMethod('setKey', {...key.args, 'value': value}); + + Future isKeySupported(DjiKey key) async => + await _methods.invokeMethod('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 listenKey(DjiKey key) async => + await _methods.invokeMethod('listenKey', key.args) ?? key.id; + + Future cancelKey(String id) => _methods.invokeMethod('cancelKey', {'id': id}); + // ── Camera ────────────────────────────────────────────────────────────────── /// mode: `photo` | `video` | `mediaDownload` | `playback` Future setCameraMode(String mode) => @@ -157,3 +192,45 @@ class DjiService { Future djiLogout() => _methods.invokeMethod('djiLogout'); Future refreshDjiAccountState() => _methods.invokeMethod('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 get args => { + '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; +} diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 4d91cde..6bf1e76 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -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 _subs = [ + 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? 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 media = []; @@ -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(); + } } diff --git a/Fly App/lib/fly/state/aircraft_state.dart b/Fly App/lib/fly/state/aircraft_state.dart new file mode 100644 index 0000000..ec8431c --- /dev/null +++ b/Fly App/lib/fly/state/aircraft_state.dart @@ -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 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 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? 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(); + } +} diff --git a/Fly App/lib/fly/state/alert_state.dart b/Fly App/lib/fly/state/alert_state.dart new file mode 100644 index 0000000..006548a --- /dev/null +++ b/Fly App/lib/fly/state/alert_state.dart @@ -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 _all = const []; + final Set _dismissed = {}; + + /// Keys that appeared in the last [apply] — what P3 buzzes and pops a card for. + Set lastAdded = const {}; + + DateTime? updatedAt; + + /// Every listed alert, most severe first, then oldest first. + List get all => _all; + + /// [all] minus what the pilot dismissed. + List get active => [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> items, {DateTime? now}) { + final DateTime t = now ?? DateTime.now(); + final Map previous = {for (final DiagnosticAlert a in _all) a.key: a}; + final Map next = {}; + for (final Map 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 = {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 []; + _dismissed.clear(); + lastAdded = const {}; + 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; + } +} diff --git a/Fly App/lib/fly/state/battery_state.dart b/Fly App/lib/fly/state/battery_state.dart new file mode 100644 index 0000000..8800cea --- /dev/null +++ b/Fly App/lib/fly/state/battery_state.dart @@ -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 [], + this.cellVoltageLevel, + this.connectionState, + this.selfHeatingState, + }); + + factory BatteryPack.fromMap(Map 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: [ + 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 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 s = List.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 packs = const []; + + DateTime? updatedAt; + + bool get isMultiPack => packs.length > 1; + + void apply(Map 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 []; + updatedAt = null; + notifyListeners(); + } +} diff --git a/Fly App/lib/fly/state/camera_state.dart b/Fly App/lib/fly/state/camera_state.dart new file mode 100644 index 0000000..31a7cd4 --- /dev/null +++ b/Fly App/lib/fly/state/camera_state.dart @@ -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 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 e) { + isRecording = asBool(e['isRecording']) ?? false; + recordSeconds = asInt(e['recordingTimeSeconds']) ?? recordSeconds; + mode = asString(e['mode']) ?? mode; + notifyListeners(); + } + + void applyExposure(Map 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? storage, Map? 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(); + } +} diff --git a/Fly App/lib/fly/state/gimbal_state.dart b/Fly App/lib/fly/state/gimbal_state.dart new file mode 100644 index 0000000..ede176c --- /dev/null +++ b/Fly App/lib/fly/state/gimbal_state.dart @@ -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 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(); + } +} diff --git a/Fly App/lib/fly/state/link_state.dart b/Fly App/lib/fly/state/link_state.dart new file mode 100644 index 0000000..c5073dd --- /dev/null +++ b/Fly App/lib/fly/state/link_state.dart @@ -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? links, Map? 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(); + } +} diff --git a/Fly App/lib/fly/state/mission_state.dart b/Fly App/lib/fly/state/mission_state.dart new file mode 100644 index 0000000..49ebd62 --- /dev/null +++ b/Fly App/lib/fly/state/mission_state.dart @@ -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 e) { + state = asString(e['state']) ?? 'idle'; + error = asString(e['error']); + notifyListeners(); + } + + void clear() { + state = 'idle'; + error = null; + notifyListeners(); + } +} diff --git a/Fly App/lib/fly/state/parse.dart b/Fly App/lib/fly/state/parse.dart new file mode 100644 index 0000000..b0d0779 --- /dev/null +++ b/Fly App/lib/fly/state/parse.dart @@ -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`. +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? asMap(Object? v) => v is Map ? Map.from(v) : null; + +List> asMapList(Object? v) => + v is List ? >[for (final Object? e in v) if (e is Map) Map.from(e)] : const >[]; + +/// 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; +} diff --git a/Fly App/lib/fly/state/vision_state.dart b/Fly App/lib/fly/state/vision_state.dart new file mode 100644 index 0000000..002d821 --- /dev/null +++ b/Fly App/lib/fly/state/vision_state.dart @@ -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 [], + }); + + factory VisionReading.fromMap(String position, Map 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: [ + for (final Map 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 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 sensors = const {}; + + 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? vision) { + sensors = { + if (vision != null) + for (final MapEntry e in vision.entries) + if (e.value is Map) e.key: VisionReading.fromMap(e.key, Map.from(e.value as Map)), + }; + if (vision != null) updatedAt = DateTime.now(); + notifyListeners(); + } + + void clear() { + sensors = const {}; + updatedAt = null; + notifyListeners(); + } +} diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart new file mode 100644 index 0000000..4e09c4b --- /dev/null +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -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 _lastGroups = {}; + + static const List _groups = ['battery', 'links', 'rc', 'storage', 'cameraSettings', 'gimbal', 'vision']; + + /// Applies [event] if it is one of the telemetry types; returns whether it was. + bool apply(Map 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 t) { + // Flight-controller keys change every tick; absent until the FC first reports. + if (t.containsKey('satelliteCount')) model.aircraft.apply(t); + + final Set changed = {}; + 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? 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? g = asMap(t['gimbal']); + g == null ? model.gimbal.clear() : model.gimbal.apply(g); + } + if (changed.contains('vision')) model.vision.apply(asMap(t['vision'])); + } +} diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index d6253b1..0962dd5 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -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 { final DjiService _dji = DjiService(); final FlightModel _model = FlightModel(); + late final TelemetryReducer _telemetry = TelemetryReducer(_model); StreamSubscription>? _sub; StreamSubscription? _authSub; StreamSubscription? _phoneLocSub; @@ -133,6 +135,8 @@ class _HomePageState extends State { 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 { 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 files = (event['files'] as List?) ?? const []; _model.media = files @@ -263,25 +210,8 @@ class _HomePageState extends State { } 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; diff --git a/Fly App/lib/ui/fly/debug/telemetry_inspector_page.dart b/Fly App/lib/ui/fly/debug/telemetry_inspector_page.dart new file mode 100644 index 0000000..624075a --- /dev/null +++ b/Fly App/lib/ui/fly/debug/telemetry_inspector_page.dart @@ -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 _toggleSimulator(BuildContext context) async { + final bool active = model.aircraft.simulatorActive == true; + if (!active) { + final bool? go = await showDialog( + 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: [ + 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: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 10, 6), + child: Row(children: [ + const Expanded( + child: Text('Telemetry inspector', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)), + ), + ListenableBuilder( + listenable: Listenable.merge([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 createState() => _TelemetryInspectorState(); +} + +class _TelemetryInspectorState extends State { + /// Ticks the "updated … ago" labels; sections otherwise rebuild on their own data. + final ValueNotifier _clock = ValueNotifier(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: [ + _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 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: [ + Padding( + padding: const EdgeInsets.only(top: 18, bottom: 6), + child: Row(children: [ + Expanded(child: Text(title.toUpperCase(), style: _eyebrow)), + ListenableBuilder( + listenable: Listenable.merge([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: [ + 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: [ + SizedBox(width: 150, child: Text(r.$1, style: _label)), + Expanded(child: Text(r.$2, style: _value)), + ]), + ), + ]), + ), + ]); + } +} diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart index 6f69fbf..df05252 100644 --- a/Fly App/lib/ui/settings_menu_page.dart +++ b/Fly App/lib/ui/settings_menu_page.dart @@ -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 { )); } + void _openTelemetry() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => TelemetryInspectorPage(model: _m, dji: _dji), + )); + } + void _snack(String msg) { ScaffoldMessenger.of(context) ..clearSnackBars() @@ -148,6 +155,7 @@ class _SettingsMenuPageState extends State { ('MSDK', _m.sdkVersion), ]), _infoRow('Capability profile', _m.profile.connected ? 'View' : '—', onTap: _openProfile), + _infoRow('Telemetry inspector', 'View', onTap: _openTelemetry), ]), }; return Padding( diff --git a/Fly App/test/fly/telemetry_fixtures.dart b/Fly App/test/fly/telemetry_fixtures.dart new file mode 100644 index 0000000..431771f --- /dev/null +++ b/Fly App/test/fly/telemetry_fixtures.dart @@ -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 telemetryTick({ + int battery = 64, + double altitude = 32.4, + double gimbalPitch = -30, + int uplink = 88, + double noseDistance = 6.5, + String? smartRTHState = 'IDLE', +}) => + { + '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': { + 'remainingFlightTime': 780, + 'timeNeededToGoHome': 40, + 'timeNeededToLand': 22, + 'batteryNeededToGoHome': 12, + 'batteryNeededToLand': 6, + 'maxRadiusCanFlyAndGoHome': 2400.0, + 'smartRTHState': smartRTHState, + 'smartRTHCountdown': 0, + }, + 'gimbal': {'pitch': gimbalPitch, 'roll': 0.0, 'yaw': 87.0}, + 'vision': { + 'NOSE': { + 'isSensorBeingUsed': true, + 'isDisabled': false, + 'systemWarning': 'SAFE', + 'distance': noseDistance, + 'avoidAlertLevel': 0, + 'sectors': [ + {'distance': 9.0, 'warningLevel': 'LEVEL_1'}, + {'distance': noseDistance, 'warningLevel': 'LEVEL_2'}, + {'distance': 12.0, 'warningLevel': 'LEVEL_1'}, + {'distance': 15.0, 'warningLevel': 'INVALID'}, + ], + }, + }, + 'battery': { + 'percent': battery, + 'voltage': 15.6, + 'temperature': 38.5, + 'lowWarningThreshold': 30, + 'seriousLowWarningThreshold': 15, + 'packs': [ + { + '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': [3.91, 3.90, 3.92, 3.88], + 'cellVoltageLevel': 'LEVEL_0', + 'connectionState': 'NORMAL', + }, + ], + }, + 'links': {'kind': 'lightbridge', 'uplinkQuality': uplink, 'downlinkQuality': 74}, + 'rc': {'batteryPercent': 81, 'batteryMah': 4200, 'isCharging': false}, + 'storage': { + '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': { + '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 diagnostics(List> items) => { + 'type': 'diagnostics', + 'items': items, + }; + +const Map compassInterference = { + 'component': 'FLIGHT_CONTROLLER', + 'code': 1004, + 'subCode': 0, + 'componentIndex': 0, + 'reason': 'Compass interference.', + 'solution': 'Move away from metal objects.', +}; + +const Map motorBlocked = { + 'component': 'FLIGHT_CONTROLLER', + 'code': 1301, + 'componentIndex': 0, + 'reason': 'Motor blocked.', + 'solution': 'Land and check the propellers.', +}; + +const Map sdSlow = { + 'component': 'CAMERA', + 'code': 2010, + 'componentIndex': 0, + 'reason': 'SD card write speed slow.', + 'solution': null, +}; diff --git a/Fly App/test/fly/telemetry_test.dart b/Fly App/test/fly/telemetry_test.dart new file mode 100644 index 0000000..f1c814a --- /dev/null +++ b/Fly App/test/fly/telemetry_test.dart @@ -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, [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 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({'type': 'connection', 'connected': true}), isFalse); + expect(r.apply({'type': 'mission', 'state': 'tracking'}), isTrue); + expect(m.tracking, isTrue); + }); + }); + + group('AlertState', () { + test('diagnostics are graded, ordered and de-duplicated', () { + r.apply(diagnostics(>[compassInterference, sdSlow, motorBlocked, compassInterference])); + final List 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(>[compassInterference], now: t0); + s.apply(>[compassInterference, sdSlow], now: t0.add(const Duration(seconds: 5))); + expect(s.all.firstWhere((DiagnosticAlert d) => d.code == 1004).firstSeen, t0); + expect(s.lastAdded, {'CAMERA/2010/0'}); + s.apply(>[sdSlow]); + expect(s.all.single.code, 2010); + }); + + test('a dismissed alert stays hidden until it clears and returns', () { + final AlertState s = AlertState(); + s.apply(>[sdSlow]); + s.dismiss('CAMERA/2010/0'); + expect(s.active, isEmpty); + expect(s.top, isNotNull, reason: 'the marquee still shows it'); + s.apply(>[sdSlow]); + expect(s.active, isEmpty); + s.apply(const >[]); + s.apply(>[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(>[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 + }); +}