Teach the Fly App what the connected aircraft can do

The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no
screen may assume one model. This adds the capability layer the rest of
the plan builds on (P0).

ProductProfileBridge reads what the SDK can report at runtime (cameras
and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and
RC feature flags, batteries) and emits it as a productProfile event on
connect and component change. Capability ranges stay empty until the
camera handshakes, so it re-probes at 3, 10 and 30 s and emits only
when something changed.

On the Dart side, ProductProfile merges that with a static
product_matrix keyed by the MSDK Model enum name, for facts the SDK
cannot report: vision sensor layout, battery bay count, intelligent
modes. SDK answers win and every fact records its source. HudFeatures
lists each capability-gated element with its predicate, and Requires
hides a widget the profile rules out. The flight page now gates its
gimbal slider, camera column and vision pill, labels the link Wi-Fi or
HD, and takes the gimbal pitch range from the profile.

Settings > About > Capability profile shows the active profile, fact
sources and which features are on. Matrix rows are seeded from published
specs until runtime capture confirms them; nothing has run on a real
aircraft yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 17:56:08 +02:00
1 parent f2a20e541d
commit 606838a0b1
14 files changed
+1648 -42

No files matched your search

@@ -146,18 +146,25 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
}
// ── enum-name → label mapping (for the read-back model) ─────────────────────
private fun shutterLabel(name: String?): String? =
name?.removePrefix("SHUTTER_SPEED_")?.replace("_DOT_", ".")?.replace("_", "/")
// Shared with [ProductProfileBridge], which publishes the capability ranges in
// the same labels the setters above accept.
companion object {
internal fun isoLabel(name: String?): String? =
name?.removePrefix("ISO_")
private fun apertureLabel(name: String?): String? =
name?.removePrefix("F_")?.replace("_DOT_", ".")?.let { "f/$it" }
internal fun shutterLabel(name: String?): String? =
name?.removePrefix("SHUTTER_SPEED_")?.replace("_DOT_", ".")?.replace("_", "/")
private fun evLabel(name: String?): String? {
if (name == null) return null
val neg = name.startsWith("N_")
val body = name.removePrefix("N_").removePrefix("P_").replace("_", ".")
if (body == "0.0") return "0.0"
return (if (neg) "-" else "+") + body
internal fun apertureLabel(name: String?): String? =
name?.removePrefix("F_")?.replace("_DOT_", ".")?.let { "f/$it" }
internal fun evLabel(name: String?): String? {
if (name == null) return null
val neg = name.startsWith("N_")
val body = name.removePrefix("N_").removePrefix("P_").replace("_", ".")
if (body == "0.0") return "0.0"
return (if (neg) "-" else "+") + body
}
}
private inline fun <reified T : Enum<T>> enumOrNull(name: String?): T? {
@@ -22,7 +22,7 @@ import io.flutter.plugin.common.MethodChannel
*
* - [METHOD_CHANNEL] handles imperative calls from Dart, routed to per-subsystem
* helpers ([FlightControllerBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge]).
* [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge]).
* - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event
* is a map carrying a `type` key.
*
@@ -101,8 +101,9 @@ class DjiSdkBridge(
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)
private val subBridges = listOf(flight, camera, gimbal, mission, media, account, profile)
init {
methodChannel.setMethodCallHandler(this)
@@ -0,0 +1,231 @@
package com.pilotvault.flyapp
import dji.common.gimbal.CapabilityKey
import dji.common.util.DJIParamMinMaxCapability
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.gimbal.Gimbal
import dji.sdk.products.Aircraft
import dji.sdk.products.HandHeld
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The product capability layer (plan P0): what the connected product *is* and
* what it can do, as reported by the SDK itself. Emits a `productProfile` event
* on connect and on every component change, and answers `getProductProfile`.
*
* Only facts the SDK reports go here. What it cannot report — the vision sensor
* layout, the battery count of a disconnected bay, which intelligent modes the
* firmware offers — comes from the Dart-side `product_matrix.dart`, keyed by
* [BaseProduct.getModel]'s enum name, which is why `model` is published raw.
*
* Every probe is wrapped: many `is…Supported()` getters are abstract and some
* products' implementations throw instead of answering false, and one bad
* getter must not cost the pilot the rest of the profile.
*/
class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
companion object {
/** Coalesces the burst of component changes that follows a connect. */
private const val SETTLE_MS = 400L
/**
* Camera capability ranges are backed by the SDK's key cache and come back
* empty until the camera has finished its handshake, so the profile is
* rebuilt a few times after connect; only changed profiles are emitted.
*/
private val REFRESH_AFTER_MS = longArrayOf(3_000L, 10_000L, 30_000L)
}
/** Invalidates queued rebuilds when the product changes again. */
@Volatile private var generation = 0
/** Last profile emitted, so the refresh passes stay quiet when nothing moved. */
private var lastEmitted: Map<String, Any?>? = null
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
if (call.method != "getProductProfile") return false
ctx.mainHandler.post { result.success(build(ctx.product())) }
return true
}
override fun bind(product: BaseProduct?) {
ctx.mainHandler.post {
val gen = ++generation
lastEmitted = null
ctx.mainHandler.postDelayed({ rebuild(gen) }, SETTLE_MS)
for (delay in REFRESH_AFTER_MS) {
ctx.mainHandler.postDelayed({ rebuild(gen) }, SETTLE_MS + delay)
}
}
}
/** Main thread only. */
private fun rebuild(gen: Int) {
if (gen != generation) return
val product = DJISDKManager.getInstance().product
if (product == null || !product.isConnected) return
val profile = build(product)
if (profile == lastEmitted) return
lastEmitted = profile
ctx.emit(profile)
}
private fun build(product: BaseProduct?): Map<String, Any?> {
if (product == null || !product.isConnected) {
return mapOf("type" to "productProfile", "connected" to false)
}
val aircraft = product as? Aircraft
val model = product.model
return mapOf(
"type" to "productProfile",
"connected" to true,
"model" to model?.name,
"modelDisplayName" to model?.displayName,
"productKind" to when (product) {
is Aircraft -> "aircraft"
is HandHeld -> "handheld"
else -> "unknown"
},
"cameras" to probe { product.cameras }.orEmpty().filterNotNull().map(::cameraMap),
"gimbals" to gimbals(product).map(::gimbalMap),
"link" to linkType(product),
"flightController" to aircraft?.flightController?.let { fc ->
mapOf(
"flightAssistantSupported" to probe { fc.isFlightAssistantSupported },
"virtualStickAvailable" to probe { fc.isVirtualStickControlModeAvailable },
"rtkSupported" to probe { fc.isRTKSupported },
"compassCount" to probe { fc.compassCount },
"imuCount" to probe { fc.imuCount },
"propellerCalibrationSupported" to probe { fc.isPropellerCalibrationSupported },
"virtualFenceSupported" to probe { fc.isVirtualFenceSupported },
"accessLockerSupported" to probe { fc.isAccessLockerSupported },
"onboardSdkAvailable" to probe { fc.isOnboardSDKDeviceAvailable },
"simulatorAvailable" to (probe { fc.simulator } != null),
)
},
"remoteController" to aircraft?.remoteController?.let { rc ->
mapOf(
"masterSlaveSupported" to probe { rc.isMasterSlaveModeSupported },
"focusControllerSupported" to probe { rc.isFocusControllerSupported },
"customizableButtonSupported" to probe { rc.isCustomizableButtonSupported },
"multiDevicePairingSupported" to probe { rc.isMultiDevicePairingSupported },
)
},
"batteries" to probe { product.batteries }.orEmpty().filterNotNull().map { b ->
mapOf(
"index" to probe { b.index },
"isSmart" to probe { b.isSmartBattery },
"cells" to probe { b.numberOfCells }?.takeIf { it > 0 },
)
},
)
}
// ── Camera ──────────────────────────────────────────────────────────────────
private fun cameraMap(cam: Camera): Map<String, Any?> {
val caps = probe { cam.capabilities }
return mapOf(
"index" to probe { cam.index },
"displayName" to probe { cam.displayName },
"isThermal" to probe { cam.isThermalCamera },
"ssdSupported" to probe { cam.isSSDSupported },
"opticalZoomSupported" to probe { cam.isOpticalZoomSupported },
"hybridZoomSupported" to probe { cam.isHybridZoomSupported },
"digitalZoomSupported" to probe { cam.isDigitalZoomSupported },
"tapZoomSupported" to probe { cam.isTapZoomSupported },
"adjustableApertureSupported" to probe { cam.isAdjustableApertureSupported },
"adjustableFocalPointSupported" to probe { cam.isAdjustableFocalPointSupported },
"meteringSupported" to probe { cam.isMeteringSupported },
"playbackSupported" to probe { cam.isPlaybackSupported },
"mediaDownloadSupported" to probe { cam.isMediaDownloadModeSupported },
"timeLapseSupported" to probe { cam.isTimeLapseSupported },
"multiLensSupported" to probe { cam.isMultiLensCameraSupported },
"interchangeableLensSupported" to probe { cam.isInterchangeableLensSupported },
"internalStorageSupported" to probe { cam.isInternalStorageSupported },
"mechanicalShutterSupported" to probe { cam.isMechanicalShutterSupported },
"flatCameraModeSupported" to probe { cam.isFlatCameraModeSupported },
"modes" to names(probe { caps?.modeRange() }),
"exposureModes" to names(probe { caps?.exposureModeRange() }),
"isos" to names(probe { caps?.ISORange() }, CameraBridge.Companion::isoLabel),
"shutterSpeeds" to names(probe { caps?.shutterSpeedRange() }, CameraBridge.Companion::shutterLabel),
"apertures" to names(probe { caps?.apertureRange() }, CameraBridge.Companion::apertureLabel),
"exposureCompensations" to names(probe { caps?.exposureCompensationRange() }, CameraBridge.Companion::evLabel),
"videoResolutions" to probe { caps?.videoResolutionAndFrameRateRange() }.orEmpty()
.filterNotNull()
.mapNotNull { r ->
val res = r.resolution?.name ?: return@mapNotNull null
val fps = r.frameRate?.name ?: return@mapNotNull null
"$res@$fps"
}
.distinct(),
)
}
/** Enum names (or their setter labels) with the SDK's UNKNOWN placeholders dropped. */
private fun names(values: Array<out Enum<*>?>?, label: (String?) -> String? = { it }): List<String> =
values.orEmpty()
.mapNotNull { it?.name }
.filter { it != "UNKNOWN" }
.mapNotNull(label)
.distinct()
// ── Gimbal ──────────────────────────────────────────────────────────────────
private fun gimbals(product: BaseProduct): List<Gimbal> {
val many = (product as? Aircraft)?.let { probe { it.gimbals } }.orEmpty().filterNotNull()
if (many.isNotEmpty()) return many
return listOfNotNull(probe { product.gimbal })
}
private fun gimbalMap(g: Gimbal): Map<String, Any?> {
val caps = probe { g.capabilities }.orEmpty()
fun range(key: CapabilityKey): Map<String, Any?>? {
val c = caps[key] as? DJIParamMinMaxCapability ?: return null
if (!c.isSupported) return null
return mapOf("min" to c.min?.toDouble(), "max" to c.max?.toDouble())
}
fun supported(key: CapabilityKey): Boolean = caps[key]?.isSupported == true
return mapOf(
"index" to probe { g.index },
// Empty until the gimbal has handshaken; Dart then assumes pitch only.
"capabilitiesKnown" to caps.isNotEmpty(),
"pitch" to range(CapabilityKey.ADJUST_PITCH),
"yaw" to range(CapabilityKey.ADJUST_YAW),
"roll" to range(CapabilityKey.ADJUST_ROLL),
"pitchAdjustable" to supported(CapabilityKey.ADJUST_PITCH),
"yawAdjustable" to supported(CapabilityKey.ADJUST_YAW),
"rollAdjustable" to supported(CapabilityKey.ADJUST_ROLL),
"pitchRangeExtensionSupported" to supported(CapabilityKey.PITCH_RANGE_EXTENSION),
)
}
// ── Link ────────────────────────────────────────────────────────────────────
/**
* `ocusync` / `lightbridge` / `wifi`, or null when the air link has not been
* enumerated yet (the matrix then supplies the model's usual link). The RC
* links are checked before Wi-Fi so that a product reporting more than one
* resolves to its RC link; a Wi-Fi-only connection still lands on `wifi`.
*/
private fun linkType(product: BaseProduct): String? {
val link = probe { product.airLink } ?: return null
return when {
probe { link.isOcuSyncLinkSupported } == true -> "ocusync"
probe { link.isLightbridgeLinkSupported } == true -> "lightbridge"
probe { link.isWiFiLinkSupported } == true -> "wifi"
else -> null
}
}
/** Runs one SDK probe; a throwing getter reads as "unknown" rather than failing the profile. */
private inline fun <T> probe(block: () -> T): T? = try {
block()
} catch (_: Throwable) {
null
}
}