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:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user