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:
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 {
|
||||
|
||||
+113
-1
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user