Give the Fly App GO 4-style camera controls

One settings table in CameraBridge watches every camera value into the
telemetry tick and backs a generic setCameraValue, so the tabbed camera
menu (Exposure / Photo / Video / Other) is built from row specs and the
camera's own capability ranges. Writes show at once and fall back to what
the camera reports. Adds the quick exposure panel (Auto/A/S/M, ISO slider,
shutter and aperture wheels, EV strip, metered EV in M), a new camera
column with record timer and interval/pano progress, a zoom column for
zoom cameras, tap-to-focus / tap-to-meter, and a live histogram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 19:57:38 +02:00
1 parent 6ebc567e97
commit c1770fff73
26 files changed
+3546 -171

No files matched your search

@@ -1,5 +1,7 @@
package com.pilotvault.flyapp
import android.graphics.Point
import android.graphics.PointF
import dji.common.camera.ExposureSettings
import dji.common.camera.ResolutionAndFrameRate
import dji.common.camera.SSDState
@@ -7,7 +9,10 @@ import dji.common.camera.SettingsDefinitions
import dji.common.camera.StorageState
import dji.common.camera.SystemState
import dji.common.camera.WhiteBalance
import dji.common.error.DJIError
import dji.keysdk.CameraKey
import dji.keysdk.KeyManager
import dji.keysdk.callback.SetCallback
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.products.Aircraft
@@ -15,18 +20,22 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Camera control: mode, photo/record, shoot-photo modes, and Pro exposure
* (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.
* Camera control: mode, photo/record, shoot-photo modes, exposure, the camera
* menu's settings, tap-to-focus / spot metering, zoom and the histogram. Emits
* `camera` (system state), `exposure` and `histogram` 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]:
* Also feeds four 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}
* cameraSettings: {<every SETTINGS id>, isoSetting, videoResolution, videoFrameRate,
* whiteBalancePreset, whiteBalanceKelvin, intervalCount, intervalSeconds,
* styleSharpness, styleContrast, styleSaturation, spotCol, spotRow,
* focusX, focusY, zoomFocalLength, zoomMin, zoomMax, zoomStep,
* zoomOpticalMax, digitalZoomFactor}
* ssd: {connected, state, capacity, remainingMB, availableRecordingSeconds}
* thermal: {spotTemperature}
* ```
@@ -34,14 +43,53 @@ import io.flutter.plugin.common.MethodChannel
* on thermal cameras (XT / XT2 / Mavic 2 Enterprise Dual); the HUD's SSD and
* thermal readouts (plan P2) are hidden on everything else.
* The settings are watched keys, so a change made from the RC's dial or the
* settings sheet shows up without a re-read.
* camera menu shows up without a re-read — the menu (plan P4) shows what the
* camera reports back, never what it asked for.
*/
class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
/**
* One simple camera setting: its id in the `cameraSettings` group (and in
* `setCameraValue`), its CameraKey, and its value type (an enum class, or
* Boolean). The table drives both the watch and the setter, so a camera
* menu row is one line here plus one row spec in Dart.
*/
private class Setting(val key: String, val type: Class<*>)
private val settings: Map<String, Setting> = linkedMapOf(
"aeLock" to Setting(CameraKey.AE_LOCK, java.lang.Boolean::class.java),
"focusMode" to Setting(CameraKey.FOCUS_MODE, SettingsDefinitions.FocusMode::class.java),
"exposureMode" to Setting(CameraKey.EXPOSURE_MODE, SettingsDefinitions.ExposureMode::class.java),
"meteringMode" to Setting(CameraKey.METERING_MODE, SettingsDefinitions.MeteringMode::class.java),
"shootPhotoMode" to Setting(CameraKey.SHOOT_PHOTO_MODE, SettingsDefinitions.ShootPhotoMode::class.java),
"aebCount" to Setting(CameraKey.PHOTO_AEB_COUNT, SettingsDefinitions.PhotoAEBCount::class.java),
"burstCount" to Setting(CameraKey.PHOTO_BURST_COUNT, SettingsDefinitions.PhotoBurstCount::class.java),
"panoramaMode" to Setting(CameraKey.PHOTO_PANORAMA_MODE, SettingsDefinitions.PhotoPanoramaMode::class.java),
"photoAspectRatio" to Setting(CameraKey.PHOTO_ASPECT_RATIO, SettingsDefinitions.PhotoAspectRatio::class.java),
"photoFileFormat" to Setting(CameraKey.PHOTO_FILE_FORMAT, SettingsDefinitions.PhotoFileFormat::class.java),
"videoFileFormat" to Setting(CameraKey.VIDEO_FILE_FORMAT, SettingsDefinitions.VideoFileFormat::class.java),
"videoStandard" to Setting(CameraKey.VIDEO_STANDARD, SettingsDefinitions.VideoStandard::class.java),
"colorProfile" to Setting(CameraKey.CAMERA_COLOR, SettingsDefinitions.CameraColor::class.java),
"antiFlicker" to Setting(CameraKey.ANTI_FLICKER_FREQUENCY, SettingsDefinitions.AntiFlickerFrequency::class.java),
"fileIndexMode" to Setting(CameraKey.FILE_INDEX_MODE, SettingsDefinitions.FileIndexMode::class.java),
"videoCaption" to Setting(CameraKey.VIDEO_CAPTION_ENABLED, java.lang.Boolean::class.java),
"autoLockGimbal" to Setting(CameraKey.AUTO_LOCK_GIMBAL_ENABLED, java.lang.Boolean::class.java),
"histogramEnabled" to Setting(CameraKey.HISTOGRAM_ENABLED, java.lang.Boolean::class.java),
)
private val keys = KeyWatch(ctx)
/** Histogram frames arrive at the video rate; the HUD needs a few per second. */
private var lastHistogramAt = 0L
private fun cam(): Camera? = ctx.aircraft()?.camera
private fun index(camera: Camera): Int = try {
camera.index
} catch (_: Throwable) {
0
}
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
val camera = cam()
when (call.method) {
@@ -64,6 +112,8 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
if (mode == null) ctx.fail(result, "Unsupported photo mode")
else it.setShootPhotoMode(mode, ctx.completion(result))
}
// The classic flight screen's Auto / Pro switch. The HUD sets the four
// GO 4 modes through `setCameraValue` / `exposureMode` instead.
"setExposureProgram" -> guard(camera, result) {
val mode = if (call.argument<String>("program") == "pro")
SettingsDefinitions.ExposureMode.MANUAL
@@ -91,6 +141,71 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
if (wb == null) ctx.fail(result, "Unsupported white balance")
else it.setWhiteBalance(wb, ctx.completion(result))
}
"setCameraValue" -> guard(camera, result) { setValue(it, call, result) }
"setVideoResolutionAndFrameRate" -> guard(camera, result) {
val res = enumOrNull<SettingsDefinitions.VideoResolution>(call.argument<String>("resolution"))
val fps = enumOrNull<SettingsDefinitions.VideoFrameRate>(call.argument<String>("frameRate"))
if (res == null || fps == null) ctx.fail(result, "Unsupported video size")
else it.setVideoResolutionAndFrameRate(ResolutionAndFrameRate(res, fps), ctx.completion(result))
}
"setPhotoTimeInterval" -> guard(camera, result) {
val count = call.argument<Int>("count") ?: 255
val seconds = call.argument<Int>("seconds") ?: 5
it.setPhotoTimeIntervalSettings(
SettingsDefinitions.PhotoTimeIntervalSettings(count, seconds),
ctx.completion(result),
)
}
"setPictureStyle" -> guard(camera, result) {
val style = SettingsDefinitions.PictureStylePreset.Builder()
.sharpness(call.argument<Int>("sharpness") ?: 0)
.contrast(call.argument<Int>("contrast") ?: 0)
.saturation(call.argument<Int>("saturation") ?: 0)
.build()
setKey(CameraKey.create(CameraKey.PICTURE_STYLE_PRESET, index(it)), style, result)
}
// Tap-to-focus: x/y are 0..1 across the live view.
"setFocusTarget" -> guard(camera, result) {
val p = PointF(unit(call.argument<Double>("x")), unit(call.argument<Double>("y")))
it.setFocusTarget(p, ctx.completion(result))
}
// Spot metering on the camera's own grid (12 × 8 on most cameras): switch
// to SPOT, then move the spot to the cell under x/y.
"setSpotMeteringTarget" -> guard(camera, result) { c ->
val (cols, rows) = meteringGrid(c)
val col = (unit(call.argument<Double>("x")) * cols).toInt().coerceIn(0, cols - 1)
val row = (unit(call.argument<Double>("y")) * rows).toInt().coerceIn(0, rows - 1)
c.setMeteringMode(SettingsDefinitions.MeteringMode.SPOT) { error ->
if (error != null) ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) }
else c.setSpotMeteringTarget(Point(col, row), ctx.completion(result))
}
}
"startContinuousZoom" -> guard(camera, result) {
if (safe { it.isOpticalZoomSupported } != true) {
ctx.fail(result, "This camera zooms in steps only")
} else {
val dir = if (call.argument<String>("direction") == "out")
SettingsDefinitions.ZoomDirection.ZOOM_OUT
else SettingsDefinitions.ZoomDirection.ZOOM_IN
it.startContinuousOpticalZoom(dir, SettingsDefinitions.ZoomSpeed.NORMAL, ctx.completion(result))
}
}
"stopContinuousZoom" -> guard(camera, result) { it.stopContinuousOpticalZoom(ctx.completion(result)) }
// A focal length in the camera's own units (mm × 10 on the Mavic 2 Zoom).
"setZoomFocalLength" -> guard(camera, result) {
val mm = call.argument<Int>("value")
when {
mm == null -> ctx.fail(result, "No focal length")
safe { it.isHybridZoomSupported } == true -> it.setHybridZoomFocalLength(mm, ctx.completion(result))
safe { it.isOpticalZoomSupported } == true -> it.setOpticalZoomFocalLength(mm, ctx.completion(result))
else -> ctx.fail(result, "No optical zoom")
}
}
"setDigitalZoomFactor" -> guard(camera, result) {
val f = call.argument<Double>("value")?.toFloat() ?: 1f
it.setDigitalZoomFactor(f, ctx.completion(result))
}
"restoreCameraDefaults" -> guard(camera, result) { it.restoreFactorySettings(ctx.completion(result)) }
// Erases the card (Aircraft Status sheet, plan P3). `location` picks the SD
// card or internal storage; the UI slide-confirms and refuses while recording.
"formatStorage" -> guard(camera, result) {
@@ -100,10 +215,8 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
}
it.formatStorage(location, ctx.completion(result))
}
"setHistogramEnabled" -> {
// Histogram data streaming is model-dependent; accept the toggle so
// the UI stays responsive. Real histogram bins are a later add.
result.success(null)
"setHistogramEnabled" -> guard(camera, result) {
it.setHistogramEnabled(call.argument<Boolean>("value") == true, ctx.completion(result))
}
else -> return false
}
@@ -114,6 +227,54 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
if (camera == null) ctx.fail(result, "No camera connected") else block(camera)
}
/** `setCameraValue {id, value}`: one row of [settings], by enum name or bool. */
private fun setValue(camera: Camera, call: MethodCall, result: MethodChannel.Result) {
val id = call.argument<String>("id")
val s = settings[id]
if (s == null) {
ctx.fail(result, "Unknown camera setting $id")
return
}
val raw = call.argument<Any>("value")
val value: Any? = if (s.type == java.lang.Boolean::class.java) {
raw as? Boolean
} else {
s.type.enumConstants?.firstOrNull { (it as Enum<*>).name == raw?.toString() }
}
if (value == null) {
ctx.fail(result, "Unsupported value $raw for $id")
return
}
setKey(CameraKey.create(s.key, index(camera)), value, result)
}
private fun setKey(key: CameraKey, value: Any, result: MethodChannel.Result) {
val km = KeyManager.getInstance()
if (km == null) {
ctx.fail(result, "SDK not ready")
return
}
km.setValue(key, value, 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) }
}
})
}
private fun meteringGrid(camera: Camera): Pair<Int, Int> {
val km = KeyManager.getInstance()
val i = index(camera)
val cols = (safe { km?.getValue(CameraKey.create(CameraKey.SPOT_METERING_COL_NUMS, i)) } as? Number)?.toInt()
val rows = (safe { km?.getValue(CameraKey.create(CameraKey.SPOT_METERING_ROW_NUMS, i)) } as? Number)?.toInt()
return Pair(cols?.takeIf { it > 0 } ?: 12, rows?.takeIf { it > 0 } ?: 8)
}
private fun unit(v: Double?): Float = (v ?: 0.5).toFloat().coerceIn(0f, 1f)
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
val camera = product.camera ?: return
@@ -143,30 +304,89 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
}
}
}
val index = try {
camera.index
} catch (_: Throwable) {
0
safe {
camera.setHistogramCallback { bins: ShortArray ->
val now = System.currentTimeMillis()
if (now - lastHistogramAt >= 250) {
lastHistogramAt = now
val values = bins.map { it.toInt() }
ctx.mainHandler.post { ctx.emit(mapOf("type" to "histogram", "bins" to values)) }
}
}
}
val index = index(camera)
val optical = safe { camera.isOpticalZoomSupported } == true
val hybrid = safe { camera.isHybridZoomSupported } == true
val digital = safe { camera.isDigitalZoomSupported } == true
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 ->
fun patch(values: Map<String, Any?>) = ctx.hub.patchSlow("cameraSettings", values)
fun watch(key: String, onValue: (Any?) -> Unit) = keys.watch(CameraKey.create(key, index), onValue)
for ((id, s) in settings) watch(s.key) { v -> patch(mapOf(id to KeyValues.toDart(v))) }
// The ISO *setting* (AUTO included); `exposure.iso` is what the camera metered.
watch(CameraKey.ISO) { v -> patch(mapOf("isoSetting" to isoLabel((v as? Enum<*>)?.name))) }
watch(CameraKey.RESOLUTION_FRAME_RATE) { v ->
val r = v as? ResolutionAndFrameRate
ctx.hub.patchSlow(
"cameraSettings",
mapOf("videoResolution" to r?.resolution?.name, "videoFrameRate" to r?.frameRate?.name),
patch(mapOf("videoResolution" to r?.resolution?.name, "videoFrameRate" to r?.frameRate?.name))
}
watch(CameraKey.WHITE_BALANCE) { v ->
val wb = v as? WhiteBalance
patch(
mapOf(
"whiteBalancePreset" to wb?.whiteBalancePreset?.name,
// The SDK counts colour temperature in units of 100 K.
"whiteBalanceKelvin" to wb?.colorTemperature?.takeIf { it > 0 }?.times(100),
)
)
}
watch(CameraKey.PHOTO_TIME_INTERVAL_SETTINGS) { v ->
val t = v as? SettingsDefinitions.PhotoTimeIntervalSettings
patch(mapOf("intervalCount" to t?.captureCount, "intervalSeconds" to t?.timeIntervalInSeconds))
}
watch(CameraKey.PICTURE_STYLE_PRESET) { v ->
val p = v as? SettingsDefinitions.PictureStylePreset
patch(mapOf("styleSharpness" to p?.sharpness, "styleContrast" to p?.contrast, "styleSaturation" to p?.saturation))
}
watch(CameraKey.SPOT_METERING_TARGET) { v ->
val p = v as? Point
patch(mapOf("spotCol" to p?.x, "spotRow" to p?.y))
}
watch(CameraKey.FOCUS_TARGET) { v ->
val p = v as? PointF
patch(mapOf("focusX" to p?.x?.toDouble(), "focusY" to p?.y?.toDouble()))
}
if (hybrid) {
watch(CameraKey.HYBRID_ZOOM_SPEC) { v ->
val z = v as? SettingsDefinitions.HybridZoomSpec
patch(
mapOf(
"zoomMin" to z?.minHybridFocalLength,
"zoomMax" to z?.maxHybridFocalLength,
"zoomOpticalMax" to z?.maxOpticalFocalLength,
"zoomStep" to z?.focalLengthStep,
)
)
}
watch(CameraKey.HYBRID_ZOOM_FOCAL_LENGTH) { v -> patch(mapOf("zoomFocalLength" to (v as? Number)?.toInt())) }
} else if (optical) {
watch(CameraKey.OPTICAL_ZOOM_SPEC) { v ->
val z = v as? SettingsDefinitions.OpticalZoomSpec
patch(
mapOf(
"zoomMin" to z?.minFocalLength,
"zoomMax" to z?.maxFocalLength,
"zoomOpticalMax" to z?.maxFocalLength,
"zoomStep" to z?.focalLengthStep,
)
)
}
watch(CameraKey.OPTICAL_ZOOM_FOCAL_LENGTH) { v -> patch(mapOf("zoomFocalLength" to (v as? Number)?.toInt())) }
}
if (digital) {
watch(CameraKey.DIGITAL_ZOOM_FACTOR) { v -> patch(mapOf("digitalZoomFactor" to (v as? Number)?.toDouble())) }
}
}
}
@@ -202,6 +422,14 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
"isRecording" to state.isRecording,
"recordingTimeSeconds" to state.currentVideoRecordingTimeInSeconds,
"mode" to state.mode?.name,
"shootingSingle" to state.isShootingSinglePhoto,
"shootingInterval" to state.isShootingIntervalPhoto,
"shootingBurst" to (state.isShootingBurstPhoto || state.isShootingRAWBurstPhoto),
"shootingPanorama" to state.isShootingPanoramaPhoto,
"panoramaProgress" to state.panoramaProgress,
"storingPhoto" to state.isStoringPhoto,
"overheating" to state.isOverheating,
"hasError" to state.hasError(),
)
)
}
@@ -213,6 +441,7 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
"iso" to s.iso.toString(),
"shutter" to shutterLabel(s.shutterSpeed?.name),
"aperture" to apertureLabel(s.aperture?.name),
// In manual exposure this is the meter's offset from a correct exposure.
"ev" to evLabel(s.exposureCompensation?.name),
)
)
@@ -240,10 +469,12 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
return (if (neg) "N_" else "P_") + digits
}
/** `AUTO`, a preset name (`SUNNY`, `CLOUDY`, …) or a custom temperature (`5600K`). */
private fun whiteBalance(v: String?): WhiteBalance? {
if (v == null) return null
if (v.equals("AUTO", true)) {
return WhiteBalance(SettingsDefinitions.WhiteBalancePreset.AUTO)
val preset = enumOrNull<SettingsDefinitions.WhiteBalancePreset>(v.uppercase())
if (preset != null && preset != SettingsDefinitions.WhiteBalancePreset.CUSTOM) {
return WhiteBalance(preset)
}
val kelvin = v.replace("K", "", true).trim().toIntOrNull() ?: return null
// DJI custom colour temperature is expressed in units of 100 K.
@@ -2,6 +2,8 @@ package com.pilotvault.flyapp
import dji.common.gimbal.CapabilityKey
import dji.common.util.DJIParamMinMaxCapability
import dji.keysdk.CameraKey
import dji.keysdk.KeyManager
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.gimbal.Gimbal
@@ -163,9 +165,29 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
"$res@$fps"
}
.distinct(),
// Ranges `Capabilities` has no getter for, from the key cache (empty
// until the handshake, like the ones above). The camera menu (plan P4)
// builds its option lists from these.
"photoModes" to cachedNames(cam, CameraKey.SHOOT_PHOTO_MODE_RANGE),
"whiteBalancePresets" to cachedNames(cam, CameraKey.WHITE_BALANCE_PRESENT_RANGE),
"antiFlickerModes" to cachedNames(cam, CameraKey.ANTI_FLICKER_RANGE),
"videoStandards" to cachedNames(cam, CameraKey.VIDEO_STANDARD_RANGE),
"colors" to cachedNames(cam, CameraKey.CAMERA_COLOR_RANGE),
)
}
/** Enum names of a cached range key (an array or list of enums), UNKNOWN dropped. */
private fun cachedNames(cam: Camera, key: String): List<String> {
val index = probe { cam.index } ?: 0
val v = probe { KeyManager.getInstance()?.getValue(CameraKey.create(key, index)) }
val items: List<Any?> = when (v) {
is Array<*> -> v.toList()
is Collection<*> -> v.toList()
else -> emptyList()
}
return items.mapNotNull { (it as? Enum<*>)?.name }.filter { it != "UNKNOWN" }.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()