Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones

Vision radar: graded obstacle sectors around the centre of the video,
the nearest distance per side, the obstacle overhead, and BLOCKED on a
side whose sensors cannot see once the motors run. On aircraft with 360°
sensing, sides without their own readings are filled from the
perception ring. The same sectors sit on the attitude ball's rim, turned
by the heading. Settings > Vision gains Display radar chart and the
braking / sensing distances per direction.

On-screen sticks: the rail's STICKS button hands the aircraft to two
stick pads after a slide-to-confirm. The 25 Hz stream runs natively and
sends zeros once Dart's last command is 300 ms old, so a stalled UI
leaves the aircraft hovering. The sticks close when the remote takes
over, on RTH or on landing. They are simulator-only on aircraft with a
remote unless Settings > General > Developer allows real flights; Spark
and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward
in velocity + body mode is coded from DJI's documentation and must be
checked in the simulator first.

AirSense: a banner for manned aircraft at warning level 2 and up, with
distance, direction and ICAO code; the planes on the map; the full list
in a sheet and Settings > Safety; and the aircraft's own avoidance
action with its countdown.

Fly zones: DJI GEO zones around the aircraft (or the phone) on the map
with their height limits, a banner for FlySafe notices and in/near-zone
states, take-off refused in restricted and locked authorization zones,
self-unlock of authorization zones, the aircraft's licences (enable,
sync from the DJI account, remove all; ground only), and the GEO
database version with an update button.

Tested with unit and widget tests and screenshot renders only; nothing
has run on an aircraft or the simulator yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 21:14:12 +02:00
1 parent 93f4a580e9
commit e02bb0e5cd
34 files changed
+4376 -15

No files matched your search

@@ -0,0 +1,66 @@
package com.pilotvault.flyapp
import dji.common.flightcontroller.adsb.AirSenseSystemInformation
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* AirSense (ADS-B In, plan P8): manned aircraft the drone's receiver hears, as the
* `airSense` slow group of the [TelemetryHub]:
*
* ```
* airSense: {warningLevel: 0..4 | null, airplanes: [{code, warningLevel, direction,
* heading, distance, latitude, longitude}]}
* ```
*
* `code` is the ICAO address, `direction` the compass octant relative to the drone
* (`AirSenseDirection` name), `distance` in metres. MSDK 4.18 reports no altitude.
* Aircraft without a receiver never call back, so the group stays absent.
*
* Aircraft that act on a threat themselves also report `airSenseAvoidance`
* (fast group): `{mode: NONE | AVOIDING | LANDING, countdown: s}`.
*/
class AirSenseBridge(private val ctx: BridgeCtx) : SubBridge {
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
try {
product.flightController?.setASBInformationCallback { info ->
val values = infoMap(info)
ctx.mainHandler.post { ctx.hub.putSlow("airSense", values) }
}
// What the flight controller itself does about a threat (Matrice-class
// aircraft): `NONE` / `AVOIDING` / `LANDING`, with a countdown in seconds.
product.flightController?.setADSBAvoidanceActionCallback { action ->
val values = mapOf(
"mode" to action?.airSenseAvoidanceMode?.name,
"countdown" to action?.avoidanceCountDown?.toDouble(),
)
ctx.mainHandler.post { ctx.hub.putFast(mapOf("airSenseAvoidance" to values)) }
}
} catch (_: Throwable) {
// No AirSense on this flight controller.
}
}
private fun level(name: String?): Int? = name?.removePrefix("LEVEL_")?.toIntOrNull()
private fun infoMap(info: AirSenseSystemInformation?): Map<String, Any?> = mapOf(
"warningLevel" to level(info?.warningLevel?.name),
"airplanes" to info?.airplaneStates.orEmpty().filterNotNull().map { p ->
mapOf(
"code" to p.code,
"warningLevel" to level(p.warningLevel?.name),
"direction" to p.relativeDirection?.name,
"heading" to p.heading.toDouble(),
"distance" to p.distance,
"latitude" to p.latitude,
"longitude" to p.longitude,
)
},
)
}
@@ -111,6 +111,9 @@ class DjiSdkBridge(
ProductProfileBridge(ctx),
KeyBridge(ctx),
CalibrationBridge(ctx),
VirtualStickBridge(ctx),
AirSenseBridge(ctx),
FlyZoneBridge(ctx),
)
init {
@@ -1,6 +1,10 @@
package com.pilotvault.flyapp
import dji.common.error.DJIError
import dji.common.flightcontroller.VisionDetectionState
import dji.common.flightcontroller.flightassistant.ObstacleAvoidanceSensorState
import dji.common.flightcontroller.flightassistant.PerceptionInformation
import dji.common.util.CommonCallbacks
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
@@ -18,13 +22,79 @@ import io.flutter.plugin.common.MethodChannel
*
* Aircraft without obstacle sensing never call back, so `vision` stays absent.
* The palm-control state (`PalmControlState` name) goes out as `palmControl`.
*
* Omnidirectional perception (Mavic 2-class, plan P8) adds the `perception` fast
* group — one distance per `angleInterval` degrees clockwise from the nose, plus
* up / down, in metres with "nothing seen" as null — and the per-direction sensor
* health as the `obstacleSensors` slow group:
* `{forward: {working, enabled}, backward, left, right, up, down}`.
*/
class FlightAssistantBridge(private val ctx: BridgeCtx) : SubBridge {
/** Main thread only. */
private val sensors = sortedMapOf<String, Map<String, Any?>>()
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean = false
private fun assistant() = try {
ctx.aircraft()?.flightController?.flightAssistant
} catch (_: Throwable) {
null
}
/** `Horizontal` / `Upward` / `Downward` → the SDK direction, or null. */
private fun direction(name: String?) =
PerceptionInformation.DJIFlightAssistantObstacleSensingDirection.values().firstOrNull { it.name == name }
/**
* Sensing distances (plan P8): `getSensingDistances` answers
* `{avoidance: {Horizontal, Upward, Downward}, perception: {…}}` in metres, a
* direction the aircraft refuses reading null; `setAvoidanceDistance` (where
* the aircraft brakes) and `setMaxPerceptionDistance` (how far it looks) take
* `{direction, value}`.
*/
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"getSensingDistances" -> {
val a = assistant()
if (a == null) {
ctx.fail(result, "Obstacle sensing unavailable on this aircraft")
return true
}
val dirs = PerceptionInformation.DJIFlightAssistantObstacleSensingDirection.values().toList()
val avoidance = LinkedHashMap<String, Any?>()
val perception = LinkedHashMap<String, Any?>()
var pending = dirs.size * 2
fun done() {
if (--pending == 0) result.success(mapOf("avoidance" to avoidance, "perception" to perception))
}
fun getter(into: MutableMap<String, Any?>, name: String) = object : CommonCallbacks.CompletionCallbackWith<Float> {
override fun onSuccess(v: Float) {
ctx.mainHandler.post { into[name] = v.toDouble(); done() }
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post { into[name] = null; done() }
}
}
for (d in dirs) {
a.getVisualObstaclesAvoidanceDistance(d, getter(avoidance, d.name))
a.getMaxPerceptionDistance(d, getter(perception, d.name))
}
}
"setAvoidanceDistance", "setMaxPerceptionDistance" -> {
val a = assistant()
val d = direction(call.argument<String>("direction"))
val v = call.argument<Double>("value")
when {
a == null -> ctx.fail(result, "Obstacle sensing unavailable on this aircraft")
d == null || v == null -> ctx.fail(result, "Invalid sensing distance")
call.method == "setAvoidanceDistance" -> a.setVisualObstaclesAvoidanceDistance(v.toFloat(), d, ctx.completion(result))
else -> a.setMaxPerceptionDistance(v.toFloat(), d, ctx.completion(result))
}
}
else -> return false
}
return true
}
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
@@ -41,6 +111,22 @@ class FlightAssistantBridge(private val ctx: BridgeCtx) : SubBridge {
ctx.hub.putFast(mapOf("vision" to sensors.toMap()))
}
}
assistant.setVisualPerceptionInformationCallback(object : CommonCallbacks.CompletionCallbackWith<PerceptionInformation> {
override fun onSuccess(info: PerceptionInformation) {
val values = perceptionMap(info)
ctx.mainHandler.post { ctx.hub.putFast(mapOf("perception" to values)) }
}
override fun onFailure(error: DJIError?) {}
})
assistant.setObstacleAvoidanceSensorStateListener(object : CommonCallbacks.CompletionCallbackWith<ObstacleAvoidanceSensorState> {
override fun onSuccess(state: ObstacleAvoidanceSensorState) {
val values = sensorStateMap(state)
ctx.mainHandler.post { ctx.hub.putSlow("obstacleSensors", values) }
}
override fun onFailure(error: DJIError?) {}
})
// Palm control (Spark's advanced gestures): only aircraft that have it call back.
assistant.setPalmControlStateCallback { state ->
ctx.mainHandler.post { ctx.hub.putFast(mapOf("palmControl" to state?.name)) }
@@ -64,4 +150,30 @@ class FlightAssistantBridge(private val ctx: BridgeCtx) : SubBridge {
)
},
)
/** Millimetres → metres; 0 and the "nothing in range" sentinels read as null. */
private fun metres(mm: Int): Double? = if (mm <= 0 || mm >= NOTHING_MM) null else mm / 1000.0
private fun perceptionMap(p: PerceptionInformation): Map<String, Any?> = mapOf(
"angleInterval" to p.angleInterval.toDouble(),
"distances" to p.distances?.map { metres(it) }.orEmpty(),
"up" to metres(p.upwardObstacleDistance),
"down" to metres(p.downwardObstacleDistance),
)
private fun sensorStateMap(s: ObstacleAvoidanceSensorState): Map<String, Any?> = mapOf(
"forward" to side(s.isForwardVisualObstacleAvoidanceSensorWorking, s.isForwardVisualObstacleAvoidanceSensorEnabled),
"backward" to side(s.isBackwardVisualObstacleAvoidanceSensorWorking, s.isBackwardVisualObstacleAvoidanceSensorEnabled),
"left" to side(s.isLeftSideVisualObstacleAvoidanceSensorWorking, s.isLeftSideVisualObstacleAvoidanceSensorEnabled),
"right" to side(s.isRightSideVisualObstacleAvoidanceSensorWorking, s.isRightSideVisualObstacleAvoidanceSensorEnabled),
"up" to side(s.isUpwardVisualObstacleAvoidanceSensorWorking, s.isUpwardVisualObstacleAvoidanceSensorEnabled),
"down" to side(s.isDownwardVisualObstacleAvoidanceSensorWorking, s.isDownwardVisualObstacleAvoidanceSensorEnabled),
)
private fun side(working: Boolean, enabled: Boolean) = mapOf("working" to working, "enabled" to enabled)
private companion object {
/** Perception reports "nothing in range" as a large value rather than 0. */
const val NOTHING_MM = 60000
}
}
@@ -0,0 +1,260 @@
package com.pilotvault.flyapp
import dji.common.error.DJIError
import dji.common.flightcontroller.flyzone.CustomUnlockZone
import dji.common.flightcontroller.flyzone.FlyZoneInformation
import dji.common.flysafe.notification.FlySafeNotification
import dji.common.flysafe.notification.FlySafeNotificationType
import dji.common.util.CommonCallbacks
import dji.sdk.base.BaseProduct
import dji.sdk.flightcontroller.FlyZoneManager
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* DJI GEO fly zones (plan P8): the `FlyZoneManager` is app-level, so it works
* from the phone's position even before an aircraft connects.
*
* - `flyZone` slow group: `{state}` — the `FlyZoneState` name (`CLEAR`,
* `NEAR_RESTRICTED_ZONE`, `IN_WARNING_ZONE`, `IN_RESTRICTED_ZONE`, …);
* - `flySafe` events, one per FlySafe notification: `{kind: TIP | WARNING |
* SERIOUS_WARNING | RETURN_TO_HOME, event, description, heightLimit, countdown,
* zones: [ids]}` — transient messages, so not part of the snapshot;
* - `getFlyZonesNearby {latitude?, longitude?}` → every zone around the point
* (around the aircraft when omitted), see [zoneMap];
* - `getUnlockedFlyZones` → ids unlocked on this aircraft;
* - `unlockAuthorizationFlyZone {id}` — needs a logged-in DJI account;
* - licences (custom unlock zones on the aircraft): `getFlyZoneLicences` →
* `{supported, zones: [customMap]}`, `setFlyZoneLicenceEnabled {id, enabled}`,
* `syncFlyZoneLicences` (DJI account → aircraft), `deleteAllFlyZoneLicences`;
* - the GEO database: `getFlyZoneDatabase` → `{basicVersion, basicState,
* preciseVersion, preciseState}`, `start/stopFlyZoneDatabaseUpgrade`, with
* `flyZoneDbProgress {progress}` events while it downloads.
*/
class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge {
/** The aircraft's licences as last listed, by ID, to enable them by ID. Main thread. */
private val customZones = HashMap<Int, CustomUnlockZone>()
private fun manager(): FlyZoneManager? = try {
DJISDKManager.getInstance().flyZoneManager
} catch (_: Throwable) {
null
}
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"getFlyZonesNearby" -> {
val m = manager() ?: return unavailable(result)
val cb = object : CommonCallbacks.CompletionCallbackWith<ArrayList<FlyZoneInformation>> {
override fun onSuccess(zones: ArrayList<FlyZoneInformation>?) {
val list = zones.orEmpty().filterNotNull().map { zoneMap(it) }
ctx.mainHandler.post { result.success(list) }
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post { result.error("DJI_ERROR", error?.description ?: "Fly zones unavailable", null) }
}
}
val lat = call.argument<Double>("latitude")
val lon = call.argument<Double>("longitude")
if (lat != null && lon != null) m.getFlyZonesInSurroundingArea(lat, lon, cb) else m.getFlyZonesInSurroundingArea(cb)
}
"getUnlockedFlyZones" -> {
val m = manager() ?: return unavailable(result)
m.getUnlockedFlyZonesForAircraft(object : CommonCallbacks.CompletionCallbackWith<List<FlyZoneInformation>> {
override fun onSuccess(zones: List<FlyZoneInformation>?) {
val ids = zones.orEmpty().filterNotNull().map { it.flyZoneID }
ctx.mainHandler.post { result.success(ids) }
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post { result.error("DJI_ERROR", error?.description ?: "Unlocked zones unavailable", null) }
}
})
}
"unlockAuthorizationFlyZone" -> {
val m = manager() ?: return unavailable(result)
val id = call.argument<Int>("id")
if (id == null) ctx.fail(result, "No fly zone given") else m.unlockAuthorizationFlyZone(id, ctx.completion(result))
}
// ── Licences: custom unlock zones on the aircraft ──────────────────────
"getFlyZoneLicences" -> {
val m = manager() ?: return unavailable(result)
val supported = try { m.isCustomUnlockZoneSupported } catch (_: Throwable) { false }
if (!supported) {
result.success(mapOf("supported" to false, "zones" to emptyList<Any?>()))
return true
}
m.getCustomUnlockZonesFromAircraft(object : CommonCallbacks.CompletionCallbackWith<List<CustomUnlockZone>> {
override fun onSuccess(zones: List<CustomUnlockZone>?) {
val list = zones.orEmpty().filterNotNull()
ctx.mainHandler.post {
customZones.clear()
list.forEach { customZones[it.id] = it }
result.success(mapOf("supported" to true, "zones" to list.map { customMap(it) }))
}
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post { result.error("DJI_ERROR", error?.description ?: "Licences unavailable", null) }
}
})
}
"setFlyZoneLicenceEnabled" -> {
val m = manager() ?: return unavailable(result)
val zone = call.argument<Int>("id")?.let { customZones[it] }
when {
zone == null -> ctx.fail(result, "Licence not loaded; refresh the list")
call.argument<Boolean>("enabled") == true -> m.enableCustomUnlockZone(zone, ctx.completion(result))
else -> zone.setEnabled(false, ctx.completion(result))
}
}
// Pulls the licences issued to the logged-in DJI account, then writes them to the aircraft.
"syncFlyZoneLicences" -> {
val m = manager() ?: return unavailable(result)
m.reloadUnlockedZoneGroupsFromServer(object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
if (error != null) {
ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) }
} else {
m.syncUnlockedZoneGroupToAircraft(ctx.completion(result))
}
}
})
}
"deleteAllFlyZoneLicences" -> {
val m = manager() ?: return unavailable(result)
m.deleteAllUnlockingLicenses(ctx.completion(result))
}
// ── GEO database ───────────────────────────────────────────────────────
"getFlyZoneDatabase" -> {
val m = manager() ?: return unavailable(result)
val out = LinkedHashMap<String, Any?>()
var pending = 4
fun done() {
if (--pending == 0) result.success(out)
}
fun <T> into(key: String, read: (T) -> Any?) = object : CommonCallbacks.CompletionCallbackWith<T> {
override fun onSuccess(v: T) {
ctx.mainHandler.post { out[key] = read(v); done() }
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post { out[key] = null; done() }
}
}
m.getBasicDatabaseVersion(into<String>("basicVersion") { it })
m.getBasicDatabaseState(into<dji.common.flightcontroller.flyzone.FlyZoneDatabaseState>("basicState") { it?.name })
m.getPreciseDatabaseVersion(into<String>("preciseVersion") { it })
m.getPreciseDatabaseState(into<dji.common.flightcontroller.flyzone.FlyZoneDatabaseState>("preciseState") { it?.name })
}
"startFlyZoneDatabaseUpgrade" -> {
val m = manager() ?: return unavailable(result)
m.setBasicDatabaseUpgradeProgressUpdatedCallback { p ->
ctx.mainHandler.post { ctx.emit(mapOf("type" to "flyZoneDbProgress", "progress" to p.toDouble())) }
}
m.startBasicDatabaseUpgrade(ctx.completion(result))
}
"stopFlyZoneDatabaseUpgrade" -> {
val m = manager() ?: return unavailable(result)
m.stopBasicDatabaseUpgrade(ctx.completion(result))
}
else -> return false
}
return true
}
private fun unavailable(result: MethodChannel.Result): Boolean {
ctx.fail(result, "Fly zone database unavailable")
return true
}
override fun bind(product: BaseProduct?) {
val m = manager() ?: return
try {
m.setFlyZoneStateCallback { state ->
ctx.mainHandler.post { ctx.hub.putSlow("flyZone", mapOf("state" to state?.name)) }
}
m.setFlySafeNotificationCallback { n -> notification(n)?.let { e -> ctx.mainHandler.post { ctx.emit(e) } } }
} catch (_: Throwable) {
// No GEO database on this SDK build.
}
}
private fun notification(n: FlySafeNotification?): Map<String, Any?>? {
if (n == null) return null
val base = mapOf("type" to "flySafe", "kind" to n.type?.name)
return base + when (n.type) {
FlySafeNotificationType.FLYING_SAFETY_TIP -> n.noticeInformation?.let {
mapOf("event" to it.event?.name, "description" to it.description, "heightLimit" to it.heightLimit, "zones" to ids(it.flyZoneInformation))
}
FlySafeNotificationType.WARNING -> n.warningInformation?.let {
mapOf("event" to it.event?.name, "description" to it.description, "heightLimit" to it.heightLimit, "zones" to ids(it.flyZoneInformation))
}
FlySafeNotificationType.SERIOUS_WARNING -> n.seriousWarningInformation?.let {
mapOf(
"event" to it.event?.name,
"description" to it.description,
"heightLimit" to it.heightLimit,
"countdown" to it.countdown,
"zones" to ids(it.flyZoneInformation),
)
}
FlySafeNotificationType.RETURN_TO_HOME -> n.returnToHomeInformation?.let {
mapOf("event" to it.event?.name, "description" to it.description)
}
else -> null
}.orEmpty()
}
override fun reset() {
customZones.clear()
}
private fun customMap(z: CustomUnlockZone): Map<String, Any?> = mapOf(
"id" to z.id,
"index" to z.index,
"name" to z.name,
"type" to z.unlockZoneType?.name,
"latitude" to z.center?.latitude,
"longitude" to z.center?.longitude,
"radius" to z.radius,
"height" to z.height,
"country" to z.country,
"points" to z.points.orEmpty().filterNotNull().map { listOf(it.latitude, it.longitude) },
"startTime" to z.startTime,
"endTime" to z.endTime,
"expired" to z.isExpired,
"enabled" to z.isEnabled,
)
private fun ids(zones: List<FlyZoneInformation?>?) = zones.orEmpty().filterNotNull().map { it.flyZoneID }
private fun zoneMap(z: FlyZoneInformation): Map<String, Any?> = mapOf(
"id" to z.flyZoneID,
"name" to z.name,
"category" to z.category?.name,
"reason" to z.reason?.name,
"type" to z.flyZoneType?.name,
"latitude" to z.coordinate?.latitude,
"longitude" to z.coordinate?.longitude,
"radius" to z.radius,
"startTime" to z.startTime,
"endTime" to z.endTime,
"unlockStartTime" to z.unlockStartTime,
"unlockEndTime" to z.unlockEndTime,
"subZones" to z.subFlyZones.orEmpty().filterNotNull().map { s ->
mapOf(
"areaId" to s.areaID,
"shape" to s.shape?.name,
"latitude" to s.center?.latitude,
"longitude" to s.center?.longitude,
"radius" to s.radius,
"maxHeight" to s.maxFlightHeight,
"vertices" to s.vertices.orEmpty().filterNotNull().map { listOf(it.latitude, it.longitude) },
)
},
)
}
@@ -0,0 +1,175 @@
package com.pilotvault.flyapp
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import dji.common.error.DJIError
import dji.common.flightcontroller.virtualstick.FlightControlData
import dji.common.flightcontroller.virtualstick.FlightCoordinateSystem
import dji.common.flightcontroller.virtualstick.Limits
import dji.common.flightcontroller.virtualstick.RollPitchControlMode
import dji.common.flightcontroller.virtualstick.VerticalControlMode
import dji.common.flightcontroller.virtualstick.YawControlMode
import dji.common.util.CommonCallbacks
import dji.sdk.flightcontroller.FlightController
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* On-screen sticks (plan P8): `setVirtualStickEnabled {on}` and
* `sendVirtualStick {forward, right, yaw, up}` — body-frame velocities in m/s and
* a yaw rate in °/s, already scaled by Dart.
*
* The flight controller wants a packet at 5–25 Hz or it brakes, and a Flutter
* frame can stall for longer than that, so the stream is generated here: a
* dedicated thread sends the latest command every [PERIOD_MS] (25 Hz). Dart
* repeats its command at ~10 Hz while the sticks are held; a command older than
* [STALE_MS] is replaced by zeros, so a hung UI, a dropped channel or a backgrounded
* app leaves the aircraft hovering rather than flying on.
*
* Every second the loop also asks the flight controller whether virtual-stick mode
* is still on (flipping the remote's mode switch ends it) and publishes the
* `virtualStick` fast group `{enabled, sending}`; if the aircraft dropped out of
* the mode, the loop stops by itself.
*/
class VirtualStickBridge(private val ctx: BridgeCtx) : SubBridge {
private companion object {
const val PERIOD_MS = 40L
const val STALE_MS = 300L
const val POLL_EVERY = 25 // ticks, ≈ 1 s
}
private var thread: HandlerThread? = null
private var loop: Handler? = null
@Volatile private var running = false
@Volatile private var command = FloatArray(4) // forward, right, yaw, up
@Volatile private var commandAtMs = 0L
private var ticks = 0
private fun fc(): FlightController? = ctx.aircraft()?.flightController
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"setVirtualStickEnabled" -> {
val controller = fc()
if (controller == null) {
ctx.fail(result, "No aircraft connected")
} else if (call.argument<Boolean>("on") == true) {
enable(controller, result)
} else {
disable(controller, result)
}
}
"sendVirtualStick" -> {
command = floatArrayOf(
clamp(call.argument<Double>("forward"), Limits.ROLL_PITCH_CONTROL_MIN_VELOCITY, Limits.ROLL_PITCH_CONTROL_MAX_VELOCITY),
clamp(call.argument<Double>("right"), Limits.ROLL_PITCH_CONTROL_MIN_VELOCITY, Limits.ROLL_PITCH_CONTROL_MAX_VELOCITY),
clamp(call.argument<Double>("yaw"), Limits.YAW_CONTROL_MIN_ANGULAR_VELOCITY, Limits.YAW_CONTROL_MAX_ANGULAR_VELOCITY),
clamp(call.argument<Double>("up"), Limits.VERTICAL_CONTROL_MIN_VELOCITY, Limits.VERTICAL_CONTROL_MAX_VELOCITY),
)
commandAtMs = SystemClock.elapsedRealtime()
result.success(null)
}
else -> return false
}
return true
}
private fun enable(controller: FlightController, result: MethodChannel.Result) {
// Velocity in the aircraft's own frame: what a pilot's stick means.
controller.rollPitchControlMode = RollPitchControlMode.VELOCITY
controller.yawControlMode = YawControlMode.ANGULAR_VELOCITY
controller.verticalControlMode = VerticalControlMode.VELOCITY
controller.rollPitchCoordinateSystem = FlightCoordinateSystem.BODY
controller.setVirtualStickModeEnabled(true, object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
ctx.mainHandler.post {
if (error != null) {
result.error("DJI_ERROR", error.description, null)
} else {
start()
result.success(null)
}
}
}
})
}
private fun disable(controller: FlightController, result: MethodChannel.Result) {
stop()
controller.setVirtualStickModeEnabled(false, ctx.completion(result))
}
/** Main thread. */
private fun start() {
command = FloatArray(4)
commandAtMs = 0L
ticks = 0
if (running) return
running = true
val t = HandlerThread("virtual-sticks").also { it.start() }
thread = t
loop = Handler(t.looper).also { it.post(tick) }
publish(enabled = true)
}
/** Main thread. Sends one last hover packet. */
private fun stop() {
if (!running) return
running = false
loop?.removeCallbacksAndMessages(null)
thread?.quitSafely()
loop = null
thread = null
fc()?.sendVirtualStickFlightControlData(FlightControlData(0f, 0f, 0f, 0f), null)
publish(enabled = false)
}
private val tick = object : Runnable {
override fun run() {
if (!running) return
val controller = fc()
if (controller != null) {
val fresh = SystemClock.elapsedRealtime() - commandAtMs <= STALE_MS
val c = if (fresh) command else FloatArray(4)
// In VELOCITY + BODY, MSDK's `pitch` is the sideways (right) velocity and
// `roll` the forward one — DJI's documented quirk, unverified on hardware here.
controller.sendVirtualStickFlightControlData(FlightControlData(c[1], c[0], c[2], c[3]), null)
if (++ticks % POLL_EVERY == 0) poll(controller)
}
loop?.postDelayed(this, PERIOD_MS)
}
}
private fun poll(controller: FlightController) {
controller.getVirtualStickModeEnabled(object : CommonCallbacks.CompletionCallbackWith<Boolean> {
override fun onSuccess(on: Boolean) {
ctx.mainHandler.post { if (!on && running) stop() else publish(enabled = on) }
}
override fun onFailure(error: DJIError?) {}
})
}
private fun publish(enabled: Boolean) {
ctx.mainHandler.post {
ctx.hub.putFast(mapOf("virtualStick" to mapOf("enabled" to enabled, "sending" to running)))
}
}
// No bind(): it re-runs whenever a component appears, mid-flight included; the
// poll notices when the aircraft really left the mode.
/** The product is gone: nothing left to send to. */
override fun reset() {
running = false
loop?.removeCallbacksAndMessages(null)
thread?.quitSafely()
loop = null
thread = null
}
private fun clamp(v: Double?, min: Float, max: Float): Float = (v ?: 0.0).toFloat().coerceIn(min, max)
}