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 + }); +}