From e02bb0e5cd97b44c4e19156ee44d63644f5e998a Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:14:12 +0200 Subject: [PATCH] Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/pilotvault/flyapp/AirSenseBridge.kt | 66 +++ .../com/pilotvault/flyapp/DjiSdkBridge.kt | 3 + .../flyapp/FlightAssistantBridge.kt | 114 ++++- .../com/pilotvault/flyapp/FlyZoneBridge.kt | 260 ++++++++++ .../pilotvault/flyapp/VirtualStickBridge.kt | 175 +++++++ Fly App/lib/dji_service.dart | 71 +++ Fly App/lib/flight_model.dart | 12 +- Fly App/lib/fly/fly_prefs.dart | 33 ++ Fly App/lib/fly/fly_zone_loader.dart | 94 ++++ Fly App/lib/fly/hud_features.dart | 8 + Fly App/lib/fly/safety_gate.dart | 160 +++++++ Fly App/lib/fly/sensing_distances.dart | 71 +++ Fly App/lib/fly/settings/settings_tabs.dart | 17 + Fly App/lib/fly/state/aircraft_state.dart | 7 +- Fly App/lib/fly/state/airsense_state.dart | 120 +++++ Fly App/lib/fly/state/fly_zone_state.dart | 450 ++++++++++++++++++ Fly App/lib/fly/state/vision_state.dart | 59 +++ Fly App/lib/fly/telemetry_reducer.dart | 17 +- Fly App/lib/fly/virtual_sticks.dart | 217 +++++++++ Fly App/lib/fly/vision_radar.dart | 175 +++++++ Fly App/lib/ui/fly/bottom/attitude_ball.dart | 44 +- .../ui/fly/control/virtual_stick_pads.dart | 103 ++++ Fly App/lib/ui/fly/fly_screen.dart | 161 ++++++- Fly App/lib/ui/fly/hud_kit.dart | 13 + Fly App/lib/ui/fly/left/action_rail.dart | 6 +- Fly App/lib/ui/fly/overlays/airsense.dart | 264 ++++++++++ .../ui/fly/overlays/fly_zone_licences.dart | 275 +++++++++++ Fly App/lib/ui/fly/overlays/fly_zones.dart | 333 +++++++++++++ Fly App/lib/ui/fly/overlays/vision_radar.dart | 132 +++++ .../ui/fly/panels/settings/sensing_rows.dart | 106 +++++ .../fly/panels/settings/settings_sheet.dart | 60 +++ Fly App/test/fly/fake_dji.dart | 54 +++ Fly App/test/fly/p8_extras_test.dart | 369 ++++++++++++++ .../test/fly/radar_sticks_airsense_test.dart | 342 +++++++++++++ 34 files changed, 4376 insertions(+), 15 deletions(-) create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirSenseBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlyZoneBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VirtualStickBridge.kt create mode 100644 Fly App/lib/fly/fly_zone_loader.dart create mode 100644 Fly App/lib/fly/sensing_distances.dart create mode 100644 Fly App/lib/fly/state/airsense_state.dart create mode 100644 Fly App/lib/fly/state/fly_zone_state.dart create mode 100644 Fly App/lib/fly/virtual_sticks.dart create mode 100644 Fly App/lib/fly/vision_radar.dart create mode 100644 Fly App/lib/ui/fly/control/virtual_stick_pads.dart create mode 100644 Fly App/lib/ui/fly/overlays/airsense.dart create mode 100644 Fly App/lib/ui/fly/overlays/fly_zone_licences.dart create mode 100644 Fly App/lib/ui/fly/overlays/fly_zones.dart create mode 100644 Fly App/lib/ui/fly/overlays/vision_radar.dart create mode 100644 Fly App/lib/ui/fly/panels/settings/sensing_rows.dart create mode 100644 Fly App/test/fly/p8_extras_test.dart create mode 100644 Fly App/test/fly/radar_sticks_airsense_test.dart diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirSenseBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirSenseBridge.kt new file mode 100644 index 0000000..2c33557 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirSenseBridge.kt @@ -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 = 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, + ) + }, + ) +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt index 317a797..6691620 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt @@ -111,6 +111,9 @@ class DjiSdkBridge( ProductProfileBridge(ctx), KeyBridge(ctx), CalibrationBridge(ctx), + VirtualStickBridge(ctx), + AirSenseBridge(ctx), + FlyZoneBridge(ctx), ) init { diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt index 8178de7..a2e3e42 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightAssistantBridge.kt @@ -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>() - 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() + val perception = LinkedHashMap() + var pending = dirs.size * 2 + fun done() { + if (--pending == 0) result.success(mapOf("avoidance" to avoidance, "perception" to perception)) + } + fun getter(into: MutableMap, name: String) = object : CommonCallbacks.CompletionCallbackWith { + 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("direction")) + val v = call.argument("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 { + 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 { + 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 = 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 = 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 + } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlyZoneBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlyZoneBridge.kt new file mode 100644 index 0000000..9fdd00b --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlyZoneBridge.kt @@ -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() + + 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> { + override fun onSuccess(zones: ArrayList?) { + 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("latitude") + val lon = call.argument("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> { + override fun onSuccess(zones: List?) { + 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("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())) + return true + } + m.getCustomUnlockZonesFromAircraft(object : CommonCallbacks.CompletionCallbackWith> { + override fun onSuccess(zones: List?) { + 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("id")?.let { customZones[it] } + when { + zone == null -> ctx.fail(result, "Licence not loaded; refresh the list") + call.argument("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 { + 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() + var pending = 4 + fun done() { + if (--pending == 0) result.success(out) + } + fun into(key: String, read: (T) -> Any?) = object : CommonCallbacks.CompletionCallbackWith { + 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("basicVersion") { it }) + m.getBasicDatabaseState(into("basicState") { it?.name }) + m.getPreciseDatabaseVersion(into("preciseVersion") { it }) + m.getPreciseDatabaseState(into("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? { + 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 = 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?) = zones.orEmpty().filterNotNull().map { it.flyZoneID } + + private fun zoneMap(z: FlyZoneInformation): Map = 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) }, + ) + }, + ) +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VirtualStickBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VirtualStickBridge.kt new file mode 100644 index 0000000..73379e7 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VirtualStickBridge.kt @@ -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("on") == true) { + enable(controller, result) + } else { + disable(controller, result) + } + } + "sendVirtualStick" -> { + command = floatArrayOf( + clamp(call.argument("forward"), Limits.ROLL_PITCH_CONTROL_MIN_VELOCITY, Limits.ROLL_PITCH_CONTROL_MAX_VELOCITY), + clamp(call.argument("right"), Limits.ROLL_PITCH_CONTROL_MIN_VELOCITY, Limits.ROLL_PITCH_CONTROL_MAX_VELOCITY), + clamp(call.argument("yaw"), Limits.YAW_CONTROL_MIN_ANGULAR_VELOCITY, Limits.YAW_CONTROL_MAX_ANGULAR_VELOCITY), + clamp(call.argument("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 { + 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 { + 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) +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 9d17202..02c2022 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -334,6 +334,77 @@ class DjiService { Future setTapFlySpeed(double speed) => _methods.invokeMethod('setTapFlySpeed', {'speed': speed}); Future resetTapFlyHeading() => _methods.invokeMethod('resetTapFlyHeading'); + // ── Virtual sticks (plan P8) ──────────────────────────────────────────────── + /// Puts the flight controller in (or out of) virtual-stick mode; while on, the + /// native side streams the last [sendVirtualStick] at 25 Hz, or zeros once it + /// is more than 300 ms old. + Future setVirtualStickEnabled(bool on) => + _methods.invokeMethod('setVirtualStickEnabled', {'on': on}); + + /// Body-frame velocities (m/s, forward / right / up positive) and yaw rate (°/s, clockwise). + Future sendVirtualStick({required double forward, required double right, required double yaw, required double up}) => + _methods.invokeMethod('sendVirtualStick', {'forward': forward, 'right': right, 'yaw': yaw, 'up': up}); + + // ── Sensing distances (plan P8) ───────────────────────────────────────────── + /// `{avoidance: {Horizontal, Upward, Downward}, perception: {…}}` in metres; + /// a direction the aircraft does not support reads null. + Future> getSensingDistances() async { + final dynamic m = await _methods.invokeMethod('getSensingDistances'); + return Map.from(m as Map); + } + + /// Where the aircraft brakes for an obstacle in [direction] (`Horizontal` / `Upward` / `Downward`). + Future setAvoidanceDistance(String direction, double metres) => + _methods.invokeMethod('setAvoidanceDistance', {'direction': direction, 'value': metres}); + + /// How far the aircraft looks for obstacles in [direction]. + Future setMaxPerceptionDistance(String direction, double metres) => + _methods.invokeMethod('setMaxPerceptionDistance', {'direction': direction, 'value': metres}); + + // ── Fly zones (plan P8) ───────────────────────────────────────────────────── + /// GEO zones around the point, or around the aircraft when omitted. + Future>> getFlyZonesNearby({double? latitude, double? longitude}) async { + final List? l = await _methods.invokeMethod>('getFlyZonesNearby', { + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + return >[for (final Object? z in l ?? const []) if (z is Map) Map.from(z)]; + } + + /// Ids of the authorization zones unlocked on the connected aircraft. + Future> getUnlockedFlyZones() async { + final List? l = await _methods.invokeMethod>('getUnlockedFlyZones'); + return [for (final Object? id in l ?? const []) if (id is int) id]; + } + + /// Self-unlocks an authorization zone; needs a logged-in DJI account. + Future unlockAuthorizationFlyZone(int id) => + _methods.invokeMethod('unlockAuthorizationFlyZone', {'id': id}); + + /// The aircraft's licences (custom unlock zones): `{supported, zones: [...]}`. + Future> getFlyZoneLicences() async { + final dynamic m = await _methods.invokeMethod('getFlyZoneLicences'); + return Map.from(m as Map); + } + + Future setFlyZoneLicenceEnabled(int id, bool enabled) => + _methods.invokeMethod('setFlyZoneLicenceEnabled', {'id': id, 'enabled': enabled}); + + /// Downloads the logged-in DJI account's licences and writes them to the aircraft. + Future syncFlyZoneLicences() => _methods.invokeMethod('syncFlyZoneLicences'); + + Future deleteAllFlyZoneLicences() => _methods.invokeMethod('deleteAllFlyZoneLicences'); + + /// `{basicVersion, basicState, preciseVersion, preciseState}`; unknown parts are null. + Future> getFlyZoneDatabase() async { + final dynamic m = await _methods.invokeMethod('getFlyZoneDatabase'); + return Map.from(m as Map); + } + + /// Downloads the basic GEO database; progress arrives as `flyZoneDbProgress` events. + Future startFlyZoneDatabaseUpgrade() => _methods.invokeMethod('startFlyZoneDatabaseUpgrade'); + Future stopFlyZoneDatabaseUpgrade() => _methods.invokeMethod('stopFlyZoneDatabaseUpgrade'); + /// A DJI-Fly QuickShot by name (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid). /// The native side maps each to the closest real operator or returns a typed /// "unsupported on this aircraft" error. diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 677926e..49964d9 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -4,10 +4,12 @@ import 'package:flutter/foundation.dart'; import 'fly/product_profile.dart'; import 'fly/state/aircraft_state.dart'; +import 'fly/state/airsense_state.dart'; import 'fly/state/alert_state.dart'; import 'fly/state/battery_state.dart'; import 'fly/state/calibration_state.dart'; import 'fly/state/camera_state.dart'; +import 'fly/state/fly_zone_state.dart'; import 'fly/state/gimbal_state.dart'; import 'fly/state/key_state.dart'; import 'fly/state/link_state.dart'; @@ -128,6 +130,12 @@ class FlightModel extends ChangeNotifier { final AlertState alerts = AlertState(); final MissionState mission = MissionState(); + /// Manned aircraft the AirSense receiver hears (plan P8). + final AirSenseState airSense = AirSenseState(); + + /// DJI GEO fly zones around and the latest FlySafe notice (plan P8). + final FlyZoneState flyZones = FlyZoneState(); + /// KeyManager values the settings sheet listens to (plan P6). final KeyState keys = KeyState(); @@ -139,7 +147,7 @@ class FlightModel extends ChangeNotifier { final ValueNotifier videoSize = ValueNotifier(null); late final List _subs = [ - aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, // + aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, // ]; /// Wipes the aircraft-bound sub-models on disconnect. @@ -153,6 +161,8 @@ class FlightModel extends ChangeNotifier { alerts.clear(); keys.clear(); calibration.clear(); + airSense.clear(); + flyZones.clearAircraft(); }); // ── SDK / registration / connection ──────────────────────────────────────── diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 560fbac..2adcc56 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'virtual_sticks.dart' show StickMode; + /// The video overlay grid (camera menu → Other, and the rail's GRID button). enum GridStyle { off('Off'), @@ -31,6 +33,9 @@ abstract final class FlyPrefs { static const String _longPressKey = 'fly.longPress'; static const String _routeKey = 'fly.showRoute'; static const String _termsKey = 'fly.modeTerms'; + static const String _radarKey = 'fly.radar'; + static const String _stickModeKey = 'fly.stickMode'; + static const String _sticksRealKey = 'fly.sticksRealFlight'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept /// reachable until P3's safety flows pass their simulator run (plan P2/P3). @@ -50,6 +55,16 @@ abstract final class FlyPrefs { /// Intelligent flight modes whose first-use terms the pilot accepted (plan P7), by name. static final ValueNotifier> acceptedModeTerms = ValueNotifier>({}); + /// Draw the vision radar on the video (GO 4's "Display Radar Chart", plan P8). + static final ValueNotifier showRadar = ValueNotifier(true); + + /// What each on-screen stick does (plan P8); matches the remote's stick mode by default. + static final ValueNotifier stickMode = ValueNotifier(StickMode.mode2); + + /// Developer switch: allow on-screen sticks outside the simulator on aircraft + /// that have a remote. Off by default (plan P8: simulator first). + static final ValueNotifier sticksInRealFlight = ValueNotifier(false); + static SharedPreferences? _prefs; static Future load() async { @@ -62,6 +77,9 @@ abstract final class FlyPrefs { longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter; showRoute.value = _prefs!.getBool(_routeKey) ?? true; acceptedModeTerms.value = (_prefs!.getStringList(_termsKey) ?? const []).toSet(); + showRadar.value = _prefs!.getBool(_radarKey) ?? true; + stickMode.value = _byName(StickMode.values, _prefs!.getString(_stickModeKey)) ?? StickMode.mode2; + sticksInRealFlight.value = _prefs!.getBool(_sticksRealKey) ?? false; } catch (_) { // Storage unavailable: run on the defaults. } @@ -103,6 +121,21 @@ abstract final class FlyPrefs { await _prefs?.setStringList(_termsKey, acceptedModeTerms.value.toList()..sort()); } + static Future setShowRadar(bool on) async { + showRadar.value = on; + await _prefs?.setBool(_radarKey, on); + } + + static Future setStickMode(StickMode m) async { + stickMode.value = m; + await _prefs?.setString(_stickModeKey, m.name); + } + + static Future setSticksInRealFlight(bool on) async { + sticksInRealFlight.value = on; + await _prefs?.setBool(_sticksRealKey, on); + } + static T? _byName(List values, String? name) { for (final T v in values) { if (v.name == name) return v; diff --git a/Fly App/lib/fly/fly_zone_loader.dart b/Fly App/lib/fly/fly_zone_loader.dart new file mode 100644 index 0000000..44a7a56 --- /dev/null +++ b/Fly App/lib/fly/fly_zone_loader.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import '../dji_service.dart'; +import '../flight_model.dart'; + +/// Keeps [FlightModel.flyZones] stocked with the GEO zones around (plan P8): +/// fetched around the aircraft's fix, or the phone's before it has one, again +/// after moving 2 km or every 10 minutes; plus the unlocked list once per +/// connection with the aircraft's licences, and the GEO database's state at +/// start. One fetch at a time; failures wait for the next position. +class FlyZoneLoader { + FlyZoneLoader({required this.model, required this.dji}); + + final FlightModel model; + final DjiService dji; + + bool _busy = false; + bool _unlockedLoaded = false; + + void start() { + model.aircraft.addListener(_check); + model.addListener(_onModel); + unawaited(refreshDatabase()); + _check(); + } + + void dispose() { + model.aircraft.removeListener(_check); + model.removeListener(_onModel); + } + + void _onModel() { + if (!model.connected) _unlockedLoaded = false; + _check(); + } + + /// The aircraft's fix, else the phone's. + (double, double)? get _position { + final double? lat = model.aircraft.latitude, lon = model.aircraft.longitude; + if (lat != null && lon != null && !(lat == 0 && lon == 0)) return (lat, lon); + final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude; + if (pLat != null && pLon != null) return (pLat, pLon); + return null; + } + + void _check() { + if (model.connected && !_unlockedLoaded) { + _unlockedLoaded = true; + unawaited(refreshUnlocked()); + unawaited(refreshLicences()); + } + final (double, double)? p = _position; + if (p == null || _busy || !model.flyZones.needsZonesFor(p.$1, p.$2)) return; + unawaited(_fetch(p)); + } + + Future _fetch((double, double) p) async { + _busy = true; + try { + final List> zones = await dji.getFlyZonesNearby(latitude: p.$1, longitude: p.$2); + model.flyZones.applyZones(zones, latitude: p.$1, longitude: p.$2); + } catch (_) { + // Database not ready or offline: retry when the position moves on. + } finally { + _busy = false; + } + } + + /// The aircraft's licences (custom unlock zones). + Future refreshLicences() async { + try { + model.flyZones.applyLicences(await dji.getFlyZoneLicences()); + } catch (_) { + // Not reported by this aircraft. + } + } + + /// The GEO database's versions and state (app-level, no aircraft needed). + Future refreshDatabase() async { + try { + model.flyZones.applyDatabase(await dji.getFlyZoneDatabase()); + } catch (_) { + // SDK not registered yet. + } + } + + Future refreshUnlocked() async { + try { + model.flyZones.applyUnlocked(await dji.getUnlockedFlyZones()); + } catch (_) { + // Not reported by this aircraft. + } + } +} diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index 8d49ee7..db191dd 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -95,6 +95,12 @@ abstract final class HudFeatures { /// Precision take-off records the spot with the downward cameras. static final HudFeature precisionTakeoff = HudFeature( 'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps); + /// ADS-B In: the receiver's own reports decide at runtime; the profile only rules out non-aircraft. + static final HudFeature airSense = HudFeature( + 'overlay.airSense', 'AirSense (ADS-B) warnings', 'Overlays', (ProductProfile p) => p.isAircraft); + /// DJI GEO zones: the database is app-level, every aircraft gets it. + static final HudFeature flyZones = HudFeature( + 'overlay.flyZones', 'Fly zones (GEO) warnings + map', 'Overlays', (ProductProfile p) => p.isAircraft); static final HudFeature simulator = HudFeature( 'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator); @@ -174,6 +180,8 @@ abstract final class HudFeatures { vpsReadout, visionRadar, virtualSticks, + airSense, + flyZones, precisionTakeoff, simulator, settingsVision, diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index 54853c2..d00e3e6 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -8,13 +8,16 @@ import '../flight_model.dart'; import 'action_rail_state.dart'; import 'calibration.dart'; import 'flight_status.dart'; +import 'fly_prefs.dart'; import 'hud_geometry.dart'; import 'modes/vision_modes.dart'; import 'modes/waypoint_mission.dart'; import 'product_profile.dart'; import 'state/aircraft_state.dart'; import 'state/alert_state.dart'; +import 'state/fly_zone_state.dart'; import 'state/mission_state.dart'; +import 'virtual_sticks.dart'; export 'action_rail_state.dart' show FlightCommand; @@ -197,6 +200,93 @@ class SafetyGate { if (model.profile.hasCamera && model.camera.storage != null && !model.camera.storage!.usable) { checks.add(GateCheck.warn('${model.camera.storage!.status}: photos and video will not be saved.')); } + _flyZone(checks); + } + + // ── Fly zones (plan P8) ───────────────────────────────────────────────────── + + /// DJI's GEO system refuses take-off in restricted and locked authorization + /// zones on its own; saying so here keeps the slider from promising otherwise. + void _flyZone(List checks) { + final FlyZoneState z = model.flyZones; + switch (z.state) { + case 'IN_RESTRICTED_ZONE': + case 'SUSPECTED_IN_RESTRICTED_ZONE': + checks.add(const GateCheck.block('The aircraft is in a restricted zone: take-off is not allowed here.')); + case 'IN_AUTHORIZED_ZONE': + case 'SUSPECTED_IN_AUTHORIZED_ZONE': + checks.add(const GateCheck.block('Authorization zone: unlock it first (Settings → Safety → Fly zones).')); + case 'IN_ENHANCE_WARNING_ZONE': + checks.add(const GateCheck.warn('Enhanced warning zone: check local rules before flying.')); + case 'IN_WARNING_ZONE': + checks.add(const GateCheck.warn('Warning zone: check local rules before flying.')); + case 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION': + case 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION': + checks.add(const GateCheck.warn('Height-limited zone: the aircraft will not climb above the zone\'s limit.')); + } + } + + /// Switching or removing licences changes what the flight controller lets the + /// aircraft do, so it happens on the ground only. + List checkLicenceChange() => [ + if (!model.connected) const GateCheck.block('Connect the aircraft to change its licences.'), + if (model.connected && _a.isFlying == true) const GateCheck.block('Land before changing licences.'), + ]; + + Future _licences(Future Function() change) async { + final List checks = checkLicenceChange(); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await change(); + try { + model.flyZones.applyLicences(await dji.getFlyZoneLicences()); + } catch (_) { + // The list refreshes on the next connection. + } + } + + /// Enables [licence] (the aircraft keeps one enabled at a time) or disables it. + Future setLicenceEnabled(FlyZoneLicence licence, bool enabled) async { + if (enabled && licence.expired) throw const MissionRefused([GateCheck.block('This licence has expired.')]); + await _licences(() => dji.setFlyZoneLicenceEnabled(licence.id, enabled)); + } + + /// Fetches the DJI account's licences and writes them to the aircraft. + Future syncLicences() => _licences(dji.syncFlyZoneLicences); + + /// Removes every licence from the aircraft. + Future deleteAllLicences() => _licences(dji.deleteAllFlyZoneLicences); + + /// Downloads the basic GEO database; the database state refreshes when it ends. + Future upgradeFlyZoneDatabase() async { + model.flyZones.applyUpgradeProgress(0); + try { + await dji.startFlyZoneDatabaseUpgrade(); + } finally { + model.flyZones.applyUpgradeProgress(null); + try { + model.flyZones.applyDatabase(await dji.getFlyZoneDatabase()); + } catch (_) { + // Keeps the last known state. + } + } + } + + Future cancelFlyZoneDatabaseUpgrade() async { + await dji.stopFlyZoneDatabaseUpgrade(); + model.flyZones.applyUpgradeProgress(null); + } + + /// Self-unlocks an authorization zone (DJI account required). Unlocking is + /// the pilot declaring they may fly there, so the UI confirms it first. + Future unlockFlyZone(FlyZone zone) async { + if (!zone.unlockable) throw const MissionRefused([GateCheck.block('Only authorization zones can be self-unlocked.')]); + if (!model.connected) throw const MissionRefused([GateCheck.block('Connect the aircraft to unlock a zone for it.')]); + await dji.unlockAuthorizationFlyZone(zone.id); + try { + model.flyZones.applyUnlocked({...model.flyZones.unlocked, ...await dji.getUnlockedFlyZones()}); + } catch (_) { + model.flyZones.applyUnlocked({...model.flyZones.unlocked, zone.id}); + } } void _goHome(List checks) { @@ -577,6 +667,76 @@ class SafetyGate { await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed)); } + // ── Virtual sticks (plan P8) ──────────────────────────────────────────────── + + /// Preconditions for handing the aircraft to the on-screen sticks. + /// + /// Simulator first: on an aircraft that has a remote, the sticks are refused + /// outside the simulator unless the developer switch allows them. Aircraft + /// whose primary control *is* the phone (Spark / Mavic Air on Wi-Fi) may fly + /// them for real, with a warning. + List checkVirtualSticks() { + final List checks = []; + if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.')); + if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.')); + final ProductProfile p = model.profile; + if (p.connected && !p.supportsVirtualStick) return checks..add(const GateCheck.block('This aircraft cannot be flown with on-screen sticks.')); + final String mode = _a.flightModeEnum ?? ''; + if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.')); + if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.')); + switch (RailState.activeMode(_a, model.mission)) { + case ActiveMode.waypoint: + checks.add(const GateCheck.block('Stop the waypoint mission first.')); + case ActiveMode.activeTrack: + checks.add(const GateCheck.block('Stop ActiveTrack first.')); + case ActiveMode.tapFly: + checks.add(const GateCheck.block('Stop TapFly first.')); + case ActiveMode.other: + checks.add(const GateCheck.block('Stop the intelligent flight mode first.')); + case ActiveMode.none: + break; + } + final int? percent = model.battery.percent; + final int critical = model.battery.seriousLowWarningThreshold ?? 15; + if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) { + checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.')); + } + final bool simulating = _a.simulatorActive == true; + if (!simulating) { + if (!p.virtualStickPrimary && !FlyPrefs.sticksInRealFlight.value) { + checks.add(const GateCheck.block( + 'On-screen sticks are for the simulator on this aircraft: start it in Settings → General → Developer, ' + 'or allow real flights there.')); + } else { + checks.add(const GateCheck.warn('This is a real flight: the aircraft moves as you push the sticks.')); + } + } + if (_a.isFlying != true) { + checks.add(const GateCheck.warn('The sticks do not start the motors: take off with TAKE OFF, then fly.')); + } + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel && !simulating) { + checks.add(const GateCheck.warn('Weak GPS: the aircraft drifts when the sticks are centred.')); + } + if (p.hasRcLink) { + checks.add(const GateCheck.warn("The remote's sticks are ignored meanwhile: flip its flight-mode switch to take back control.")); + } + return checks; + } + + /// Re-checks and puts the flight controller in virtual-stick mode. + Future enableVirtualSticks() async { + final List checks = checkVirtualSticks(); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.setVirtualStickEnabled(true); + } + + /// Hands control back to the remote. Always allowed. + Future disableVirtualSticks() => dji.setVirtualStickEnabled(false); + + /// One stick packet. Not re-checked per packet (25 Hz); the controller closes + /// the sticks when RTH, landing or the remote takes over. + Future sendSticks(StickCommand c) => dji.sendVirtualStick(forward: c.forward, right: c.right, yaw: c.yaw, up: c.up); + /// True when the pilot stands far enough from the recorded home point that /// RTH should ask whether to come back to them instead (GO 4's RTH target). bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance; diff --git a/Fly App/lib/fly/sensing_distances.dart b/Fly App/lib/fly/sensing_distances.dart new file mode 100644 index 0000000..c64182a --- /dev/null +++ b/Fly App/lib/fly/sensing_distances.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; + +import 'state/parse.dart'; + +/// The three directions MSDK's FlightAssistant sets distances for. +enum SensingDirection { + horizontal('Horizontal', 'Horizontal'), + up('Upward', 'Upward'), + down('Downward', 'Downward'); + + const SensingDirection(this.sdk, this.label); + + /// `DJIFlightAssistantObstacleSensingDirection` name. + final String sdk; + final String label; +} + +/// Which of the two distances. +enum SensingKind { + /// `setVisualObstaclesAvoidanceDistance`: where the aircraft brakes. + avoidance, + + /// `setMaxPerceptionDistance`: how far it looks (the warning distance). + perception, +} + +/// The aircraft's obstacle braking and perception distances (plan P8, GO 4 / +/// Pilot's Vision "obstacle distance" rows). A null value is a direction the +/// aircraft refused to report, and its row hides. +@immutable +class SensingDistances { + const SensingDistances({this.avoidance = const {}, this.perception = const {}}); + + factory SensingDistances.fromBridge(Map m) { + Map read(Object? v) { + final Map? g = asMap(v); + return {for (final SensingDirection d in SensingDirection.values) d: finite(g?[d.sdk])}; + } + + return SensingDistances(avoidance: read(m['avoidance']), perception: read(m['perception'])); + } + + final Map avoidance; + final Map perception; + + double? value(SensingKind k, SensingDirection d) => (k == SensingKind.avoidance ? avoidance : perception)[d]; + + bool get isEmpty => avoidance.values.every((double? v) => v == null) && perception.values.every((double? v) => v == null); + + SensingDistances withValue(SensingKind k, SensingDirection d, double v) => SensingDistances( + avoidance: k == SensingKind.avoidance ? {...avoidance, d: v} : avoidance, + perception: k == SensingKind.perception ? {...perception, d: v} : perception, + ); + + /// Range and step a row offers. MSDK 4.18 documents no limits; these follow + /// DJI Pilot's sliders and the flight controller refuses anything it does not + /// accept (the row then rolls back). Unverified on hardware. + static ({double min, double max, double step}) range(SensingKind k, SensingDirection d) => switch ((k, d)) { + (SensingKind.avoidance, SensingDirection.down) => (min: 0.5, max: 3, step: 0.1), + (SensingKind.avoidance, _) => (min: 1, max: 10, step: 0.5), + (SensingKind.perception, SensingDirection.down) => (min: 1, max: 10, step: 0.5), + (SensingKind.perception, _) => (min: 5, max: 40, step: 1), + }; + + /// [v] moved [steps] steps within the range, snapped to the step. + static double stepped(SensingKind k, SensingDirection d, double v, int steps) { + final ({double min, double max, double step}) r = range(k, d); + final double next = ((v / r.step).round() + steps) * r.step; + return double.parse(next.clamp(r.min, r.max).toStringAsFixed(2)); + } +} diff --git a/Fly App/lib/fly/settings/settings_tabs.dart b/Fly App/lib/fly/settings/settings_tabs.dart index 5588333..380d389 100644 --- a/Fly App/lib/fly/settings/settings_tabs.dart +++ b/Fly App/lib/fly/settings/settings_tabs.dart @@ -100,6 +100,13 @@ abstract final class SettingsTabs { static const String stickDiagram = 'stickDiagram'; static const String gimbalList = 'gimbalList'; static const String showRoute = 'showRoute'; + static const String radarChart = 'radarChart'; + static const String stickPads = 'stickPads'; + static const String airSenseList = 'airSenseList'; + static const String sensingDistances = 'sensingDistances'; + static const String flyZoneList = 'flyZoneList'; + static const String flyZoneLicences = 'flyZoneLicences'; + static const String flyZoneDatabase = 'flyZoneDatabase'; static const String clearRoute = 'clearRoute'; static const String about = 'about'; static const String developer = 'developer'; @@ -195,11 +202,18 @@ abstract final class SettingsTabs { const KeySwitchSpec(id: 'escBeep', label: 'ESC beeping', key: SettingsKeys.escBeep), const GroupSpec('SENSORS'), const CustomSpec(id: sensors, keys: [SettingsKeys.imuCount, SettingsKeys.compassCount]), + GroupSpec('AIRSENSE', requires: HudFeatures.airSense), + CustomSpec(id: airSenseList, requires: HudFeatures.airSense), + GroupSpec('FLY ZONES', requires: HudFeatures.flyZones), + CustomSpec(id: flyZoneList, requires: HudFeatures.flyZones), + CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones), + CustomSpec(id: flyZoneDatabase, requires: HudFeatures.flyZones), ]; // ── Vision ────────────────────────────────────────────────────────────────── static final List _vision = [ GroupSpec('OBSTACLE AVOIDANCE', requires: HudFeatures.settingsObstacle), + CustomSpec(id: radarChart, requires: HudFeatures.visionRadar), KeySwitchSpec( id: 'collision', label: 'Obstacle avoidance', @@ -238,6 +252,7 @@ abstract final class SettingsTabs { key: SettingsKeys.rthRemoteAvoidance, requires: HudFeatures.settingsObstacle, ), + CustomSpec(id: sensingDistances, requires: HudFeatures.settingsObstacle), const GroupSpec('POSITIONING & LANDING'), const KeySwitchSpec(id: 'vps', label: 'Vision positioning', key: SettingsKeys.vps), const KeySwitchSpec( @@ -496,6 +511,8 @@ abstract final class SettingsTabs { const GroupSpec('MAP'), const CustomSpec(id: showRoute), const CustomSpec(id: clearRoute), + GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks), + CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks), const GroupSpec('ABOUT'), const CustomSpec(id: about, keys: [SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]), const GroupSpec('DEVELOPER'), diff --git a/Fly App/lib/fly/state/aircraft_state.dart b/Fly App/lib/fly/state/aircraft_state.dart index f84da9f..873b582 100644 --- a/Fly App/lib/fly/state/aircraft_state.dart +++ b/Fly App/lib/fly/state/aircraft_state.dart @@ -118,6 +118,10 @@ class AircraftState extends ChangeNotifier { /// The flight controller's built-in simulator is running (developer only). bool? simulatorActive; + /// The flight controller is in virtual-stick mode (plan P8), from the native + /// stick loop's `virtualStick` group; null until sticks were first enabled. + bool? virtualStickEnabled; + bool? compassHasError; bool? compassCalibrating; @@ -172,6 +176,7 @@ class AircraftState extends ChangeNotifier { failsafe = asBool(t['isFailsafe']); orientationMode = asString(t['orientationMode']); simulatorActive = asBool(t['simulatorActive']); + virtualStickEnabled = asBool(asMap(t['virtualStick'])?['enabled']); compassHasError = asBool(t['compassHasError']); compassCalibrating = asBool(t['compassCalibrating']); compassCalibrationState = asString(t['compassCalibrationState']); @@ -183,7 +188,7 @@ class AircraftState extends ChangeNotifier { satellites = gpsSignalLevel = flightTimeSeconds = null; isFlying = motorsOn = homeLocationSet = ultrasonicBeingUsed = ultrasonicError = null; visionPositioningBeingUsed = landingConfirmationNeeded = goingHome = null; - lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = null; + lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = virtualStickEnabled = null; compassHasError = compassCalibrating = null; compassCalibrationState = null; flightMode = flightModeEnum = goHomeExecutionState = batteryThresholdBehavior = windWarning = orientationMode = null; diff --git a/Fly App/lib/fly/state/airsense_state.dart b/Fly App/lib/fly/state/airsense_state.dart new file mode 100644 index 0000000..c5c52dd --- /dev/null +++ b/Fly App/lib/fly/state/airsense_state.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; + +import 'parse.dart'; + +/// One manned aircraft the AirSense (ADS-B In) receiver hears. +@immutable +class AirSensePlane { + const AirSensePlane({ + required this.code, + this.warningLevel = 0, + this.direction, + this.heading, + this.distance, + this.latitude, + this.longitude, + }); + + factory AirSensePlane.fromMap(Map m) => AirSensePlane( + code: (asString(m['code']) ?? '').trim(), + warningLevel: asInt(m['warningLevel']) ?? 0, + direction: asString(m['direction']), + heading: finite(m['heading']), + distance: finite(m['distance']), + latitude: finite(m['latitude']), + longitude: finite(m['longitude']), + ); + + /// ICAO 24-bit address, as the receiver formats it. + final String code; + + /// 0 (no threat) … 4 (collision risk). + final int warningLevel; + + /// `AirSenseDirection` octant relative to the drone: `NORTH`, `NORTH_EAST`, … `UNKNOWN`. + final String? direction; + + final double? heading; // deg + final double? distance; // m + final double? latitude; + final double? longitude; + + bool get hasPosition => latitude != null && longitude != null && !(latitude == 0 && longitude == 0); + + /// `NE`, `S`, … or null when unknown. + String? get octant => switch (direction) { + 'NORTH' => 'N', + 'NORTH_EAST' => 'NE', + 'EAST' => 'E', + 'SOUTH_EAST' => 'SE', + 'SOUTH' => 'S', + 'SOUTH_WEST' => 'SW', + 'WEST' => 'W', + 'NORTH_WEST' => 'NW', + _ => null, + }; +} + +/// AirSense (plan P8): the receiver's overall warning and every plane it hears, +/// from the telemetry tick's `airSense` group. [reported] stays false on +/// aircraft without a receiver. +class AirSenseState extends ChangeNotifier { + bool reported = false; + + /// The receiver's overall level, 0–4. + int warningLevel = 0; + + /// Nearest first. + List planes = const []; + + /// The level to act on: the receiver's own, or the worst single plane's. + int get level => planes.fold(warningLevel, (int w, AirSensePlane p) => p.warningLevel > w ? p.warningLevel : w); + + /// Level 2 asks for caution; 3 and 4 ask the pilot to descend. + static const int cautionLevel = 2; + static const int dangerLevel = 3; + + /// Planes at caution level or worse, nearest first. + List get threats => [for (final AirSensePlane p in planes) if (p.warningLevel >= cautionLevel) p]; + + /// The plane a warning should name: the worst, then the nearest. + AirSensePlane? get worst { + AirSensePlane? best; + for (final AirSensePlane p in planes) { + if (best == null || p.warningLevel > best.warningLevel) best = p; + } + return best; + } + + void apply(Map? m) { + reported = m != null; + warningLevel = asInt(m?['warningLevel']) ?? 0; + planes = [for (final Map p in asMapList(m?['airplanes'])) AirSensePlane.fromMap(p)] + ..sort((AirSensePlane a, AirSensePlane b) => (a.distance ?? double.infinity).compareTo(b.distance ?? double.infinity)); + notifyListeners(); + } + + /// What the flight controller does about a threat by itself, on the aircraft + /// that report it (`airSenseAvoidance`): `NONE`, `AVOIDING` or `LANDING`. + String? avoidanceMode; + + /// Seconds until the avoidance action starts. + double? avoidanceCountdown; + + bool get avoiding => avoidanceMode == 'AVOIDING' || avoidanceMode == 'LANDING'; + + void applyAvoidance(Map? m) { + avoidanceMode = asString(m?['mode']); + avoidanceCountdown = finite(m?['countdown']); + notifyListeners(); + } + + void clear() { + reported = false; + warningLevel = 0; + planes = const []; + avoidanceMode = null; + avoidanceCountdown = null; + notifyListeners(); + } +} diff --git a/Fly App/lib/fly/state/fly_zone_state.dart b/Fly App/lib/fly/state/fly_zone_state.dart new file mode 100644 index 0000000..8214058 --- /dev/null +++ b/Fly App/lib/fly/state/fly_zone_state.dart @@ -0,0 +1,450 @@ +import 'package:flutter/foundation.dart'; + +import '../hud_geometry.dart'; +import 'parse.dart'; + +/// How a fly zone (or the aircraft's standing towards one) should look. +enum ZoneLevel { none, info, caution, danger } + +/// One part of a GEO zone: a circle, or a polygon with its own height limit. +@immutable +class SubFlyZone { + const SubFlyZone({required this.shape, this.latitude, this.longitude, this.radius, this.maxHeight, this.vertices = const <(double, double)>[]}); + + factory SubFlyZone.fromMap(Map m) => SubFlyZone( + shape: asString(m['shape']) ?? 'UNKNOWN', + latitude: finite(m['latitude']), + longitude: finite(m['longitude']), + radius: finite(m['radius']), + maxHeight: asInt(m['maxHeight']), + vertices: <(double, double)>[ + if (m['vertices'] is List) + for (final Object? v in m['vertices'] as List) + if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!), + ], + ); + + /// `CYLINDER` or `POLYGON`. + final String shape; + final double? latitude; + final double? longitude; + final double? radius; // m + /// Height limit inside this part, m (0 = no flight). + final int? maxHeight; + final List<(double, double)> vertices; + + bool get isPolygon => shape == 'POLYGON' && vertices.length >= 3; + + /// Where the height label goes: the circle's centre, or the polygon's vertex average. + (double, double)? get labelPoint { + if (isPolygon) { + double la = 0, lo = 0; + for (final (double, double) v in vertices) { + la += v.$1; + lo += v.$2; + } + return (la / vertices.length, lo / vertices.length); + } + return latitude == null || longitude == null ? null : (latitude!, longitude!); + } + + /// `NO FLY` at 0 m, `120 m` for a ceiling, null when the part sets none. + String? get heightLabel { + final int? h = maxHeight; + if (h == null || h < 0) return null; + return h == 0 ? 'NO FLY' : '$h m'; + } +} + +/// One DJI GEO zone (`FlyZoneInformation`). +@immutable +class FlyZone { + const FlyZone({ + required this.id, + this.name = '', + this.category = 'UNKNOWN', + this.reason, + this.latitude, + this.longitude, + this.radius, + this.startTime, + this.endTime, + this.unlockStartTime, + this.unlockEndTime, + this.subZones = const [], + }); + + factory FlyZone.fromMap(Map m) => FlyZone( + id: asInt(m['id']) ?? 0, + name: (asString(m['name']) ?? '').trim(), + category: asString(m['category']) ?? 'UNKNOWN', + reason: asString(m['reason']), + latitude: finite(m['latitude']), + longitude: finite(m['longitude']), + radius: finite(m['radius']), + startTime: _blank(asString(m['startTime'])), + endTime: _blank(asString(m['endTime'])), + unlockStartTime: _blank(asString(m['unlockStartTime'])), + unlockEndTime: _blank(asString(m['unlockEndTime'])), + subZones: [for (final Map s in asMapList(m['subZones'])) SubFlyZone.fromMap(s)], + ); + + static String? _blank(String? s) => s == null || s.trim().isEmpty ? null : s.trim(); + + final int id; + final String name; + + /// `WARNING`, `ENHANCED_WARNING`, `AUTHORIZATION`, `RESTRICTED`, `UNKNOWN`. + final String category; + + /// `FlyZoneReason` name: `AIRPORT`, `MILITARY`, `PRISON`, … + final String? reason; + final double? latitude; + final double? longitude; + final double? radius; // m + final String? startTime; + final String? endTime; + final String? unlockStartTime; + final String? unlockEndTime; + final List subZones; + + bool get unlockable => category == 'AUTHORIZATION'; + + ZoneLevel get level => switch (category) { + 'RESTRICTED' => ZoneLevel.danger, + 'AUTHORIZATION' || 'ENHANCED_WARNING' => ZoneLevel.caution, + 'WARNING' => ZoneLevel.info, + _ => ZoneLevel.none, + }; + + String get categoryLabel => switch (category) { + 'RESTRICTED' => 'Restricted zone', + 'AUTHORIZATION' => 'Authorization zone', + 'ENHANCED_WARNING' => 'Enhanced warning zone', + 'WARNING' => 'Warning zone', + _ => 'Fly zone', + }; + + /// `COMMERCIAL_AIRPORTS` → `Commercial airports`. + String? get reasonLabel { + final String? r = reason; + if (r == null || r == 'UNKNOWN') return null; + final String s = r.replaceAll('_', ' ').toLowerCase(); + return s[0].toUpperCase() + s.substring(1); + } + + /// Metres from ([lat], [lon]) to the zone's centre, minus its radius (0 inside). + double? distanceFrom(double lat, double lon) { + if (latitude == null || longitude == null) return null; + final double d = HudGeometry.distance(lat, lon, latitude!, longitude!) - (radius ?? 0); + return d < 0 ? 0 : d; + } +} + +/// One FlySafe notification: transient advice from DJI's GEO system. +@immutable +class FlySafeNotice { + const FlySafeNotice({required this.kind, this.event, this.description, this.heightLimit, this.countdown, this.zoneIds = const [], required this.at}); + + factory FlySafeNotice.fromMap(Map m, DateTime at) => FlySafeNotice( + kind: asString(m['kind']) ?? 'UNKNOWN', + event: asString(m['event']), + description: (asString(m['description']) ?? '').trim(), + heightLimit: asInt(m['heightLimit']), + countdown: asInt(m['countdown']), + zoneIds: [if (m['zones'] is List) for (final Object? z in m['zones'] as List) if (z is int) z], + at: at, + ); + + /// `FLYING_SAFETY_TIP`, `WARNING`, `SERIOUS_WARNING`, `RETURN_TO_HOME`. + final String kind; + final String? event; + final String? description; + final int? heightLimit; + final int? countdown; + final List zoneIds; + final DateTime at; + + ZoneLevel get level => switch (kind) { + 'SERIOUS_WARNING' => ZoneLevel.danger, + 'WARNING' || 'RETURN_TO_HOME' => ZoneLevel.caution, + _ => ZoneLevel.info, + }; + + /// A short title when the SDK's description is empty. + String get title => switch (event) { + 'IN_NO_FLY_ZONE' => 'In a no-fly zone: the aircraft is landing', + 'IN_LIMIT_HEIGHT_AREA' => 'In a height-limited zone', + 'IN_AUTHORIZATION_ZONE_WITHOUT_UNLOCKING' => 'In an authorization zone that is not unlocked', + 'TAKE_OFF_FAILED_IN_NO_FLY_ZONE' || 'TAKE_OFF_FAILED_IN_NO_FLY_ZONE_WITHOUT_GPS' => 'Take-off refused: no-fly zone', + 'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE_WITHOUT_UNLOCKING' || + 'TAKE_OFF_FAILED_IN_AUTHORIZED_AREA_WITHOUT_GPS_AND_UNLOCKING' => + 'Take-off refused: unlock the authorization zone first', + 'NEAR_NO_FLY_ZONE' => 'Return to Home passes near a no-fly zone', + 'CROSS_NO_FLY_ZONE' => 'Return to Home would cross a no-fly zone', + 'TOUCH_NO_FLY_ZONE' => 'At the edge of a no-fly zone', + 'TOUCH_AUTHORIZATION_ZONE' => 'At the edge of an authorization zone', + 'REACH_MAXIMUM_FLIGHT_HEIGHT' => 'At the zone\'s height limit', + 'TAKE_OFF_NEAR_NO_FLY_ZONE' => 'Near a no-fly zone', + 'AUTHORIZED_ZONE_WITHOUT_UNLOCKING_NEARBY' || 'AUTHORIZED_ZONE_AND_RESTRICTIONS_NEARBY' => 'Authorization zone nearby', + 'IN_AUTHORIZATION_ZONE' || 'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE' => 'In an authorization zone', + 'LIMITED_RESTRICTIONS_NEARBY' || 'RESTRICTIONS_IN_LIMITED_FLY_ZONE_NEARBY' => 'Height-limited zone nearby', + 'TAKE_OFF_IN_ENHANCED_WARNING_ZONE' => 'In an enhanced warning zone', + 'TAKE_OFF_IN_WARNING_AREA' => 'In a warning zone', + 'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE' || 'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE_WITHOUT_GPS' => 'Taking off in a height-limited zone', + 'RTH_AFFECTED_AT_BOUNDARY_OF_GEO_ZONE' => 'Return to Home is limited by a fly zone', + _ => kind == 'SERIOUS_WARNING' ? 'Fly zone warning' : 'Fly zone notice', + }; +} + +/// DJI GEO fly zones (plan P8): the aircraft's standing (`flyZone.state`), the +/// latest FlySafe notification, the zones around, and which are unlocked. +class FlyZoneState extends ChangeNotifier { + /// `FlyZoneState` name, null until reported. + String? state; + + FlySafeNotice? notice; + + List zones = const []; + + /// Where [zones] were fetched around, and when. + (double, double)? zonesAround; + DateTime? zonesAt; + + Set unlocked = const {}; + + /// A notice stays on screen this long (a serious one with a countdown, until replaced). + static const Duration noticeLife = Duration(seconds: 15); + + ZoneLevel get stateLevel => switch (state) { + 'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' || 'PHONE_IN_RESTRICTED_ZONE' => ZoneLevel.danger, + 'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' || 'IN_ENHANCE_WARNING_ZONE' || 'NEAR_RESTRICTED_ZONE' => ZoneLevel.caution, + 'IN_WARNING_ZONE' || + 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' || + 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' || + 'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' || + 'NEAR_HEIGHT_LIMITED_ZONE' || + 'NEAR_AUTHORIZED_ZONE' || + 'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' || + 'NEAR_MULTIPLE_TYPE_FLY_ZONE' || + 'IN_AUTHORIZED_ZONE_WITH_LICENSE' => + ZoneLevel.info, + _ => ZoneLevel.none, + }; + + String get stateLabel => switch (state) { + 'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' => 'In a restricted zone', + 'PHONE_IN_RESTRICTED_ZONE' => 'You are in a restricted zone', + 'NEAR_RESTRICTED_ZONE' => 'Near a restricted zone', + 'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' => 'In an authorization zone', + 'IN_AUTHORIZED_ZONE_WITH_LICENSE' => 'In an unlocked authorization zone', + 'NEAR_AUTHORIZED_ZONE' || 'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' => 'Near an authorization zone', + 'IN_ENHANCE_WARNING_ZONE' => 'In an enhanced warning zone', + 'IN_WARNING_ZONE' => 'In a warning zone', + 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' || + 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' || + 'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' => + 'In a height-limited zone', + 'NEAR_HEIGHT_LIMITED_ZONE' => 'Near a height-limited zone', + 'NEAR_MULTIPLE_TYPE_FLY_ZONE' => 'Near several fly zones', + _ => 'No fly zone around', + }; + + /// The notice to show at [now]: fresh, or a serious one still counting down. + FlySafeNotice? noticeAt(DateTime now) { + final FlySafeNotice? n = notice; + if (n == null) return null; + final Duration age = now.difference(n.at); + if (age <= noticeLife) return n; + if (n.kind == 'SERIOUS_WARNING' && (n.countdown ?? 0) > age.inSeconds) return n; + return null; + } + + /// The zone(s) a notice names, as loaded. + List zonesFor(FlySafeNotice n) => [for (final FlyZone z in zones) if (n.zoneIds.contains(z.id)) z]; + + /// Zones nearest first from ([lat], [lon]). + List nearest(double? lat, double? lon) { + if (lat == null || lon == null) return zones; + return [...zones]..sort((FlyZone a, FlyZone b) => + (a.distanceFrom(lat, lon) ?? double.infinity).compareTo(b.distanceFrom(lat, lon) ?? double.infinity)); + } + + void applyState(Map? m) { + state = asString(m?['state']); + notifyListeners(); + } + + void applyNotice(Map m, {DateTime? now}) { + notice = FlySafeNotice.fromMap(m, now ?? DateTime.now()); + notifyListeners(); + } + + void applyZones(List> list, {required double latitude, required double longitude, DateTime? now}) { + zones = [for (final Map m in list) FlyZone.fromMap(m)]; + zonesAround = (latitude, longitude); + zonesAt = now ?? DateTime.now(); + notifyListeners(); + } + + void applyUnlocked(Iterable ids) { + unlocked = ids.toSet(); + notifyListeners(); + } + + /// Whether a fresh fetch is due for a fix at ([lat], [lon]). + bool needsZonesFor(double lat, double lon, {DateTime? now, double moveM = 2000, Duration maxAge = const Duration(minutes: 10)}) { + final (double, double)? at = zonesAround; + final DateTime? when = zonesAt; + if (at == null || when == null) return true; + if ((now ?? DateTime.now()).difference(when) > maxAge) return true; + return HudGeometry.distance(at.$1, at.$2, lat, lon) > moveM; + } + + // ── Licences and the GEO database ───────────────────────────────────────── + + /// Null until listed; false on aircraft without custom unlock zones. + bool? licencesSupported; + List licences = const []; + + FlyZoneDatabase? database; + + /// Basic database download, 0..1, while an upgrade runs; null otherwise. + double? upgradeProgress; + + FlyZoneLicence? get enabledLicence => licences.where((FlyZoneLicence l) => l.enabled).firstOrNull; + + void applyLicences(Map m) { + licencesSupported = asBool(m['supported']) ?? false; + licences = [for (final Map z in asMapList(m['zones'])) FlyZoneLicence.fromMap(z)] + ..sort((FlyZoneLicence a, FlyZoneLicence b) => a.index.compareTo(b.index)); + notifyListeners(); + } + + void applyDatabase(Map m) { + database = FlyZoneDatabase.fromMap(m); + notifyListeners(); + } + + /// The SDK's progress float; read as a percentage when above 1. A complete + /// download ends the upgrade (MSDK does not say whether the start call's + /// completion waits for it). + void applyUpgradeProgress(double? p) { + final double? v = p == null ? null : (p > 1 ? p / 100 : p).clamp(0.0, 1.0); + upgradeProgress = v != null && v >= 1 ? null : v; + notifyListeners(); + } + + /// Aircraft-bound parts only: the zones database is app-level and stays. + void clearAircraft() { + state = null; + notice = null; + unlocked = const {}; + licencesSupported = null; + licences = const []; + notifyListeners(); + } +} + +/// One licence on the aircraft (`CustomUnlockZone`): a DJI-issued unlock for a +/// circle, polygon, height or whole country, valid between two dates. One at a +/// time is enabled. +@immutable +class FlyZoneLicence { + const FlyZoneLicence({ + required this.id, + this.index = 0, + this.name = '', + this.type = 'UNKNOWN', + this.latitude, + this.longitude, + this.radius, + this.height, + this.country, + this.points = const <(double, double)>[], + this.startTime, + this.endTime, + this.expired = false, + this.enabled = false, + }); + + factory FlyZoneLicence.fromMap(Map m) => FlyZoneLicence( + id: asInt(m['id']) ?? 0, + index: asInt(m['index']) ?? 0, + name: (asString(m['name']) ?? '').trim(), + type: asString(m['type']) ?? 'UNKNOWN', + latitude: finite(m['latitude']), + longitude: finite(m['longitude']), + radius: finite(m['radius']), + height: asInt(m['height']), + country: asString(m['country']), + points: <(double, double)>[ + if (m['points'] is List) + for (final Object? v in m['points'] as List) + if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!), + ], + startTime: FlyZone._blank(asString(m['startTime'])), + endTime: FlyZone._blank(asString(m['endTime'])), + expired: asBool(m['expired']) ?? false, + enabled: asBool(m['enabled']) ?? false, + ); + + final int id; + final int index; + final String name; + + /// `UnlockZoneType`: `GEO_UNLOCK`, `CIRCLE_UNLOCK_AREA`, `COUNTRY_UNLOCK`, `HEIGHT_UNLOCK`, `PENTAGON_UNLOCK`. + final String type; + final double? latitude; + final double? longitude; + final double? radius; // m + final int? height; // m, for height unlocks + final String? country; + final List<(double, double)> points; + final String? startTime; + final String? endTime; + final bool expired; + final bool enabled; + + String get typeLabel => switch (type) { + 'GEO_UNLOCK' => 'GEO zone unlock', + 'CIRCLE_UNLOCK_AREA' => 'Circle unlock', + 'PENTAGON_UNLOCK' => 'Area unlock', + 'COUNTRY_UNLOCK' => 'Country unlock', + 'HEIGHT_UNLOCK' => 'Height unlock', + _ => 'Licence', + }; + + /// Drawable on the map: a circle with a centre and radius, or a polygon. + bool get hasArea => points.length >= 3 || (latitude != null && longitude != null && (radius ?? 0) > 0); + + String get validity => [if (startTime != null) startTime!, if (endTime != null) endTime!].join(' → '); +} + +/// The GEO database's versions and state (`FlyZoneDatabaseState` names). +@immutable +class FlyZoneDatabase { + const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState}); + + factory FlyZoneDatabase.fromMap(Map m) => FlyZoneDatabase( + basicVersion: asString(m['basicVersion']), + basicState: asString(m['basicState']), + preciseVersion: asString(m['preciseVersion']), + preciseState: asString(m['preciseState']), + ); + + final String? basicVersion; + final String? basicState; // INITIALIZING / NO_INTERNET_CONNECTION / OUT_OF_DATE / UP_TO_DATE / UNKNOWN + final String? preciseVersion; + final String? preciseState; + + bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE'; + + static String stateLabel(String? s) => switch (s) { + 'UP_TO_DATE' => 'Up to date', + 'OUT_OF_DATE' => 'Out of date', + 'NO_INTERNET_CONNECTION' => 'No internet connection', + 'INITIALIZING' => 'Initializing', + _ => 'Unknown', + }; +} diff --git a/Fly App/lib/fly/state/vision_state.dart b/Fly App/lib/fly/state/vision_state.dart index 45c1eba..31ec85b 100644 --- a/Fly App/lib/fly/state/vision_state.dart +++ b/Fly App/lib/fly/state/vision_state.dart @@ -56,13 +56,70 @@ class VisionReading { bool get dangerous => systemWarning == 'DANGEROUS'; } +/// Omnidirectional perception (Mavic 2-class): one distance per [angleInterval] +/// degrees clockwise from the nose, plus straight up and down. Null distances +/// saw nothing in range. +@immutable +class Perception { + const Perception({required this.angleInterval, this.distances = const [], this.up, this.down}); + + factory Perception.fromMap(Map m) => Perception( + angleInterval: finite(m['angleInterval']) ?? 0, + distances: [if (m['distances'] is List) for (final Object? d in m['distances'] as List) finite(d)], + up: finite(m['up']), + down: finite(m['down']), + ); + + final double angleInterval; // deg + final List distances; // m + final double? up; + final double? down; +} + +/// One obstacle-sensing direction's health (`ObstacleAvoidanceSensorState`). +@immutable +class SensorHealth { + const SensorHealth({required this.working, required this.enabled}); + + final bool working; + final bool enabled; + + /// Switched on but not seeing: covered, dirty or too dark. + bool get blocked => enabled && !working; +} + /// Obstacle sensing per sensor position. Fed from the `vision` group of the /// telemetry tick; empty on aircraft without obstacle sensing. class VisionState extends ChangeNotifier { Map sensors = const {}; + /// Omni perception, on the aircraft that report it (`perception` group). + Perception? perception; + + /// Per-direction sensor health, keyed `forward` / `backward` / `left` / + /// `right` / `up` / `down` (`obstacleSensors` group). + Map health = const {}; + DateTime? updatedAt; + void applyPerception(Map? m) { + perception = m == null ? null : Perception.fromMap(m); + notifyListeners(); + } + + void applyHealth(Map? m) { + health = { + if (m != null) + for (final MapEntry e in m.entries) + if (e.value is Map) + e.key: SensorHealth( + working: asBool((e.value as Map)['working']) ?? true, + enabled: asBool((e.value as Map)['enabled']) ?? true, + ), + }; + notifyListeners(); + } + VisionReading? get nose => sensors['NOSE']; VisionReading? get tail => sensors['TAIL']; @@ -108,6 +165,8 @@ class VisionState extends ChangeNotifier { void clear() { sensors = const {}; + perception = null; + health = const {}; updatedAt = null; palmControl = null; notifyListeners(); diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index 726c89b..c201e74 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -10,7 +10,8 @@ import 'state/parse.dart'; /// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight /// controller's flat keys plus one nested group per subsystem (`battery`, /// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`, -/// `gimbalSettings`, `vision`, `calibration`). It arrives at +/// `gimbalSettings`, `vision`, `calibration`, `perception`, `obstacleSensors`, +/// `airSense`, `airSenseAvoidance`, `flyZone`). It arrives at /// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz /// refreshes, so each group is compared with the last one seen and its /// sub-model only notified when it actually moved. A group missing from the @@ -22,7 +23,7 @@ class TelemetryReducer { final Map _lastGroups = {}; - static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl']; + static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl', 'perception', 'obstacleSensors', 'airSense', 'airSenseAvoidance', 'flyZone']; /// Applies [event] if it is one of the telemetry types; returns whether it was. bool apply(Map event) { @@ -50,6 +51,13 @@ class TelemetryReducer { case 'mission': model.mission.apply(event); return true; + case 'flySafe': + // The GEO system is app-level: its notices count with or without an aircraft. + model.flyZones.applyNotice(event); + return true; + case 'flyZoneDbProgress': + model.flyZones.applyUpgradeProgress(finite(event['progress'])); + return true; case 'videoSize': final int? w = asInt(event['width']), h = asInt(event['height']); if (w != null && h != null && w > 0 && h > 0) model.videoSize.value = Size(w.toDouble(), h.toDouble()); @@ -97,5 +105,10 @@ class TelemetryReducer { if (changed.contains('vision')) model.vision.apply(asMap(t['vision'])); if (changed.contains('calibration')) model.calibration.apply(asMap(t['calibration'])); if (changed.contains('palmControl')) model.vision.applyPalm(asString(t['palmControl'])); + if (changed.contains('perception')) model.vision.applyPerception(asMap(t['perception'])); + if (changed.contains('obstacleSensors')) model.vision.applyHealth(asMap(t['obstacleSensors'])); + if (changed.contains('airSense')) model.airSense.apply(asMap(t['airSense'])); + if (changed.contains('airSenseAvoidance')) model.airSense.applyAvoidance(asMap(t['airSenseAvoidance'])); + if (changed.contains('flyZone')) model.flyZones.applyState(asMap(t['flyZone'])); } } diff --git a/Fly App/lib/fly/virtual_sticks.dart b/Fly App/lib/fly/virtual_sticks.dart new file mode 100644 index 0000000..2fd3cb1 --- /dev/null +++ b/Fly App/lib/fly/virtual_sticks.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:ui' show Offset; + +import 'package:flutter/foundation.dart'; + +/// Which stick does what, as on the remote (RC `AircraftMappingStyle`). +enum StickMode { + /// Left: forward/back + yaw; right: throttle + sideways. + mode1('Mode 1'), + + /// Left: throttle + yaw; right: forward/back + sideways. DJI's default. + mode2('Mode 2'), + + /// Left: forward/back + sideways; right: throttle + yaw. + mode3('Mode 3'); + + const StickMode(this.label); + final String label; +} + +/// What a full stick deflection means. +@immutable +class StickLimits { + const StickLimits({this.horizontal = 5, this.vertical = 2, this.yaw = 60}); + + /// GO 4's Wi-Fi joystick default is gentle; these are a step below P mode. + static const StickLimits normal = StickLimits(); + static const StickLimits gentle = StickLimits(horizontal: 2, vertical: 1, yaw: 30); + + final double horizontal; // m/s + final double vertical; // m/s + final double yaw; // °/s +} + +/// One packet for the native stick loop: body-frame velocities and a yaw rate. +@immutable +class StickCommand { + const StickCommand({this.forward = 0, this.right = 0, this.yaw = 0, this.up = 0}); + + static const StickCommand zero = StickCommand(); + + final double forward; // m/s + final double right; // m/s + final double yaw; // °/s, clockwise + final double up; // m/s + + bool get isZero => forward == 0 && right == 0 && yaw == 0 && up == 0; + + @override + bool operator ==(Object other) => + other is StickCommand && other.forward == forward && other.right == right && other.yaw == yaw && other.up == up; + + @override + int get hashCode => Object.hash(forward, right, yaw, up); + + @override + String toString() => 'StickCommand(f ${forward.toStringAsFixed(2)}, r ${right.toStringAsFixed(2)}, ' + 'yaw ${yaw.toStringAsFixed(1)}, up ${up.toStringAsFixed(2)})'; +} + +/// Stick positions → [StickCommand] (plan P8). Pure, so the mapping is unit-tested. +/// +/// Positions are −1…1 with x to the right and **y up** (push = +1). +abstract final class StickMapper { + /// Below this deflection the stick counts as centred (thumbs never rest at 0). + static const double deadzone = 0.08; + + /// Share of the cubic term: small deflections are finer than a linear map. + static const double expo = 0.4; + + /// The curve one axis goes through: deadzone, rescale, expo. + static double shape(double v) { + final double a = v.abs().clamp(0.0, 1.0); + if (a <= deadzone) return 0; + final double x = (a - deadzone) / (1 - deadzone); + return (expo * x * x * x + (1 - expo) * x) * v.sign; + } + + static StickCommand command(Offset left, Offset right, {StickMode mode = StickMode.mode2, StickLimits limits = StickLimits.normal}) { + final (double throttle, double yaw, double pitch, double roll) = switch (mode) { + StickMode.mode1 => (right.dy, left.dx, left.dy, right.dx), + StickMode.mode2 => (left.dy, left.dx, right.dy, right.dx), + StickMode.mode3 => (right.dy, right.dx, left.dy, left.dx), + }; + double r(double v) => v == 0 ? 0 : v; // no −0.0 in packets or tests + return StickCommand( + forward: r(shape(pitch) * limits.horizontal), + right: r(shape(roll) * limits.horizontal), + yaw: r(shape(yaw) * limits.yaw), + up: r(shape(throttle) * limits.vertical), + ); + } + + /// Clamps a finger's offset from the pad centre into the unit circle, y up. + static Offset normalise(Offset fromCentre, double radius) { + if (radius <= 0) return Offset.zero; + Offset v = Offset(fromCentre.dx / radius, -fromCentre.dy / radius); + final double len = v.distance; + if (len > 1) v = v / len; + return v; + } +} + +/// The on-screen sticks' state while they fly the aircraft. +/// +/// [setLeft] / [setRight] send at once when the command changes, and a +/// [heartbeat] timer repeats it while a stick is off centre — the native loop +/// hovers once a command is 300 ms old, so a held stick must keep saying so. +/// Centred sticks send one zero and go quiet. +/// +/// [open] is what the pilot asked for; the aircraft's own answer +/// (`virtualStickEnabled` in the telemetry) closes it through [onAircraft] when +/// the remote's mode switch or RTH takes over. +class VirtualStickController extends ChangeNotifier { + VirtualStickController({required this.send, this.heartbeat = const Duration(milliseconds: 100)}); + + /// Delivers one command to the bridge. + final Future Function(StickCommand c) send; + final Duration heartbeat; + + StickMode mode = StickMode.mode2; + StickLimits limits = StickLimits.normal; + + Offset left = Offset.zero; + Offset right = Offset.zero; + + /// The sticks are on and flying the aircraft. + bool open = false; + + /// Why the sticks last closed without the pilot asking, for one snackbar. + String? closedReason; + + /// The aircraft confirmed virtual-stick mode since [open] turned on. + bool _confirmed = false; + + StickCommand _last = StickCommand.zero; + Timer? _beat; + + StickCommand get command => StickMapper.command(left, right, mode: mode, limits: limits); + + void setLeft(Offset v) { + left = v; + _changed(); + } + + void setRight(Offset v) { + right = v; + _changed(); + } + + /// Both sticks back to centre (a finger lifted off a pad does this too). + void centre() { + left = right = Offset.zero; + _changed(); + } + + void _changed() { + notifyListeners(); + if (!open) return; + final StickCommand c = command; + if (c != _last) _emit(c); + if (c.isZero) { + _beat?.cancel(); + _beat = null; + } else { + _beat ??= Timer.periodic(heartbeat, (_) => _emit(command)); + } + } + + void _emit(StickCommand c) { + _last = c; + unawaited(send(c).catchError((Object _) {})); + } + + /// The aircraft entered virtual-stick mode. + void opened() { + open = true; + _confirmed = false; + closedReason = null; + left = right = Offset.zero; + _last = StickCommand.zero; + notifyListeners(); + } + + /// Stops sending; [reason] is shown when the aircraft, not the pilot, ended it. + void close([String? reason]) { + _beat?.cancel(); + _beat = null; + final bool wasOpen = open; + open = false; + left = right = Offset.zero; + _last = StickCommand.zero; + if (wasOpen) closedReason = reason; + notifyListeners(); + } + + /// Follows the flight controller: once it has confirmed the mode, a report + /// that it left (remote mode switch), or an RTH / landing, closes the sticks. + void onAircraft({required bool? enabled, required bool goingHome, required bool landing}) { + if (!open) return; + if (enabled == true) _confirmed = true; + if (_confirmed && enabled == false) { + close('The remote took back control'); + } else if (goingHome) { + close('Return to Home took over'); + } else if (landing) { + close('The aircraft is landing'); + } + } + + @override + void dispose() { + _beat?.cancel(); + super.dispose(); + } +} + diff --git a/Fly App/lib/fly/vision_radar.dart b/Fly App/lib/fly/vision_radar.dart new file mode 100644 index 0000000..f37e38f --- /dev/null +++ b/Fly App/lib/fly/vision_radar.dart @@ -0,0 +1,175 @@ +import 'package:flutter/foundation.dart'; + +import 'state/vision_state.dart'; + +/// The four horizontal radar sides, clockwise from the nose. +enum RadarSide { + front('Front', 'NOSE', 'forward', 0, 60), + right('Right', 'RIGHT', 'right', 90, 40), + back('Rear', 'TAIL', 'backward', 180, 60), + left('Left', 'LEFT', 'left', 270, 40); + + const RadarSide(this.label, this.position, this.healthKey, this.bearing, this.span); + + final String label; + + /// `VisionDetectionState` position name. + final String position; + + /// `obstacleSensors` key. + final String healthKey; + + /// Centre of the side, degrees clockwise from the nose. + final double bearing; + + /// Arc the side covers on the radar, degrees. + final double span; +} + +/// How alarming a sector is, for its colour. +enum RadarBand { none, notice, caution, danger } + +@immutable +class RadarSector { + const RadarSector({required this.start, required this.sweep, this.distance, this.level = 0}); + + /// Degrees clockwise from the nose. + final double start; + final double sweep; + final double? distance; // m + /// 0 (nothing / invalid) … 6 (very close), as MSDK's `ObstacleDetectionSectorWarning`. + final int level; + + RadarBand get band => VisionRadar.bandFor(level); +} + +@immutable +class RadarSideView { + const RadarSideView({required this.side, this.sectors = const [], this.closest, this.blocked = false, this.off = false}); + + final RadarSide side; + final List sectors; + final double? closest; // m + /// Switched on but not seeing (covered, dirty, too dark). + final bool blocked; + + /// Obstacle sensing for this side is switched off. + final bool off; + + int get level => sectors.fold(0, (int w, RadarSector s) => s.level > w ? s.level : w); + RadarBand get band => VisionRadar.bandFor(level); +} + +/// What the vision radar overlay (plan P8, GO 4's `vision_radar_view`) draws: +/// per side, sectors graded by distance, the nearest distance, and whether the +/// side's sensors are blocked; plus the obstacle overhead. +/// +/// Pure: built from [VisionState] so the grading is unit-tested without a +/// painter. Each side prefers the aircraft's own `VisionDetectionState` +/// sectors (and their warning levels); a side that has none is filled from +/// omni perception's distance ring, graded by [levelFor]. +@immutable +class VisionRadar { + const VisionRadar({this.sides = const {}, this.up}); + + /// Obstacles further than this are not drawn. + static const double range = 16; + + /// Above this the overhead distance is not shown. + static const double upRange = 10; + + /// Sectors a perception-filled side is split into. + static const int perceptionSectors = 4; + + final Map sides; + + /// Overhead obstacle, m, when within [upRange]. + final double? up; + + bool get isEmpty => sides.values.every((RadarSideView s) => s.sectors.every((RadarSector x) => x.level == 0) && !s.blocked) && up == null; + + int get level => sides.values.fold(0, (int w, RadarSideView s) => s.level > w ? s.level : w); + + List get blocked => [for (final RadarSideView s in sides.values) if (s.blocked) s.side]; + + static RadarBand bandFor(int level) => level >= 5 + ? RadarBand.danger + : level >= 3 + ? RadarBand.caution + : level >= 1 + ? RadarBand.notice + : RadarBand.none; + + /// A warning level for a bare distance (perception reports no level). + static int levelFor(double? d) { + if (d == null || d > range) return 0; + if (d <= 1.5) return 6; + if (d <= 2.5) return 5; + if (d <= 4) return 4; + if (d <= 6) return 3; + if (d <= 10) return 2; + return 1; + } + + static int _sdkLevel(VisionSector s) => s.distance != null && s.distance! > range ? 0 : s.level; + + static VisionRadar of(VisionState v) { + final Map sides = {}; + final Perception? p = v.perception; + for (final RadarSide side in RadarSide.values) { + final VisionReading? r = v.sensors[side.position]; + final SensorHealth? h = v.health[side.healthKey]; + List sectors = const []; + if (r != null && r.sectors.isNotEmpty) { + // Sectors run left to right as the sensor sees them: clockwise, whichever way it faces. + final double sweep = side.span / r.sectors.length; + final double start = side.bearing - side.span / 2; + sectors = [ + for (int i = 0; i < r.sectors.length; i++) + RadarSector(start: start + i * sweep, sweep: sweep, distance: r.sectors[i].distance, level: _sdkLevel(r.sectors[i])), + ]; + } else if (p != null && p.angleInterval > 0 && p.distances.isNotEmpty) { + sectors = _fromPerception(side, p); + } + final bool off = r?.isDisabled == true || (h != null && !h.enabled); + final bool blocked = !off && (h?.blocked ?? false); + if (sectors.isEmpty && !blocked && !off && r == null) continue; + double? closest; + for (final RadarSector s in sectors) { + final double? d = s.distance; + if (s.level > 0 && d != null && (closest == null || d < closest)) closest = d; + } + sides[side] = RadarSideView(side: side, sectors: sectors, closest: closest, blocked: blocked, off: off); + } + final double? up = p?.up; + return VisionRadar(sides: sides, up: up != null && up <= upRange ? up : null); + } + + /// Splits [side]'s arc into [perceptionSectors] and takes the nearest ring + /// sample in each. + static List _fromPerception(RadarSide side, Perception p) { + final int n = p.distances.length; + final double sweep = side.span / perceptionSectors; + final double start = side.bearing - side.span / 2; + return [ + for (int i = 0; i < perceptionSectors; i++) + () { + double? nearest; + final double a0 = start + i * sweep, a1 = a0 + sweep; + for (int k = 0; k < n; k++) { + final double? d = p.distances[k]; + if (d == null || !_within(k * p.angleInterval, a0, a1)) continue; + if (nearest == null || d < nearest) nearest = d; + } + return RadarSector(start: a0, sweep: sweep, distance: nearest, level: levelFor(nearest)); + }(), + ]; + } + + /// Whether bearing [a] lies in [a0, a1), all in degrees, wrapping at 360. + static bool _within(double a, double a0, double a1) { + double norm(double x) => ((x % 360) + 360) % 360; + final double x = norm(a - a0); + return x < a1 - a0; + } +} diff --git a/Fly App/lib/ui/fly/bottom/attitude_ball.dart b/Fly App/lib/ui/fly/bottom/attitude_ball.dart index d33830c..1fef557 100644 --- a/Fly App/lib/ui/fly/bottom/attitude_ball.dart +++ b/Fly App/lib/ui/fly/bottom/attitude_ball.dart @@ -1,10 +1,12 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import '../../../flight_model.dart'; import '../../../fly/hud_geometry.dart'; import '../../../fly/state/aircraft_state.dart'; +import '../../../fly/vision_radar.dart'; import '../../../theme.dart'; import '../hud_kit.dart'; @@ -12,6 +14,10 @@ import '../hud_kit.dart'; /// roll, with a north-up radar on top — the aircraft arrow in the middle /// pointing along its heading, the home point and the pilot (phone GPS) /// plotted by bearing and distance, and a tick on the rim for the gimbal's yaw. +/// +/// Plan P8 adds GO 4's `DJIVisualObstacleRadarView` ring: the vision radar's +/// graded sectors drawn on the rim, turned with the heading (the ball is +/// north-up, the sensors are not). class AttitudeBall extends StatelessWidget { const AttitudeBall({super.key, required this.model, this.size = 96}); @@ -21,13 +27,14 @@ class AttitudeBall extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([model.aircraft, model.gimbal]), + listenable: Listenable.merge([model.aircraft, model.gimbal, model.vision]), builder: (BuildContext context, _) { final AttitudeBallData d = AttitudeBallData.from( model.aircraft, gimbalYaw: model.gimbal.yaw, phoneLat: model.phoneLatitude, phoneLon: model.phoneLongitude, + radar: VisionRadar.of(model.vision), ); return SizedBox( width: size, @@ -43,6 +50,9 @@ class AttitudeBall extends StatelessWidget { } } +/// One graded radar sector on the ball's rim. +typedef BallObstacle = ({double start, double sweep, RadarBand band}); + /// Everything the painter needs, resolved from the models so it can be compared /// for [CustomPainter.shouldRepaint] and built in tests. @immutable @@ -57,10 +67,11 @@ class AttitudeBallData { this.pilotBearing, this.pilotDistance, this.rangeM = 100, + this.obstacles = const [], }); /// Bearings and distances are taken from the aircraft to each point. - factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon}) { + factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon, VisionRadar? radar}) { final double? lat = a.latitude, lon = a.longitude; final bool fix = lat != null && lon != null && !(lat == 0 && lon == 0); double? hb, hd, pb, pd; @@ -82,9 +93,18 @@ class AttitudeBallData { pilotBearing: pb, pilotDistance: pd, rangeM: HudGeometry.ballRange(math.max(hd ?? 0, pd ?? 0)), + obstacles: [ + for (final RadarSideView side in radar?.sides.values ?? const []) + for (final RadarSector s in side.sectors) + if (s.band != RadarBand.none) (start: s.start, sweep: s.sweep, band: s.band), + ], ); } + /// Graded radar sectors, degrees clockwise from the *nose*; the painter + /// turns them by [heading]. + final List obstacles; + final double? pitch; final double? roll; final double? heading; @@ -108,11 +128,12 @@ class AttitudeBallData { other.homeDistance == homeDistance && other.pilotBearing == pilotBearing && other.pilotDistance == pilotDistance && - other.rangeM == rangeM; + other.rangeM == rangeM && + listEquals(other.obstacles, obstacles); @override int get hashCode => - Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM); + Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM, Object.hashAll(obstacles)); } class AttitudeBallPainter extends CustomPainter { @@ -154,6 +175,21 @@ class AttitudeBallPainter extends CustomPainter { ..color = const Color(0x33EAF0FA)); _label(canvas, 'N', c + Offset(0, -r + 7), Glass.ink, 8); + // ── Obstacle ring (plan P8): sensor sectors turned by the heading ────── + final double heading = d.heading ?? 0; + for (final BallObstacle o in d.obstacles) { + canvas.drawArc( + Rect.fromCircle(center: c, radius: r - 2.5), + (heading + o.start + 0.5 - 90) * math.pi / 180, + (o.sweep - 1) * math.pi / 180, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = o.band == RadarBand.danger ? 4 : 3 + ..color = RadarColors.of(o.band), + ); + } + // ── Gimbal yaw tick (on the rim) ─────────────────────────────────────── final double? gy = d.gimbalYaw; if (gy != null) { diff --git a/Fly App/lib/ui/fly/control/virtual_stick_pads.dart b/Fly App/lib/ui/fly/control/virtual_stick_pads.dart new file mode 100644 index 0000000..bdc92d5 --- /dev/null +++ b/Fly App/lib/ui/fly/control/virtual_stick_pads.dart @@ -0,0 +1,103 @@ +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; + +import '../../../fly/virtual_sticks.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// What each pad's axes do in [mode], for the captions under the pads. +({String left, String right}) stickCaptions(StickMode mode) => switch (mode) { + StickMode.mode1 => (left: 'FWD · YAW', right: 'UP · SIDE'), + StickMode.mode2 => (left: 'UP · YAW', right: 'FWD · SIDE'), + StickMode.mode3 => (left: 'FWD · SIDE', right: 'UP · YAW'), + }; + +/// One on-screen stick (plan P8): a ring the thumb drags a knob inside, +/// springing back to centre on release like a remote's stick. Reports −1…1 +/// with y up; the drag starts where the finger lands, not after the slop. +class StickPad extends StatefulWidget { + const StickPad({super.key, required this.value, required this.onChanged, required this.caption, this.size = 110}); + + final Offset value; + final ValueChanged onChanged; + final String caption; + final double size; + + @override + State createState() => _StickPadState(); +} + +class _StickPadState extends State { + double get _radius => widget.size / 2 - 14; + + void _to(Offset local) { + final Offset centre = Offset(widget.size / 2, widget.size / 2); + widget.onChanged(StickMapper.normalise(local - centre, _radius)); + } + + @override + Widget build(BuildContext context) { + final Offset v = widget.value; + final Offset knob = Offset(v.dx * _radius, -v.dy * _radius); + final bool active = v != Offset.zero; + return Column(mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + dragStartBehavior: DragStartBehavior.down, + onPanStart: (DragStartDetails d) => _to(d.localPosition), + onPanUpdate: (DragUpdateDetails d) => _to(d.localPosition), + onPanEnd: (_) => widget.onChanged(Offset.zero), + onPanCancel: () => widget.onChanged(Offset.zero), + child: SizedBox.square( + dimension: widget.size, + child: Stack(alignment: Alignment.center, children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Glass.pill, + border: Border.all(color: active ? Glass.accent : const Color(0x4DEAF0FA), width: 1.5), + ), + ), + // Cross-hair: the stick's two axes. + Container(width: 1, height: widget.size - 28, color: Glass.hairline), + Container(width: widget.size - 28, height: 1, color: Glass.hairline), + Transform.translate( + offset: knob, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? Glass.accent : const Color(0xCC253552), + border: Border.all(color: const Color(0x66FFFFFF)), + ), + ), + ), + ]), + ), + ), + const SizedBox(height: 2), + Text(widget.caption, style: HudText.label.copyWith(fontSize: 8)), + ]); + } +} + +/// The rail's STICKS button: lit while the on-screen sticks fly the aircraft. +class SticksButton extends StatelessWidget { + const SticksButton({super.key, required this.open, required this.onTap}); + + final bool open; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GlassPill( + key: const ValueKey('rail-sticks'), + height: 24, + color: open ? Glass.accent : Glass.pillStrong, + padding: const EdgeInsets.symmetric(horizontal: 6), + onTap: onTap, + child: Text('STICKS', style: HudText.label.copyWith(fontSize: 8, color: Glass.ink, fontWeight: FontWeight.w700)), + ); + } +} diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 0b25976..6b4d152 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart' show LatLng; import '../../dji_service.dart'; import '../../flight_model.dart'; import '../../fly/flight_trail.dart'; +import '../../fly/fly_zone_loader.dart'; import '../../fly/fly_prefs.dart'; import '../../fly/hud_features.dart'; import '../../fly/modes/mission_library.dart'; @@ -15,6 +16,7 @@ import '../../fly/modes/waypoint_mission.dart' show MissionLimits; import '../../fly/product_profile.dart'; import '../../fly/safety_gate.dart'; import '../../fly/state/mission_state.dart'; +import '../../fly/virtual_sticks.dart'; import '../../theme.dart'; import '../album_page.dart'; import '../capture_modes_page.dart'; @@ -22,6 +24,7 @@ import '../pv_icons.dart'; import 'bottom/attitude_ball.dart'; import 'bottom/minimap.dart'; import 'bottom/telemetry_strip.dart'; +import 'control/virtual_stick_pads.dart'; import 'flight_command_flow.dart'; import 'hud_kit.dart'; import 'layers/video_layer.dart'; @@ -35,11 +38,15 @@ import 'modes/waypoints/waypoint_layers.dart'; import 'modes/waypoints/waypoint_panels.dart'; import 'modes/waypoints/waypoint_sheets.dart'; import 'modes/waypoints/waypoint_start.dart'; +import 'overlays/airsense.dart'; import 'overlays/alert_stack.dart'; import 'overlays/camera_touch_layer.dart'; +import 'overlays/fly_zone_licences.dart'; +import 'overlays/fly_zones.dart'; import 'overlays/gimbal_drag_layer.dart'; import 'overlays/histogram_view.dart'; import 'overlays/safety_banners.dart'; +import 'overlays/vision_radar.dart'; import 'panels/aircraft_status_sheet.dart'; import 'panels/camera_menu/camera_menu_sheet.dart'; import 'panels/camera_quick_exposure.dart'; @@ -81,6 +88,12 @@ import 'top/status_bar.dart'; /// along the bottom, the tool column on the right — and the run panel at the /// head of the left column follows the upload and the flight, whatever the /// screen shows. The route stays on the map after leaving the mode. +/// +/// Radar, sticks, AirSense (plan P8): the vision radar around the picture's +/// centre; the rail's STICKS button hands the aircraft to two on-screen stick +/// pads through the SafetyGate (simulator first); AirSense warnings above the +/// error stack and the planes it hears on the map; DJI GEO fly zones on the +/// map with their banner, and the radar ring on the attitude ball. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji, this.waypoints}); @@ -115,6 +128,13 @@ class _FlyScreenState extends State { MissionLimits _limits = const MissionLimits(); int _seenFinishes = 0; + /// The on-screen sticks (plan P8); [_sticksOpen] mirrors its `open` for layout. + late final VirtualStickController _sticks = VirtualStickController(send: _gate.sendSticks)..mode = FlyPrefs.stickMode.value; + bool _sticksOpen = false; + + /// Keeps the GEO zones around the aircraft loaded (plan P8). + late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji); + /// Waypoints is open on the big map and nothing is flying: taps edit. bool get _editing { final WaypointProgress run = _m.mission.waypoint; @@ -124,6 +144,9 @@ class _FlyScreenState extends State { /// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner. static const Size _pipSize = Size(150, 90); + /// An on-screen stick pad's diameter, in HUD canvas pixels. + static const double _stickPad = 110; + FlightModel get _m => widget.model; DjiService get _dji => widget.dji; @@ -139,6 +162,61 @@ class _FlyScreenState extends State { _m.addListener(_onModel); _seenFinishes = _m.mission.waypointFinishes; _m.mission.addListener(_onMission); + _m.aircraft.addListener(_onSticksAircraft); + _sticks.addListener(_onSticks); + FlyPrefs.stickMode.addListener(_onStickMode); + _zones.start(); + } + + void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value; + + /// The flight controller can end virtual-stick mode on its own (the remote's + /// mode switch, RTH, landing): the controller closes, and the loop is told to stop. + void _onSticksAircraft() { + final String mode = _m.aircraft.flightModeEnum ?? ''; + _sticks.onAircraft( + enabled: _m.aircraft.virtualStickEnabled, + goingHome: _m.aircraft.goingHome == true || mode == 'GO_HOME', + landing: mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING', + ); + } + + void _onSticks() { + if (_sticks.open == _sticksOpen) return; + final String? reason = _sticks.closedReason; + if (!_sticks.open && reason != null) { + _gate.disableVirtualSticks().catchError((Object _) {}); + if (mounted) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text('On-screen sticks off: $reason'))); + } + } + if (mounted) setState(() => _sticksOpen = _sticks.open); + } + + Future _toggleSticks(BuildContext context) async { + if (_sticks.open) { + _sticks.close(); + try { + await _gate.disableVirtualSticks(); + } catch (e) { + _error('On-screen sticks', e); + } + return; + } + await startVisionMode( + context, + title: 'On-screen sticks', + icon: 'rc', + description: '${FlyPrefs.stickMode.value.label}. Lift your thumbs and the aircraft hovers. Tap STICKS again to hand back to the remote.', + checks: _gate.checkVirtualSticks(), + start: () async { + await _gate.enableVirtualSticks(); + _sticks.opened(); + }, + onError: _error, + ); } /// Says how a waypoint mission ended, and leaves edit mode when one starts. @@ -254,7 +332,10 @@ class _FlyScreenState extends State { void _onModel() { if (_m.connected == _wasConnected) return; _wasConnected = _m.connected; - if (!_m.connected) _flow.resetReview(); + if (!_m.connected) { + _flow.resetReview(); + _sticks.close(); + } } Future _openStatus(BuildContext context, {bool offerTakeOff = false}) { @@ -267,6 +348,12 @@ class _FlyScreenState extends State { void dispose() { _m.removeListener(_onModel); _m.mission.removeListener(_onMission); + _m.aircraft.removeListener(_onSticksAircraft); + FlyPrefs.stickMode.removeListener(_onStickMode); + _zones.dispose(); + // Leaving the screen hands the aircraft back to the remote. + if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {}); + _sticks.dispose(); if (widget.waypoints == null) _waypoints.dispose(); _vision.dispose(); _library.dispose(); @@ -308,7 +395,14 @@ class _FlyScreenState extends State { trail: _trail, controller: _minimap, interactive: _mapBig, - layers: waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing), + layers: [ + // Zones under the route, planes on top. + flyZoneMapLayer(_m.flyZones), + flyZoneLicenceLayer(_m.flyZones), + flyZoneHeightLayer(_m.flyZones), + ...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing), + airSenseMapLayer(_m.airSense), + ], onTap: (LatLng at) { if (_editing) _waypoints.tapMap(at); }, @@ -364,6 +458,14 @@ class _FlyScreenState extends State { ), ), ], + // Obstacles around the picture's centre; touch-transparent. + Positioned.fill( + child: Requires( + HudFeatures.visionRadar, + whenDisconnected: false, + child: VisionRadarOverlay(vision: _m.vision, aircraft: _m.aircraft, video: _m.videoSize), + ), + ), Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), ] else ...[ Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), @@ -417,7 +519,8 @@ class _FlyScreenState extends State { ), ), // ── Zoom column (zoom cameras only) ─────────────────────────────────── - if (!_mapBig) + // The right stick pad takes its place while the sticks fly. + if (!_mapBig && !_sticksOpen) Positioned( right: 68, top: 76, @@ -507,19 +610,51 @@ class _FlyScreenState extends State { listenable: _waypoints, builder: (BuildContext context, Widget? child) => _editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!, + // The left stick pad takes the attitude ball's place while the sticks fly. child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - AttitudeBall(model: _m, size: 84), + if (_sticksOpen) const SizedBox(width: _stickPad + 10) else AttitudeBall(model: _m, size: 84), const SizedBox(width: 6), TelemetryStrip(aircraft: _m.aircraft), ]), ), ), + // ── On-screen sticks (plan P8) ──────────────────────────────────────── + if (_sticksOpen) + Positioned( + left: _pipSize.width + 10, + bottom: 0, + child: ListenableBuilder( + listenable: _sticks, + builder: (BuildContext context, _) => StickPad( + key: const ValueKey('stick-left'), + size: _stickPad, + value: _sticks.left, + onChanged: _sticks.setLeft, + caption: stickCaptions(_sticks.mode).left, + ), + ), + ), + if (_sticksOpen) + Positioned( + right: 70, + bottom: 0, + child: ListenableBuilder( + listenable: _sticks, + builder: (BuildContext context, _) => StickPad( + key: const ValueKey('stick-right'), + size: _stickPad, + value: _sticks.right, + onChanged: _sticks.setRight, + caption: stickCaptions(_sticks.mode).right, + ), + ), + ), // ── Mission run panel / route board, error stack, low-battery banners ─ // Bounded above the attitude ball; the stack fits what is left under the banners. Positioned( left: 108, top: StatusBar.height + SmartBatteryBar.height + 8, - bottom: 104, + bottom: _sticksOpen ? _stickPad + 18 : 104, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error), // Palm control (Spark) flies the aircraft from the pilot's hand: say so. @@ -564,6 +699,17 @@ class _FlyScreenState extends State { onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits), ), ), + Requires( + HudFeatures.airSense, + child: AirSenseBanner(airSense: _m.airSense, onTap: () => showAirSenseSheet(context, airSense: _m.airSense)), + ), + Requires( + HudFeatures.flyZones, + child: FlyZoneBanner( + zones: _m.flyZones, + onTap: () => showFlyZoneSheet(context, model: _m, gate: _gate, onError: _error), + ), + ), SafetyBanners(gate: _gate, onError: _error), Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))), ]), @@ -577,6 +723,11 @@ class _FlyScreenState extends State { flow: _flow, modeActive: _mode != null, onModes: () => _openModes(context), + trailing: Requires( + HudFeatures.virtualSticks, + whenDisconnected: false, + child: SticksButton(open: _sticksOpen, onTap: () => _toggleSticks(context)), + ), ), ), if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)), diff --git a/Fly App/lib/ui/fly/hud_kit.dart b/Fly App/lib/ui/fly/hud_kit.dart index b81fb8d..ae4c97a 100644 --- a/Fly App/lib/ui/fly/hud_kit.dart +++ b/Fly App/lib/ui/fly/hud_kit.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../fly/flight_status.dart'; +import '../../fly/vision_radar.dart' show RadarBand; import '../../theme.dart'; /// Shared pieces of the flight HUD v2 (plan P2): the scaling canvas every HUD @@ -125,6 +126,18 @@ abstract final class HudColors { }; } +/// Vision radar band colours (plan P8), shared by the video overlay and the attitude ball. +abstract final class RadarColors { + static const Color notice = Color(0xFFF4D35E); + + static Color of(RadarBand b) => switch (b) { + RadarBand.danger => HudColors.danger, + RadarBand.caution => HudColors.caution, + RadarBand.notice => notice, + RadarBand.none => HudColors.off, + }; +} + /// The rounded translucent pill most HUD readouts sit on. class GlassPill extends StatelessWidget { const GlassPill({super.key, required this.child, this.height = 26, this.color = Glass.pill, this.padding, this.onTap}); diff --git a/Fly App/lib/ui/fly/left/action_rail.dart b/Fly App/lib/ui/fly/left/action_rail.dart index b89f62d..1ba7dee 100644 --- a/Fly App/lib/ui/fly/left/action_rail.dart +++ b/Fly App/lib/ui/fly/left/action_rail.dart @@ -17,7 +17,7 @@ import '../hud_kit.dart'; /// The grid toggle that used to sit last lives in the camera menu (Other → /// Grid): a fourth button would reach the minimap on a phone. class ActionRail extends StatelessWidget { - const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false}); + const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false, this.trailing}); final FlightModel model; final FlightCommandFlow flow; @@ -28,6 +28,9 @@ class ActionRail extends StatelessWidget { /// A mode's editor is open (the IFM button lights up). final bool modeActive; + /// A small control under the buttons (plan P8: the STICKS toggle). + final Widget? trailing; + static ({String icon, String label, Color color}) _look(FlightCommand c) => switch (c) { FlightCommand.takeOff => (icon: 'takeoff', label: 'TAKE OFF', color: Glass.accent), FlightCommand.land => (icon: 'land', label: 'LAND', color: Glass.pillStrong), @@ -76,6 +79,7 @@ class ActionRail extends StatelessWidget { onTap: onModes, ), ), + if (trailing != null) Padding(padding: const EdgeInsets.only(top: 2), child: trailing), ]); }, ); diff --git a/Fly App/lib/ui/fly/overlays/airsense.dart b/Fly App/lib/ui/fly/overlays/airsense.dart new file mode 100644 index 0000000..83abdb8 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/airsense.dart @@ -0,0 +1,264 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../../fly/state/airsense_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +String _km(double? m) => m == null ? '—' : (m >= 1000 ? '${(m / 1000).toStringAsFixed(1)} km' : '${m.round()} m'); + +/// Level 2 is caution, 3 and up danger; below that nothing to say. +Color? airSenseColor(int level) => level >= AirSenseState.dangerLevel + ? HudColors.danger + : level >= AirSenseState.cautionLevel + ? HudColors.caution + : null; + +/// The AirSense warning (plan P8) above the error stack: a manned aircraft is +/// close enough for the receiver to grade it level 2 (caution) or 3–4 (descend). +/// Names the nearest worst plane; tapping opens the list. Buzzes when the level +/// rises. +class AirSenseBanner extends StatefulWidget { + const AirSenseBanner({super.key, required this.airSense, required this.onTap, this.width = 280}); + + final AirSenseState airSense; + final VoidCallback onTap; + final double width; + + @override + State createState() => _AirSenseBannerState(); +} + +class _AirSenseBannerState extends State { + int _lastLevel = 0; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.airSense, + builder: (BuildContext context, _) { + final AirSenseState s = widget.airSense; + final int level = s.level; + if (level > _lastLevel && level >= AirSenseState.cautionLevel) { + level >= AirSenseState.dangerLevel ? HapticFeedback.heavyImpact() : HapticFeedback.mediumImpact(); + } + _lastLevel = level; + // The aircraft acting on a threat itself outranks the level. + final Color? color = s.avoiding ? HudColors.danger : airSenseColor(level); + if (color == null) return const SizedBox.shrink(); + final int? countdown = s.avoidanceCountdown?.ceil(); + final String? avoidTitle = !s.avoiding + ? null + : (s.avoidanceMode == 'LANDING' ? 'Landing to avoid manned aircraft' : 'Avoiding manned aircraft') + + (countdown != null && countdown > 0 ? ' in $countdown s' : ''); + final AirSensePlane? p = s.worst; + final String where = p == null + ? '' + : [_km(p.distance), if (p.octant != null) p.octant!, if (p.code.isNotEmpty) p.code].join(' · '); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: GestureDetector( + key: const ValueKey('banner-airsense'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Container( + width: widget.width, + decoration: BoxDecoration(color: color.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.fromLTRB(9, 6, 7, 6), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + const PVIcon('alert', size: 14, color: Colors.white), + const SizedBox(width: 6), + Expanded( + child: Text( + avoidTitle ?? + (level >= 4 + ? 'Collision risk: descend now' + : level >= AirSenseState.dangerLevel + ? 'Manned aircraft close: descend' + : 'Manned aircraft nearby'), + style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + ]), + if (where.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(where, style: HudText.monoSmall.copyWith(color: Colors.white)), + ], + ]), + ), + ), + ); + }, + ); + } +} + +/// Every plane the receiver hears, nearest first (GO 4's "AirSense System" +/// list: ICAO · distance; MSDK 4.18 has no altitude). Shared by the AirSense +/// sheet and the settings sheet's Safety tab. +class AirSenseList extends StatelessWidget { + const AirSenseList({super.key, required this.airSense, this.shrinkWrap = false}); + + final AirSenseState airSense; + final bool shrinkWrap; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: airSense, + builder: (BuildContext context, _) { + if (!airSense.reported) { + return _note('This aircraft has not reported AirSense. Only aircraft with an ADS-B receiver hear manned traffic.'); + } + if (airSense.planes.isEmpty) return _note('No manned aircraft heard.'); + final Widget header = Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row(children: [ + Expanded(flex: 3, child: Text('ICAO', style: HudText.label)), + Expanded(flex: 3, child: Text('DISTANCE', style: HudText.label)), + Expanded(flex: 2, child: Text('DIR', style: HudText.label)), + Expanded(flex: 2, child: Text('HDG', style: HudText.label)), + ]), + ); + return ListView( + shrinkWrap: shrinkWrap, + physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, + padding: EdgeInsets.zero, + children: [ + header, + for (final AirSensePlane p in airSense.planes) + Padding( + key: ValueKey('airsense-${p.code}'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: Row(children: [ + Expanded( + flex: 3, + child: Row(children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: airSenseColor(p.warningLevel) ?? HudColors.off), + ), + const SizedBox(width: 6), + Flexible(child: Text(p.code.isEmpty ? '—' : p.code, style: HudText.mono)), + ]), + ), + Expanded(flex: 3, child: Text(_km(p.distance), style: HudText.mono)), + Expanded(flex: 2, child: Text(p.octant ?? '—', style: HudText.mono)), + Expanded(flex: 2, child: Text(p.heading == null ? '—' : '${p.heading!.round()}°', style: HudText.mono)), + ]), + ), + ], + ); + }, + ); + } + + Widget _note(String text) => Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Text(text, style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)), + ); +} + +/// The AirSense list as a right side sheet over the video. +Future showAirSenseSheet(BuildContext context, {required AirSenseState airSense}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'AirSense', + barrierColor: const Color(0x55050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + child: Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: 340, + height: MediaQuery.sizeOf(context).height, + child: SafeArea( + left: false, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 4, 6), + child: Row(children: [ + const PVIcon('radio', size: 16, color: Glass.ink), + const SizedBox(width: 8), + Expanded(child: Text('AirSense', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + IconButton( + key: const ValueKey('airsense-close'), + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ), + const Divider(height: 1, color: Glass.hairline), + Expanded(child: AirSenseList(airSense: airSense)), + Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Manned aircraft always have right of way. Descend and give way when AirSense warns.', + style: HudText.sans.copyWith(color: HudText.dim, fontSize: 10.5, fontWeight: FontWeight.w400), + ), + ), + ]), + ), + ), + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); +} + +/// The planes on the map: a heading-rotated arrow, coloured by warning level. +Widget airSenseMapLayer(AirSenseState airSense) => ListenableBuilder( + listenable: airSense, + builder: (BuildContext context, _) => MarkerLayer(markers: [ + for (final AirSensePlane p in airSense.planes) + if (p.hasPosition) + Marker( + key: ValueKey('airsense-marker-${p.code}'), + point: LatLng(p.latitude!, p.longitude!), + width: 26, + height: 26, + child: Transform.rotate( + angle: (p.heading ?? 0) * math.pi / 180, + child: CustomPaint(painter: _PlanePainter(airSenseColor(p.warningLevel) ?? Glass.ink)), + ), + ), + ]), + ); + +/// A nose-up arrowhead with a dark outline, readable on any map tile. +class _PlanePainter extends CustomPainter { + _PlanePainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final Path path = Path() + ..moveTo(w / 2, 2) + ..lineTo(w - 4, h - 4) + ..lineTo(w / 2, h * 0.7) + ..lineTo(4, h - 4) + ..close(); + canvas.drawPath(path, Paint()..color = color); + canvas.drawPath(path, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = const Color(0xCC081020)); + } + + @override + bool shouldRepaint(_PlanePainter old) => old.color != color; +} diff --git a/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart b/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart new file mode 100644 index 0000000..8f6327f --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../../flight_model.dart'; +import '../../../fly/safety_gate.dart'; +import '../../../fly/state/fly_zone_state.dart'; +import '../../../theme.dart'; +import '../dialogs/slide_to_confirm.dart'; +import '../hud_kit.dart'; +import '../panels/camera_menu/camera_rows.dart'; +import 'fly_zones.dart' show flyZoneColor; + +/// Height limits on the map (plan P8): each part's ceiling at its centre — +/// `120 m`, or `NO FLY` where the limit is 0. +Widget flyZoneHeightLayer(FlyZoneState zones) => ListenableBuilder( + listenable: zones, + builder: (BuildContext context, _) => MarkerLayer(markers: [ + for (final FlyZone z in zones.zones) + for (final SubFlyZone s in z.subZones) + if (s.heightLabel != null && s.labelPoint != null) + Marker( + key: ValueKey('flyzone-height-${z.id}-${s.labelPoint}'), + point: LatLng(s.labelPoint!.$1, s.labelPoint!.$2), + width: 64, + height: 18, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xCC081020), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: flyZoneColor(z)), + ), + child: Text(s.heightLabel!, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8, fontWeight: FontWeight.w700)), + ), + ), + ), + ]), + ); + +/// The aircraft's licence areas on the map, green; the enabled one stronger. +Widget flyZoneLicenceLayer(FlyZoneState zones) => ListenableBuilder( + listenable: zones, + builder: (BuildContext context, _) { + final List circles = []; + final List polygons = []; + for (final FlyZoneLicence l in zones.licences) { + if (!l.hasArea || l.expired) continue; + final Color fill = HudColors.ready.withValues(alpha: l.enabled ? 0.18 : 0.05); + final Color border = HudColors.ready.withValues(alpha: l.enabled ? 1 : 0.5); + if (l.points.length >= 3) { + polygons.add(Polygon( + points: [for (final (double, double) p in l.points) LatLng(p.$1, p.$2)], + color: fill, + borderColor: border, + borderStrokeWidth: 1.5, + )); + } else { + circles.add(CircleMarker( + point: LatLng(l.latitude!, l.longitude!), + radius: l.radius!, + useRadiusInMeter: true, + color: fill, + borderColor: border, + borderStrokeWidth: 1.5, + )); + } + } + return Stack(children: [ + if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), + if (circles.isNotEmpty) CircleLayer(circles: circles), + ]); + }, + ); + +/// Licences on the aircraft (plan P8): DJI-issued custom unlocks, one enabled +/// at a time, synced from the logged-in DJI account. Every change happens on +/// the ground; enabling, disabling and removing are confirmed by a slide. +class FlyZoneLicences extends StatelessWidget { + const FlyZoneLicences({super.key, required this.model, required this.gate, required this.onError}); + + final FlightModel model; + final SafetyGate gate; + final void Function(String label, Object error) onError; + + Future _run(BuildContext context, String label, SlideConfirmSpec spec, Future Function() action) async { + final SlideResult? ok = await showSlideToConfirm(context, spec); + if (ok == null) return; + try { + await action(); + } catch (e) { + onError(label, e); + } + } + + static String _name(FlyZoneLicence l) => l.name.isEmpty ? '${l.typeLabel} ${l.id}' : l.name; + + @override + Widget build(BuildContext context) { + final FlyZoneState z = model.flyZones; + return ListenableBuilder( + listenable: Listenable.merge([z, model.aircraft]), + builder: (BuildContext context, _) { + final List blocked = gate.checkLicenceChange(); + final String? lock = blocked.isEmpty ? null : blocked.first.message; + final TextStyle note = HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400); + final Widget header = Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Text('LICENCES', style: HudText.label), + ); + if (z.licencesSupported == false) { + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + header, + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 10), + child: Text('This aircraft does not take custom unlock licences.', style: note), + ), + ]); + } + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + header, + if (z.licences.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 6), + child: Text(z.licencesSupported == null ? 'Licences load when the aircraft connects.' : 'No licences on this aircraft.', style: note), + ), + for (final FlyZoneLicence l in z.licences) + SwitchTile( + key: ValueKey('licence-${l.id}'), + label: _name(l), + hint: [ + l.typeLabel, + if (l.height != null && l.height! > 0) 'up to ${l.height} m', + if (l.country != null && l.country!.isNotEmpty) l.country!, + if (l.validity.isNotEmpty) l.validity, + if (l.expired) 'expired', + ].join(' · '), + value: l.enabled, + onChanged: (bool on) { + if (lock != null) { + onError('Licence', MissionRefused(blocked)); + return; + } + _run( + context, + 'Licence', + SlideConfirmSpec( + icon: 'shield', + title: '${on ? 'Enable' : 'Disable'} ${_name(l)}', + description: on + ? 'The aircraft may fly in the licensed area until the licence ends. Any other enabled licence is switched off.' + : 'The licensed area is locked again.', + sliderLabel: on ? 'Slide to enable' : 'Slide to disable', + color: HudColors.ready, + ), + () => gate.setLicenceEnabled(l, on), + ); + }, + ), + ButtonTile( + id: 'syncLicences', + label: 'Sync from DJI account', + hint: lock ?? 'Downloads the licences issued to your DJI account onto the aircraft', + button: 'Sync', + onTap: lock != null + ? null + : () async { + try { + await gate.syncLicences(); + } catch (e) { + onError('Sync licences', e); + } + }, + ), + if (z.licences.isNotEmpty) + ButtonTile( + id: 'deleteLicences', + label: 'Remove all licences', + hint: lock, + button: 'Remove', + danger: true, + onTap: lock != null + ? null + : () => _run( + context, + 'Remove licences', + const SlideConfirmSpec( + icon: 'shield', + title: 'Remove all licences', + description: 'Every licence is deleted from the aircraft. Sync again to get them back.', + sliderLabel: 'Slide to remove', + color: HudColors.danger, + ), + gate.deleteAllLicences, + ), + ), + ]); + }, + ); + } +} + +/// The GEO database (plan P8): versions, whether DJI has a newer one, and the +/// basic database download with its progress. +class FlyZoneDatabaseRow extends StatelessWidget { + const FlyZoneDatabaseRow({super.key, required this.zones, required this.gate, required this.onError}); + + final FlyZoneState zones; + final SafetyGate gate; + final void Function(String label, Object error) onError; + + static String _line(String name, String? version, String? state) => '$name ${version ?? '—'} · ${FlyZoneDatabase.stateLabel(state)}'; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: zones, + builder: (BuildContext context, _) { + final FlyZoneDatabase? db = zones.database; + final double? p = zones.upgradeProgress; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Text('GEO DATABASE', style: HudText.label), + ), + Padding( + key: const ValueKey('flyzone-db'), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Text( + db == null + ? 'Not reported yet' + : '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}', + style: HudText.monoSmall.copyWith(color: db?.outOfDate == true ? HudColors.caution : Glass.ink), + ), + ), + if (p != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 4, 4), + child: Row(children: [ + Expanded(child: LinearProgressIndicator(key: const ValueKey('flyzone-db-progress'), value: p)), + const SizedBox(width: 8), + Text('${(p * 100).round()} %', style: HudText.monoSmall), + TextButton( + key: const ValueKey('flyzone-db-cancel'), + onPressed: () async { + try { + await gate.cancelFlyZoneDatabaseUpgrade(); + } catch (e) { + onError('Cancel update', e); + } + }, + child: const Text('Cancel'), + ), + ]), + ) + else + ButtonTile( + id: 'updateFlyZoneDb', + label: 'Update basic database', + hint: db?.outOfDate == true ? 'A newer database is available' : "Downloads DJI's latest GEO zones over the internet", + button: 'Update', + onTap: () async { + try { + await gate.upgradeFlyZoneDatabase(); + } catch (e) { + onError('GEO database', e); + } + }, + ), + ]); + }, + ); + } +} diff --git a/Fly App/lib/ui/fly/overlays/fly_zones.dart b/Fly App/lib/ui/fly/overlays/fly_zones.dart new file mode 100644 index 0000000..d4ee22b --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/fly_zones.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../../flight_model.dart'; +import '../../../fly/safety_gate.dart'; +import '../../../fly/state/fly_zone_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../dialogs/slide_to_confirm.dart'; +import '../hud_kit.dart'; +import 'fly_zone_licences.dart'; + +/// GO 4's GEO colours: restricted red, authorization blue, enhanced warning +/// orange, warning yellow. +Color flyZoneColor(FlyZone z) => switch (z.category) { + 'RESTRICTED' => HudColors.danger, + 'AUTHORIZATION' => const Color(0xFF5B93F5), + 'ENHANCED_WARNING' => HudColors.caution, + 'WARNING' => RadarColors.notice, + _ => HudColors.off, + }; + +Color? zoneLevelColor(ZoneLevel l) => switch (l) { + ZoneLevel.danger => HudColors.danger, + ZoneLevel.caution => HudColors.caution, + ZoneLevel.info => const Color(0xFF3A5A8C), + ZoneLevel.none => null, + }; + +String _km(double? m) => m == null ? '' : (m >= 1000 ? '${(m / 1000).toStringAsFixed(1)} km' : '${m.round()} m'); + +/// The fly-zone banner (plan P8) above the error stack: the latest FlySafe +/// notice while it is fresh (a serious one with its countdown), else the +/// aircraft's standing when it is in or next to a restricted / authorization +/// zone. Tap opens the zone list. Buzzes on a serious warning. +class FlyZoneBanner extends StatefulWidget { + const FlyZoneBanner({super.key, required this.zones, required this.onTap, this.width = 280}); + + final FlyZoneState zones; + final VoidCallback onTap; + final double width; + + @override + State createState() => _FlyZoneBannerState(); +} + +class _FlyZoneBannerState extends State { + Timer? _tick; + FlySafeNotice? _buzzed; + + @override + void dispose() { + _tick?.cancel(); + super.dispose(); + } + + /// Rebuilds once a second while a notice is up, so it expires and counts down. + void _schedule(bool on) { + _tick?.cancel(); + _tick = on ? Timer(const Duration(seconds: 1), () => mounted ? setState(() {}) : null) : null; + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.zones, + builder: (BuildContext context, _) { + final FlyZoneState z = widget.zones; + final DateTime now = DateTime.now(); + final FlySafeNotice? n = z.noticeAt(now); + _schedule(n != null); + if (n != null && n.level == ZoneLevel.danger && !identical(n, _buzzed)) { + _buzzed = n; + HapticFeedback.heavyImpact(); + } + final String title; + final String? body; + final ZoneLevel level; + if (n != null) { + final int? left = n.countdown == null ? null : n.countdown! - now.difference(n.at).inSeconds; + title = left != null && left > 0 ? '${n.title} · $left s' : n.title; + final String? zone = z.zonesFor(n).map((FlyZone f) => f.name).where((String s) => s.isNotEmpty).firstOrNull; + body = [ + if (n.description != null && n.description!.isNotEmpty) n.description!, + if (n.heightLimit != null && n.heightLimit! > 0) 'Height limit ${n.heightLimit} m', + if (zone != null) zone, + ].join(' · '); + level = n.level; + } else if (z.stateLevel.index >= ZoneLevel.caution.index) { + title = z.stateLabel; + body = null; + level = z.stateLevel; + } else { + return const SizedBox.shrink(); + } + final Color color = zoneLevelColor(level) ?? HudColors.neutral; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: GestureDetector( + key: const ValueKey('banner-flyzone'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Container( + width: widget.width, + decoration: BoxDecoration(color: color.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.fromLTRB(9, 6, 7, 6), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + const PVIcon('shield', size: 14, color: Colors.white), + const SizedBox(width: 6), + Expanded(child: Text(title, style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700))), + ]), + if (body != null && body.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(body, maxLines: 3, overflow: TextOverflow.ellipsis, + style: HudText.sans.copyWith(color: Colors.white, fontSize: 10.5, fontWeight: FontWeight.w400)), + ], + ]), + ), + ), + ); + }, + ); + } +} + +/// The zones on the map: circles in metres and polygons, filled lightly in +/// their category colour. +Widget flyZoneMapLayer(FlyZoneState zones) => ListenableBuilder( + listenable: zones, + builder: (BuildContext context, _) { + final List circles = []; + final List polygons = []; + for (final FlyZone z in zones.zones) { + final Color c = flyZoneColor(z); + final Color fill = c.withValues(alpha: zones.unlocked.contains(z.id) ? 0.06 : 0.16); + final List parts = z.subZones; + if (parts.isEmpty && z.latitude != null && z.longitude != null && (z.radius ?? 0) > 0) { + circles.add(CircleMarker( + point: LatLng(z.latitude!, z.longitude!), + radius: z.radius!, + useRadiusInMeter: true, + color: fill, + borderColor: c, + borderStrokeWidth: 1.5, + )); + } + for (final SubFlyZone s in parts) { + if (s.isPolygon) { + polygons.add(Polygon( + points: [for (final (double, double) v in s.vertices) LatLng(v.$1, v.$2)], + color: fill, + borderColor: c, + borderStrokeWidth: 1.5, + )); + } else if (s.latitude != null && s.longitude != null && (s.radius ?? 0) > 0) { + circles.add(CircleMarker( + point: LatLng(s.latitude!, s.longitude!), + radius: s.radius!, + useRadiusInMeter: true, + color: fill, + borderColor: c, + borderStrokeWidth: 1.5, + )); + } + } + } + return Stack(children: [ + if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), + if (circles.isNotEmpty) CircleLayer(circles: circles), + ]); + }, + ); + +/// Nearby zones, nearest first, with Unlock on authorization zones. Shared by +/// the zone sheet and Settings → Safety. +class FlyZoneList extends StatelessWidget { + const FlyZoneList({super.key, required this.model, required this.gate, required this.onError, this.shrinkWrap = false}); + + final FlightModel model; + final SafetyGate gate; + final void Function(String label, Object error) onError; + final bool shrinkWrap; + + Future _unlock(BuildContext context, FlyZone z) async { + final SlideResult? ok = await showSlideToConfirm( + context, + SlideConfirmSpec( + icon: 'shield', + title: 'Unlock ${z.name.isEmpty ? 'zone ${z.id}' : z.name}', + description: 'Only unlock a zone you are authorized to fly in. The unlock is recorded against your DJI account ' + 'and applies to this aircraft.', + sliderLabel: 'Slide to unlock', + color: const Color(0xFF5B93F5), + ), + ); + if (ok == null) return; + try { + await gate.unlockFlyZone(z); + } catch (e) { + onError('Unlock', e); + } + } + + @override + Widget build(BuildContext context) { + final FlyZoneState zones = model.flyZones; + return ListenableBuilder( + listenable: zones, + builder: (BuildContext context, _) { + final double? lat = model.aircraft.latitude ?? model.phoneLatitude; + final double? lon = model.aircraft.longitude ?? model.phoneLongitude; + final List list = zones.nearest(lat, lon); + final Widget status = Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: Text( + zones.state == null ? 'Fly zone status: not reported yet' : zones.stateLabel, + style: HudText.sans.copyWith(color: zoneLevelColor(zones.stateLevel) ?? Glass.ink), + ), + ); + if (list.isEmpty) { + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + status, + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Text( + zones.zonesAt == null ? 'Zones load once the aircraft or this phone has a position.' : 'No fly zones nearby.', + style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400), + ), + ), + ]); + } + return ListView( + shrinkWrap: shrinkWrap, + physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, + padding: EdgeInsets.zero, + children: [ + status, + for (final FlyZone z in list) + Padding( + key: ValueKey('flyzone-${z.id}'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(shape: BoxShape.circle, color: flyZoneColor(z))), + const SizedBox(width: 8), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(z.name.isEmpty ? 'Zone ${z.id}' : z.name, style: HudText.sans, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + [ + z.categoryLabel, + if (z.reasonLabel != null) z.reasonLabel!, + if (lat != null && lon != null) _km(z.distanceFrom(lat, lon)), + if (zones.unlocked.contains(z.id)) 'unlocked', + ].where((String s) => s.isNotEmpty).join(' · '), + style: HudText.label, + ), + ]), + ), + if (z.unlockable && !zones.unlocked.contains(z.id)) + TextButton( + key: ValueKey('flyzone-unlock-${z.id}'), + onPressed: model.connected ? () => _unlock(context, z) : null, + child: const Text('Unlock'), + ), + ]), + ), + ], + ); + }, + ); + } +} + +/// The zone list as a right side sheet over the video. +Future showFlyZoneSheet( + BuildContext context, { + required FlightModel model, + required SafetyGate gate, + required void Function(String label, Object error) onError, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Fly zones', + barrierColor: const Color(0x55050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + child: Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: 360, + height: MediaQuery.sizeOf(context).height, + child: SafeArea( + left: false, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 4, 6), + child: Row(children: [ + const PVIcon('shield', size: 16, color: Glass.ink), + const SizedBox(width: 8), + Expanded(child: Text('Fly zones', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + IconButton( + key: const ValueKey('flyzone-close'), + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ), + const Divider(height: 1, color: Glass.hairline), + Expanded( + child: ListView(padding: EdgeInsets.zero, children: [ + FlyZoneList(model: model, gate: gate, onError: onError, shrinkWrap: true), + FlyZoneLicences(model: model, gate: gate, onError: onError), + FlyZoneDatabaseRow(zones: model.flyZones, gate: gate, onError: onError), + ]), + ), + ]), + ), + ), + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); +} diff --git a/Fly App/lib/ui/fly/overlays/vision_radar.dart b/Fly App/lib/ui/fly/overlays/vision_radar.dart new file mode 100644 index 0000000..4ce3805 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/vision_radar.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../fly/fly_prefs.dart'; +import '../../../fly/modes/vision_modes.dart' show VisionGeometry; +import '../../../fly/state/aircraft_state.dart'; +import '../../../fly/state/vision_state.dart'; +import '../../../fly/vision_radar.dart'; +import '../hud_kit.dart'; + +/// The vision radar (plan P8, GO 4's `vision_radar_view`): around the centre of +/// the live picture, one arc group per sensing side — front at the top — with +/// one to three bars per sector as the obstacle closes in, coloured notice / +/// caution / danger, the nearest distance beside each side, the overhead +/// distance above, and "BLOCKED" on a side whose sensors are switched on but +/// cannot see (shown once the motors run; covered sensors on the ground are +/// normal). +/// +/// Touch-transparent. Buzzes when any side turns red. +class VisionRadarOverlay extends StatefulWidget { + const VisionRadarOverlay({super.key, required this.vision, required this.aircraft, this.video}); + + final VisionState vision; + final AircraftState aircraft; + final ValueListenable? video; + + @override + State createState() => _VisionRadarOverlayState(); +} + +class _VisionRadarOverlayState extends State { + RadarBand _lastBand = RadarBand.none; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ValueListenableBuilder( + valueListenable: FlyPrefs.showRadar, + builder: (BuildContext context, bool on, _) => !on + ? const SizedBox.shrink() + : ListenableBuilder( + listenable: Listenable.merge([widget.vision, widget.aircraft, widget.video]), + builder: (BuildContext context, _) { + final VisionRadar radar = VisionRadar.of(widget.vision); + final RadarBand band = VisionRadar.bandFor(radar.level); + if (band == RadarBand.danger && _lastBand != RadarBand.danger) HapticFeedback.heavyImpact(); + _lastBand = band; + if (radar.isEmpty) return const SizedBox.shrink(key: ValueKey('radar-empty')); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints c) => CustomPaint( + key: const ValueKey('vision-radar'), + size: c.biggest, + painter: RadarPainter( + radar: radar, + content: VisionGeometry.contentRect(c.biggest, widget.video?.value), + showBlocked: widget.aircraft.motorsOn == true, + ), + ), + ); + }, + ), + ), + ); + } +} + +class RadarPainter extends CustomPainter { + RadarPainter({required this.radar, required this.content, required this.showBlocked}); + + final VisionRadar radar; + final Rect content; + final bool showBlocked; + + /// Bearing (° clockwise from the nose, which is screen-up) → canvas radians. + static double _rad(double bearing) => (bearing - 90) * math.pi / 180; + + @override + void paint(Canvas canvas, Size size) { + final Offset c = content.center; + // Clear of the battery bar above and the telemetry strip below at phone sizes. + final double r = content.shortestSide * 0.26; + const double bar = 4, gap = 3; + for (final RadarSideView side in radar.sides.values) { + for (final RadarSector s in side.sectors) { + final int bars = s.band.index; // notice 1, caution 2, danger 3 + if (bars == 0) continue; + final Paint p = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = bar + ..strokeCap = StrokeCap.butt + ..color = RadarColors.of(s.band); + // One degree of air between neighbouring sectors. + final double start = _rad(s.start + 0.5), sweep = (s.sweep - 1) * math.pi / 180; + for (int i = 0; i < bars; i++) { + final double radius = r - i * (bar + gap); + canvas.drawArc(Rect.fromCircle(center: c, radius: radius), start, sweep, false, p); + } + } + final double a = _rad(side.side.bearing); + final Offset at = c + Offset(math.cos(a), math.sin(a)) * (r + 16); + if (side.blocked && showBlocked) { + _label(canvas, at, 'BLOCKED', HudColors.caution); + } else if (side.closest != null && side.level > 0) { + _label(canvas, at, '${side.closest!.toStringAsFixed(1)} m', RadarColors.of(side.band)); + } + } + final double? up = radar.up; + if (up != null) { + // Inside the ring, under the front arcs: above them it would crowd the banners. + _label(canvas, c + Offset(0, -(r - 26)), 'UP ${up.toStringAsFixed(1)} m', RadarColors.of(VisionRadar.bandFor(VisionRadar.levelFor(up)))); + } + } + + void _label(Canvas canvas, Offset centre, String text, Color color) { + final TextPainter tp = TextPainter( + text: TextSpan( + text: text, + style: HudText.monoSmall.copyWith(color: color, fontWeight: FontWeight.w700, shadows: const [Shadow(blurRadius: 3)]), + ), + textDirection: TextDirection.ltr, + )..layout(); + final Rect box = Rect.fromCenter(center: centre, width: tp.width + 8, height: tp.height + 4); + canvas.drawRRect(RRect.fromRectAndRadius(box, const Radius.circular(3)), Paint()..color = const Color(0x8C081020)); + tp.paint(canvas, box.topLeft + const Offset(4, 2)); + } + + @override + bool shouldRepaint(RadarPainter old) => old.radar != radar || old.content != content || old.showBlocked != showBlocked; +} diff --git a/Fly App/lib/ui/fly/panels/settings/sensing_rows.dart b/Fly App/lib/ui/fly/panels/settings/sensing_rows.dart new file mode 100644 index 0000000..9d751be --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/sensing_rows.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import '../../../../dji_service.dart'; +import '../../../../fly/sensing_distances.dart'; +import '../../hud_kit.dart'; +import '../camera_menu/camera_rows.dart'; + +/// Settings → Vision → obstacle distances (plan P8): where the aircraft brakes +/// and how far it looks, per direction the aircraft reports. Read once when the +/// tab opens; a step writes at once and rolls back if the aircraft refuses. +class SensingDistanceRows extends StatefulWidget { + const SensingDistanceRows({super.key, required this.dji, required this.connected, required this.onError}); + + final DjiService dji; + final bool connected; + final void Function(String label, Object error) onError; + + @override + State createState() => _SensingDistanceRowsState(); +} + +class _SensingDistanceRowsState extends State { + SensingDistances? _d; + bool _loading = false; + final Set<(SensingKind, SensingDirection)> _pending = <(SensingKind, SensingDirection)>{}; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(SensingDistanceRows old) { + super.didUpdateWidget(old); + if (widget.connected && !old.connected) _load(); + } + + Future _load() async { + if (!widget.connected || _loading) return; + _loading = true; + try { + final SensingDistances d = SensingDistances.fromBridge(await widget.dji.getSensingDistances()); + if (mounted) setState(() => _d = d); + } catch (_) { + if (mounted) setState(() => _d = const SensingDistances()); + } finally { + _loading = false; + } + } + + Future _step(SensingKind k, SensingDirection dir, int steps) async { + final SensingDistances? before = _d; + final double? v = before?.value(k, dir); + if (before == null || v == null) return; + final double next = SensingDistances.stepped(k, dir, v, steps); + if (next == v) return; + setState(() { + _d = before.withValue(k, dir, next); + _pending.add((k, dir)); + }); + try { + await (k == SensingKind.avoidance + ? widget.dji.setAvoidanceDistance(dir.sdk, next) + : widget.dji.setMaxPerceptionDistance(dir.sdk, next)); + } catch (e) { + if (mounted) setState(() => _d = before); + widget.onError(k == SensingKind.avoidance ? 'Braking distance' : 'Sensing range', e); + } finally { + if (mounted) setState(() => _pending.remove((k, dir))); + } + } + + @override + Widget build(BuildContext context) { + final SensingDistances? d = _d; + // Nothing reported (or not connected): the rows stay out of the way. + if (d == null || d.isEmpty) return const SizedBox.shrink(); + Widget row(SensingKind k, SensingDirection dir) { + final double? v = d.value(k, dir); + if (v == null) return const SizedBox.shrink(); + final ({double min, double max, double step}) r = SensingDistances.range(k, dir); + final String what = k == SensingKind.avoidance ? 'braking distance' : 'sensing range'; + return StepperTile( + key: ValueKey('row-sensing-${k.name}-${dir.name}'), + id: 'sensing-${k.name}-${dir.name}', + label: '${dir.label} $what', + hint: k == SensingKind.avoidance ? 'The aircraft brakes this far from an obstacle' : 'Obstacles closer than this are reported', + shown: '${v.toStringAsFixed(r.step < 1 ? 1 : 0)} m', + canDown: v > r.min, + canUp: v < r.max, + pending: _pending.contains((k, dir)), + onStep: (int s) => _step(k, dir, s), + ); + } + + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Text('OBSTACLE DISTANCES', style: HudText.label), + ), + for (final SensingKind k in SensingKind.values) + for (final SensingDirection dir in SensingDirection.values) row(k, dir), + ]); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart index bca2aa9..378dbfd 100644 --- a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart +++ b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart @@ -5,19 +5,25 @@ import '../../../../flight_model.dart'; import '../../../../fly/fly_prefs.dart'; import '../../../../fly/hud_features.dart'; import '../../../../fly/product_profile.dart'; +import '../../../../fly/safety_gate.dart'; import '../../../../fly/settings/settings_spec.dart'; import '../../../../fly/settings/settings_tabs.dart'; import '../../../../fly/state/key_state.dart'; import '../../../../fly/state/parse.dart'; +import '../../../../fly/virtual_sticks.dart' show StickMode; import '../../../../theme.dart'; import '../../../pv_icons.dart'; import '../../debug/product_profile_page.dart'; import '../../debug/telemetry_inspector_page.dart'; import '../../dialogs/slide_to_confirm.dart'; import '../../hud_kit.dart'; +import '../../overlays/airsense.dart'; +import '../../overlays/fly_zone_licences.dart'; +import '../../overlays/fly_zones.dart'; import '../camera_menu/camera_rows.dart'; import '../gimbal_sheet.dart'; import 'calibration/calibration_hub.dart'; +import 'sensing_rows.dart'; import 'setting_tiles.dart'; /// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass @@ -280,6 +286,49 @@ class _FlySettingsSheetState extends State { SettingsTabs.clearRoute => widget.onClearRoute == null ? const SizedBox.shrink() : ButtonTile(id: 'clearRoute', label: 'Clear flight route', button: 'Clear', onTap: widget.onClearRoute), + SettingsTabs.radarChart => ValueListenableBuilder( + valueListenable: FlyPrefs.showRadar, + builder: (BuildContext context, bool on, _) => SwitchTile( + key: const ValueKey('row-radarChart'), + label: 'Display radar chart', + hint: 'Obstacle distances around the centre of the video', + value: on, + onChanged: FlyPrefs.setShowRadar, + ), + ), + SettingsTabs.stickPads => ValueListenableBuilder( + valueListenable: FlyPrefs.stickMode, + builder: (BuildContext context, StickMode m, _) => ChoiceTile( + key: const ValueKey('row-stickPads'), + label: 'Stick mode', + hint: 'What each on-screen stick does; match your remote', + options: <(String, String)>[for (final StickMode s in StickMode.values) (s.name, s.label)], + selected: m.name, + onSelect: (String v) => FlyPrefs.setStickMode(StickMode.values.byName(v)), + ), + ), + SettingsTabs.sensingDistances => + SensingDistanceRows(key: const ValueKey('row-sensingDistances'), dji: widget.dji, connected: c.connected, onError: widget.onError), + SettingsTabs.flyZoneList => FlyZoneList( + key: const ValueKey('row-flyZones'), + model: _m, + gate: SafetyGate(model: _m, dji: widget.dji), + onError: widget.onError, + shrinkWrap: true, + ), + SettingsTabs.flyZoneLicences => FlyZoneLicences( + key: const ValueKey('row-flyZoneLicences'), + model: _m, + gate: SafetyGate(model: _m, dji: widget.dji), + onError: widget.onError, + ), + SettingsTabs.flyZoneDatabase => FlyZoneDatabaseRow( + key: const ValueKey('row-flyZoneDatabase'), + zones: _m.flyZones, + gate: SafetyGate(model: _m, dji: widget.dji), + onError: widget.onError, + ), + SettingsTabs.airSenseList => AirSenseList(key: const ValueKey('row-airSense'), airSense: _m.airSense, shrinkWrap: true), SettingsTabs.about => _about(), SettingsTabs.developer => _developer(), _ => const SizedBox.shrink(), @@ -439,6 +488,17 @@ class _FlySettingsSheetState extends State { builder: (BuildContext context, bool on, _) => SwitchTile(label: 'HUD performance overlay', value: on, onChanged: FlyPrefs.setPerfOverlay), ), + if (HudFeatures.virtualSticks.enabledFor(_profile) || !_profile.connected) + ValueListenableBuilder( + valueListenable: FlyPrefs.sticksInRealFlight, + builder: (BuildContext context, bool on, _) => SwitchTile( + key: const ValueKey('row-sticksRealFlight'), + label: 'On-screen sticks in real flight', + hint: 'Off: the sticks only fly the simulator on aircraft with a remote', + value: on, + onChanged: FlyPrefs.setSticksInRealFlight, + ), + ), ]); } } diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index a55fc42..de3748d 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -80,6 +80,60 @@ class FakeDjiService extends DjiService { Future getActiveTrackRetreat() async => false; @override Future stopActiveTrack() => _record('stopActiveTrack'); + + // ── Sensing distances / fly zones (plan P8) ──────────────────────────────── + Map sensing = {}; + List> flyZones = >[]; + List unlockedZones = []; + + /// Zone fetches, counted apart from [calls]: the flight screen fetches on its own. + int zoneFetches = 0; + + @override + Future> getSensingDistances() async => sensing; + @override + Future setAvoidanceDistance(String direction, double metres) => _record('setAvoidanceDistance($direction, $metres)'); + @override + Future setMaxPerceptionDistance(String direction, double metres) => _record('setMaxPerceptionDistance($direction, $metres)'); + @override + Future>> getFlyZonesNearby({double? latitude, double? longitude}) async { + zoneFetches++; + return flyZones; + } + + @override + Future> getUnlockedFlyZones() async => unlockedZones; + @override + Future unlockAuthorizationFlyZone(int id) async { + await _record('unlockAuthorizationFlyZone($id)'); + unlockedZones = [...unlockedZones, id]; + } + + /// What `getFlyZoneLicences` / `getFlyZoneDatabase` answer. + Map licences = {'supported': false, 'zones': []}; + Map flyZoneDb = {}; + + @override + Future> getFlyZoneLicences() async => licences; + @override + Future setFlyZoneLicenceEnabled(int id, bool enabled) => _record('setFlyZoneLicenceEnabled($id, $enabled)'); + @override + Future syncFlyZoneLicences() => _record('syncFlyZoneLicences'); + @override + Future deleteAllFlyZoneLicences() => _record('deleteAllFlyZoneLicences'); + @override + Future> getFlyZoneDatabase() async => flyZoneDb; + @override + Future startFlyZoneDatabaseUpgrade() => _record('startFlyZoneDatabaseUpgrade'); + @override + Future stopFlyZoneDatabaseUpgrade() => _record('stopFlyZoneDatabaseUpgrade'); + + // ── Virtual sticks (plan P8) ─────────────────────────────────────────────── + @override + Future setVirtualStickEnabled(bool on) => _record('setVirtualStickEnabled($on)'); + @override + Future sendVirtualStick({required double forward, required double right, required double yaw, required double up}) => + _record('sendVirtualStick(${forward.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${yaw.toStringAsFixed(0)}, ${up.toStringAsFixed(1)})'); @override Future startCompassCalibration() => _record('startCompassCalibration'); diff --git a/Fly App/test/fly/p8_extras_test.dart b/Fly App/test/fly/p8_extras_test.dart new file mode 100644 index 0000000..30ace38 --- /dev/null +++ b/Fly App/test/fly/p8_extras_test.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/fly_zone_loader.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/safety_gate.dart'; +import 'package:pilotvault_fly/fly/sensing_distances.dart'; +import 'package:pilotvault_fly/fly/state/fly_zone_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/fly/vision_radar.dart'; +import 'package:pilotvault_fly/ui/fly/bottom/attitude_ball.dart'; +import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; +import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/sensing_rows.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +FlightModel modelWith(Map tick, {Map? profile}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + TelemetryReducer(m).apply(tick); + return m; +} + +Map airport({int id = 101, String category = 'AUTHORIZATION', double lat = 55.68, double lon = 12.57, double radius = 500}) => + { + 'id': id, + 'name': 'Test Airport $id', + 'category': category, + 'reason': 'COMMERCIAL_AIRPORTS', + 'latitude': lat, + 'longitude': lon, + 'radius': radius, + 'subZones': [ + { + 'areaId': 1, + 'shape': 'POLYGON', + 'maxHeight': 0, + 'vertices': [ + [55.67, 12.56], + [55.69, 12.56], + [55.69, 12.58], + ], + }, + ], + }; + +void main() { + group('Attitude ball radar ring', () { + test('graded sectors become rim arcs, nose-relative; empty without obstacles', () { + final FlightModel m = modelWith(telemetryTick(noseDistance: 2.0)); + final AttitudeBallData d = AttitudeBallData.from(m.aircraft, radar: VisionRadar.of(m.vision)); + // The fixture's INVALID sector drops out; the rest keep their band. + expect(d.obstacles, hasLength(3)); + expect(d.obstacles.first.start, -30); + expect(d.obstacles.map((BallObstacle o) => o.band), contains(RadarBand.notice)); + expect(AttitudeBallData.from(m.aircraft).obstacles, isEmpty); + expect(d == AttitudeBallData.from(m.aircraft, radar: VisionRadar.of(m.vision)), isTrue); + expect(d == AttitudeBallData.from(m.aircraft), isFalse); + }); + }); + + group('SensingDistances', () { + test('reads per direction; unsupported is null; steps snap and clamp', () { + final SensingDistances d = SensingDistances.fromBridge({ + 'avoidance': {'Horizontal': 3.0, 'Upward': null, 'Downward': 0.5}, + 'perception': {'Horizontal': 20.0}, + }); + expect(d.value(SensingKind.avoidance, SensingDirection.horizontal), 3.0); + expect(d.value(SensingKind.avoidance, SensingDirection.up), isNull); + expect(d.value(SensingKind.perception, SensingDirection.down), isNull); + expect(d.isEmpty, isFalse); + expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.horizontal, 3, 1), 3.5); + expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.horizontal, 10, 1), 10); + expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.down, 0.5, -1), 0.5); + expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.down, 0.5, 1), 0.6); + expect(const SensingDistances().isEmpty, isTrue); + }); + + testWidgets('rows show what the aircraft reports; a step writes, a refusal rolls back', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService() + ..sensing = { + 'avoidance': {'Horizontal': 3.0, 'Upward': null, 'Downward': null}, + 'perception': {'Horizontal': null, 'Upward': null, 'Downward': null}, + }; + final List errors = []; + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: SensingDistanceRows(dji: dji, connected: true, onError: (String l, Object e) => errors.add(l))), + )); + await tester.pump(); + expect(find.byKey(const ValueKey('row-sensing-avoidance-horizontal')), findsOneWidget); + expect(find.byKey(const ValueKey('row-sensing-avoidance-up')), findsNothing); + await tester.tap(find.byKey(const ValueKey('step-sensing-avoidance-horizontal-plus'))); + await tester.pump(); + expect(dji.calls.single, 'setAvoidanceDistance(Horizontal, 3.5)'); + expect(find.text('3.5 m'), findsOneWidget); + + dji.failWith = Exception('refused'); + await tester.tap(find.byKey(const ValueKey('step-sensing-avoidance-horizontal-plus'))); + await tester.pump(); + await tester.pump(); + expect(find.text('3.5 m'), findsOneWidget, reason: 'rolled back'); + expect(errors, ['Braking distance']); + }); + }); + + group('AirSense avoidance', () { + test('the flight controller\'s own action is read off the fast group', () { + final FlightModel m = modelWith(telemetryTick()..['airSenseAvoidance'] = {'mode': 'LANDING', 'countdown': 4.2}); + expect(m.airSense.avoiding, isTrue); + expect(m.airSense.avoidanceCountdown, 4.2); + TelemetryReducer(m).apply(telemetryTick()..['airSenseAvoidance'] = {'mode': 'NONE', 'countdown': 0.0}); + expect(m.airSense.avoiding, isFalse); + }); + }); + + group('FlyZoneState', () { + test('zones parse with their polygons and sort by distance from the edge', () { + final FlyZoneState z = FlyZoneState() + ..applyZones(>[ + airport(id: 1, lat: 55.80), + airport(id: 2, category: 'RESTRICTED', lat: 55.68, radius: 100), + ], latitude: 55.6761, longitude: 12.5683); + final List near = z.nearest(55.6761, 12.5683); + expect(near.map((FlyZone f) => f.id), [2, 1]); + expect(near.first.level, ZoneLevel.danger); + expect(near.last.unlockable, isTrue); + expect(near.last.subZones.single.isPolygon, isTrue); + expect(near.last.reasonLabel, 'Commercial airports'); + }); + + test('a notice lasts 15 s; a serious one stays while it counts down', () { + final DateTime t0 = DateTime(2026, 10, 20, 12); + final FlyZoneState z = FlyZoneState()..applyNotice({'kind': 'WARNING', 'event': 'TAKE_OFF_NEAR_NO_FLY_ZONE'}, now: t0); + expect(z.noticeAt(t0.add(const Duration(seconds: 10))), isNotNull); + expect(z.noticeAt(t0.add(const Duration(seconds: 16))), isNull); + z.applyNotice({'kind': 'SERIOUS_WARNING', 'event': 'IN_NO_FLY_ZONE', 'countdown': 60, 'zones': [7]}, now: t0); + expect(z.noticeAt(t0.add(const Duration(seconds: 40)))!.title, 'In a no-fly zone: the aircraft is landing'); + expect(z.noticeAt(t0.add(const Duration(seconds: 61))), isNull); + expect(z.notice!.zoneIds, [7]); + }); + + test('a refetch is due after 2 km or 10 minutes', () { + final DateTime t0 = DateTime(2026, 10, 20, 12); + final FlyZoneState z = FlyZoneState(); + expect(z.needsZonesFor(55.6761, 12.5683), isTrue); + z.applyZones(const >[], latitude: 55.6761, longitude: 12.5683, now: t0); + expect(z.needsZonesFor(55.6800, 12.5683, now: t0), isFalse); + expect(z.needsZonesFor(55.7000, 12.5683, now: t0), isTrue); + expect(z.needsZonesFor(55.6761, 12.5683, now: t0.add(const Duration(minutes: 11))), isTrue); + }); + + test('FlySafe notices count without an aircraft; the state needs one', () { + final FlightModel m = FlightModel(); + TelemetryReducer(m).apply({'type': 'flySafe', 'kind': 'FLYING_SAFETY_TIP', 'event': 'TAKE_OFF_IN_WARNING_AREA'}); + expect(m.flyZones.notice!.kind, 'FLYING_SAFETY_TIP'); + final FlightModel c = modelWith(telemetryTick()..['flyZone'] = {'state': 'NEAR_RESTRICTED_ZONE'}); + expect(c.flyZones.stateLevel, ZoneLevel.caution); + expect(c.flyZones.stateLabel, 'Near a restricted zone'); + c.clearTelemetry(); + expect(c.flyZones.state, isNull); + }); + }); + + group('SafetyGate fly zones', () { + Map onGround(String state) => telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false + ..['flyZone'] = {'state': state}; + + test('take-off is refused in restricted and locked authorization zones, warned in warning zones', () { + String? block(String state) => + SafetyGate(model: modelWith(onGround(state)), dji: FakeDjiService()).check(FlightCommand.takeOff).blockers.firstOrNull?.message; + expect(block('IN_RESTRICTED_ZONE'), contains('restricted zone')); + expect(block('IN_AUTHORIZED_ZONE'), contains('unlock it first')); + final GateVerdict warn = SafetyGate(model: modelWith(onGround('IN_WARNING_ZONE')), dji: FakeDjiService()).check(FlightCommand.takeOff); + expect(warn.allowed, isTrue); + expect(warn.warnings.map((GateCheck c) => c.message), contains(startsWith('Warning zone'))); + }); + + test('unlock calls the GEO system and refreshes the unlocked list; only authorization zones', () async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = modelWith(telemetryTick()); + final SafetyGate gate = SafetyGate(model: m, dji: dji); + await gate.unlockFlyZone(FlyZone.fromMap(airport(id: 42))); + expect(dji.calls.single, 'unlockAuthorizationFlyZone(42)'); + expect(m.flyZones.unlocked, {42}); + await expectLater(gate.unlockFlyZone(FlyZone.fromMap(airport(category: 'RESTRICTED'))), throwsA(isA())); + }); + }); + + group('FlyZoneLoader', () { + test('fetches around the aircraft once, and again only after it moved', () async { + final FakeDjiService dji = FakeDjiService()..flyZones = >[airport()]; + final FlightModel m = modelWith(telemetryTick()); + final FlyZoneLoader loader = FlyZoneLoader(model: m, dji: dji)..start(); + await Future.delayed(Duration.zero); + expect(dji.zoneFetches, 1); + expect(m.flyZones.zones.single.id, 101); + TelemetryReducer(m).apply(telemetryTick()); + await Future.delayed(Duration.zero); + expect(dji.zoneFetches, 1); + TelemetryReducer(m).apply(telemetryTick()..['latitude'] = 55.72); + await Future.delayed(Duration.zero); + expect(dji.zoneFetches, 2); + loader.dispose(); + }); + }); + + group('Licences and GEO database', () { + Map licence(int id, {bool enabled = false, bool expired = false}) => { + 'id': id, + 'index': id, + 'name': 'Site $id', + 'type': 'CIRCLE_UNLOCK_AREA', + 'latitude': 55.68, + 'longitude': 12.57, + 'radius': 300.0, + 'height': 120, + 'startTime': '2026-10-01', + 'endTime': '2026-12-31', + 'expired': expired, + 'enabled': enabled, + }; + + test('licences parse in index order; one is the enabled one', () { + final FlyZoneState z = FlyZoneState() + ..applyLicences({ + 'supported': true, + 'zones': [licence(2, enabled: true), licence(1)], + }); + expect(z.licencesSupported, isTrue); + expect(z.licences.map((FlyZoneLicence l) => l.id), [1, 2]); + expect(z.enabledLicence!.id, 2); + expect(z.licences.first.hasArea, isTrue); + expect(z.licences.first.validity, '2026-10-01 → 2026-12-31'); + z.clearAircraft(); + expect(z.licencesSupported, isNull); + expect(z.licences, isEmpty); + }); + + test('database state, and progress read as a fraction or a percentage', () { + final FlyZoneState z = FlyZoneState() + ..applyDatabase({'basicVersion': '2.1', 'basicState': 'OUT_OF_DATE', 'preciseVersion': null, 'preciseState': 'UP_TO_DATE'}); + expect(z.database!.outOfDate, isTrue); + expect(FlyZoneDatabase.stateLabel(z.database!.basicState), 'Out of date'); + z.applyUpgradeProgress(0.4); + expect(z.upgradeProgress, 0.4); + z.applyUpgradeProgress(55); + expect(z.upgradeProgress, closeTo(0.55, 1e-9)); + z.applyUpgradeProgress(100); + expect(z.upgradeProgress, isNull, reason: 'a complete download ends the upgrade'); + }); + + test('sub-zone height labels and where they go', () { + final SubFlyZone poly = SubFlyZone.fromMap(airport()['subZones'][0] as Map); + expect(poly.heightLabel, 'NO FLY'); + expect(poly.labelPoint!.$1, closeTo((55.67 + 55.69 + 55.69) / 3, 1e-9)); + const SubFlyZone ceiling = SubFlyZone(shape: 'CYLINDER', latitude: 55.6, longitude: 12.5, radius: 100, maxHeight: 60); + expect(ceiling.heightLabel, '60 m'); + expect(ceiling.labelPoint, (55.6, 12.5)); + expect(const SubFlyZone(shape: 'CYLINDER').heightLabel, isNull); + }); + + test('licence changes are ground-only, refused when expired, and refresh the list', () async { + final FakeDjiService dji = FakeDjiService() + ..licences = {'supported': true, 'zones': [licence(1, enabled: true)]}; + final FlightModel flying = modelWith(telemetryTick()); + await expectLater(SafetyGate(model: flying, dji: dji).syncLicences(), throwsA(isA())); + expect(dji.calls, isEmpty); + + final FlightModel ground = modelWith(telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false); + final SafetyGate gate = SafetyGate(model: ground, dji: dji); + await gate.setLicenceEnabled(FlyZoneLicence.fromMap(licence(1)), true); + expect(dji.calls.single, 'setFlyZoneLicenceEnabled(1, true)'); + expect(ground.flyZones.enabledLicence!.id, 1, reason: 'list refreshed after the change'); + await expectLater(gate.setLicenceEnabled(FlyZoneLicence.fromMap(licence(3, expired: true)), true), throwsA(isA())); + await gate.syncLicences(); + await gate.deleteAllLicences(); + expect(dji.calls.skip(1), ['syncFlyZoneLicences', 'deleteAllFlyZoneLicences']); + }); + + test('a database upgrade shows progress, then refreshes the state', () async { + final FakeDjiService dji = FakeDjiService()..flyZoneDb = {'basicVersion': '2.2', 'basicState': 'UP_TO_DATE'}; + final FlightModel m = modelWith(telemetryTick()); + await SafetyGate(model: m, dji: dji).upgradeFlyZoneDatabase(); + expect(dji.calls.single, 'startFlyZoneDatabaseUpgrade'); + expect(m.flyZones.upgradeProgress, isNull); + expect(m.flyZones.database!.basicVersion, '2.2'); + TelemetryReducer(m).apply({'type': 'flyZoneDbProgress', 'progress': 0.3}); + expect(m.flyZones.upgradeProgress, 0.3); + }); + + testWidgets('the zone sheet lists licences; enabling slides first', (WidgetTester tester) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues({}); + final FakeDjiService dji = FakeDjiService() + ..licences = {'supported': true, 'zones': [licence(5)]} + ..flyZoneDb = {'basicVersion': '2.1', 'basicState': 'OUT_OF_DATE'}; + final FlightModel m = modelWith(telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false + ..['flyZone'] = {'state': 'NEAR_RESTRICTED_ZONE'}); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('banner-flyzone'))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.byKey(const ValueKey('licence-5')), 100, scrollable: find.byType(Scrollable).last); + expect(find.text('Site 5'), findsOneWidget); + await tester.tap(find.descendant(of: find.byKey(const ValueKey('licence-5')), matching: find.byType(Switch))); + await tester.pumpAndSettle(); + expect(dji.calls, isEmpty, reason: 'nothing changes before the slide'); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(dji.calls.single, 'setFlyZoneLicenceEnabled(5, true)'); + await tester.scrollUntilVisible(find.byKey(const ValueKey('flyzone-db')), 100, scrollable: find.byType(Scrollable).last); + expect(find.textContaining('Out of date'), findsOneWidget); + }); + }); + + group('FlyScreen fly zones', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlyPrefs.showRadar.value = true; + }); + + testWidgets('a notice shows, the sheet lists nearby zones, Unlock slides then unlocks', (WidgetTester tester) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService dji = FakeDjiService()..flyZones = >[airport(id: 77)]; + final FlightModel m = modelWith(telemetryTick()); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(); + expect(find.byKey(const ValueKey('banner-flyzone')), findsNothing); + + TelemetryReducer(m).apply({ + 'type': 'flySafe', + 'kind': 'WARNING', + 'event': 'AUTHORIZED_ZONE_WITHOUT_UNLOCKING_NEARBY', + 'description': 'Authorization zone nearby', + 'zones': [77], + }); + await tester.pump(); + expect(find.byKey(const ValueKey('banner-flyzone')), findsOneWidget); + expect(find.textContaining('Test Airport 77'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('banner-flyzone'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('flyzone-77')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('flyzone-unlock-77'))); + await tester.pumpAndSettle(); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(dji.calls, contains('unlockAuthorizationFlyZone(77)')); + expect(find.byKey(const ValueKey('flyzone-unlock-77')), findsNothing); + // Expiry runs on the wall clock, which widget tests do not advance: see the FlyZoneState tests. + }); + }); +} diff --git a/Fly App/test/fly/radar_sticks_airsense_test.dart b/Fly App/test/fly/radar_sticks_airsense_test.dart new file mode 100644 index 0000000..99ae853 --- /dev/null +++ b/Fly App/test/fly/radar_sticks_airsense_test.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/safety_gate.dart'; +import 'package:pilotvault_fly/fly/state/airsense_state.dart'; +import 'package:pilotvault_fly/fly/state/vision_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/fly/virtual_sticks.dart'; +import 'package:pilotvault_fly/fly/vision_radar.dart'; +import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; +import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +FlightModel modelWith(Map tick, {Map? profile}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + TelemetryReducer(m).apply(tick); + return m; +} + +/// A 5° perception ring with [hits] (bearing → metres) and nothing elsewhere. +Map ring(Map hits, {double? up}) => { + 'angleInterval': 5.0, + 'distances': [for (int i = 0; i < 72; i++) hits[i * 5]], + 'up': up, + 'down': null, + }; + +Map plane(String code, {int level = 0, double distance = 5000, String direction = 'NORTH_EAST'}) => { + 'code': code, + 'warningLevel': level, + 'direction': direction, + 'heading': 270.0, + 'distance': distance, + 'latitude': 55.70, + 'longitude': 12.60, + }; + +void main() { + group('VisionRadar', () { + test('a side takes the aircraft\'s own sectors, left to right, clockwise', () { + final FlightModel m = modelWith(telemetryTick(noseDistance: 6.5)); + final VisionRadar r = VisionRadar.of(m.vision); + final RadarSideView front = r.sides[RadarSide.front]!; + expect(front.sectors, hasLength(4)); + expect(front.sectors.first.start, -30); + expect(front.sectors.last.start + front.sectors.last.sweep, 30); + // The INVALID sector draws nothing; the nearest graded sector is the closest. + expect(front.sectors.map((RadarSector s) => s.level), [1, 2, 1, 0]); + expect(front.closest, 6.5); + expect(front.band, RadarBand.notice); + expect(r.sides.containsKey(RadarSide.back), isFalse); + }); + + test('distances grade into levels; far is nothing', () { + expect(VisionRadar.levelFor(null), 0); + expect(VisionRadar.levelFor(1.0), 6); + expect(VisionRadar.levelFor(2.0), 5); + expect(VisionRadar.levelFor(3.5), 4); + expect(VisionRadar.levelFor(5.0), 3); + expect(VisionRadar.levelFor(8.0), 2); + expect(VisionRadar.levelFor(14.0), 1); + expect(VisionRadar.levelFor(VisionRadar.range + 1), 0); + expect(VisionRadar.bandFor(5), RadarBand.danger); + expect(VisionRadar.bandFor(3), RadarBand.caution); + }); + + test('omni perception fills sides the aircraft gave no sectors for, wrapping at the nose', () { + final VisionState v = VisionState() + ..applyPerception(ring({180: 2.0, 355: 4.0, 90: 30}, up: 3.0).cast()); + final VisionRadar r = VisionRadar.of(v); + final RadarSideView back = r.sides[RadarSide.back]!; + expect(back.closest, 2.0); + expect(back.band, RadarBand.danger); + // 355° lies in the front arc's left half. + final RadarSideView front = r.sides[RadarSide.front]!; + expect(front.closest, 4.0); + expect(front.sectors[1].distance, 4.0); + // 30 m is out of range: the right side has sectors but nothing to draw. + expect(r.sides[RadarSide.right]!.level, 0); + expect(r.up, 3.0); + expect(r.level, 5); + + v.applyPerception(ring({}, up: 12).cast()); + expect(VisionRadar.of(v).up, isNull, reason: 'overhead beyond 10 m is not shown'); + expect(VisionRadar.of(v).isEmpty, isTrue); + }); + + test('a sensor switched on but not working is blocked; one switched off is off', () { + final FlightModel m = modelWith(telemetryTick() + ..['obstacleSensors'] = { + 'forward': {'working': false, 'enabled': true}, + 'backward': {'working': false, 'enabled': false}, + 'up': {'working': true, 'enabled': true}, + }); + final VisionRadar r = VisionRadar.of(m.vision); + expect(r.sides[RadarSide.front]!.blocked, isTrue); + expect(r.sides[RadarSide.back]!.off, isTrue); + expect(r.sides[RadarSide.back]!.blocked, isFalse); + expect(r.blocked, [RadarSide.front]); + expect(m.vision.health['up']!.blocked, isFalse); + }); + }); + + group('StickMapper', () { + test('Mode 2: left is throttle + yaw, right is pitch + roll, scaled to the limits', () { + final StickCommand c = StickMapper.command(const Offset(0, 1), const Offset(-1, 0)); + expect(c.up, StickLimits.normal.vertical); + expect(c.right, -StickLimits.normal.horizontal); + expect(c.forward, 0); + expect(c.yaw, 0); + expect(StickMapper.command(const Offset(1, 0), const Offset(0, -1)).yaw, StickLimits.normal.yaw); + expect(StickMapper.command(const Offset(1, 0), const Offset(0, -1)).forward, -StickLimits.normal.horizontal); + }); + + test('Mode 1 and Mode 3 swap the axes like the remote', () { + final StickCommand m1 = StickMapper.command(const Offset(0, 1), const Offset(0, 1), mode: StickMode.mode1); + expect(m1.forward, StickLimits.normal.horizontal); + expect(m1.up, StickLimits.normal.vertical); + final StickCommand m3 = StickMapper.command(const Offset(1, 0), const Offset(1, 0), mode: StickMode.mode3); + expect(m3.right, StickLimits.normal.horizontal); + expect(m3.yaw, StickLimits.normal.yaw); + }); + + test('the deadzone holds still; small deflections are finer than linear', () { + expect(StickMapper.command(const Offset(0.05, 0.05), const Offset(-0.07, 0.02)), StickCommand.zero); + final double half = StickMapper.shape(0.5); + expect(half, greaterThan(0)); + expect(half, lessThan(0.5 - StickMapper.deadzone)); + expect(StickMapper.shape(-1), -1); + }); + + test('a finger outside the ring clamps to it, and screen-down is stick-down', () { + final Offset v = StickMapper.normalise(const Offset(0, 200), 50); + expect(v.dx, 0); + expect(v.dy, -1); + expect(StickMapper.normalise(const Offset(25, -25), 50), const Offset(0.5, 0.5)); + }); + }); + + group('VirtualStickController', () { + testWidgets('sends on change, repeats while held, one zero on release', (WidgetTester tester) async { + final List sent = []; + final VirtualStickController c = VirtualStickController(send: (StickCommand x) async => sent.add(x)); + addTearDown(c.dispose); + + c.setRight(const Offset(0, 1)); + expect(sent, isEmpty, reason: 'nothing goes out before the aircraft confirmed the mode'); + + c.opened(); + c.setRight(const Offset(0, 1)); + expect(sent.single.forward, StickLimits.normal.horizontal); + await tester.pump(const Duration(milliseconds: 350)); + expect(sent.length, 4, reason: 'the 100 ms heartbeat keeps a held stick alive'); + + c.centre(); + expect(sent.last, StickCommand.zero); + final int n = sent.length; + await tester.pump(const Duration(milliseconds: 500)); + expect(sent.length, n, reason: 'centred sticks go quiet; the native loop hovers'); + }); + + test('the aircraft leaving the mode, RTH or landing closes the sticks with a reason', () { + final VirtualStickController c = VirtualStickController(send: (_) async {}); + c.opened(); + c.onAircraft(enabled: false, goingHome: false, landing: false); + expect(c.open, isTrue, reason: 'a stale "off" from before the mode was confirmed is ignored'); + c.onAircraft(enabled: true, goingHome: false, landing: false); + c.onAircraft(enabled: false, goingHome: false, landing: false); + expect(c.open, isFalse); + expect(c.closedReason, 'The remote took back control'); + + c.opened(); + c.onAircraft(enabled: true, goingHome: true, landing: false); + expect(c.closedReason, 'Return to Home took over'); + + c.opened(); + c.close(); + expect(c.closedReason, isNull, reason: 'the pilot closing is not news'); + c.dispose(); + }); + }); + + group('SafetyGate virtual sticks', () { + setUp(() => FlyPrefs.sticksInRealFlight.value = false); + + test('on an aircraft with a remote they are simulator-only by default', () { + final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService()); + expect(gate.checkVirtualSticks().where((GateCheck c) => c.blocks).single.message, contains('simulator')); + + FlyPrefs.sticksInRealFlight.value = true; + final List real = gate.checkVirtualSticks(); + expect(real.any((GateCheck c) => c.blocks), isFalse); + expect(real.map((GateCheck c) => c.message), contains(contains('real flight'))); + }); + + test('in the simulator they are allowed, warning that the remote is ignored', () { + final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()..['simulatorActive'] = true), dji: FakeDjiService()); + final List checks = gate.checkVirtualSticks(); + expect(checks.any((GateCheck c) => c.blocks), isFalse); + expect(checks.map((GateCheck c) => c.message), contains(contains("remote's sticks are ignored"))); + }); + + test('a Wi-Fi aircraft flown from the phone may use them for real', () { + final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick(), profile: sparkWifi), dji: FakeDjiService()); + expect(gate.model.profile.virtualStickPrimary, isTrue); + expect(gate.checkVirtualSticks().any((GateCheck c) => c.blocks), isFalse); + }); + + test('RTH, a running mission and a critical battery refuse them', () { + final FlightModel m = modelWith(telemetryTick(battery: 9) + ..['simulatorActive'] = true + ..['isGoingHome'] = true); + final List blocks = [ + for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkVirtualSticks()) + if (c.blocks) c.message, + ]; + expect(blocks, contains('Cancel Return to Home first.')); + expect(blocks, contains(startsWith('Battery critically low'))); + }); + + test('enable re-checks before switching the mode on; disable always goes', () async { + final FakeDjiService dji = FakeDjiService(); + final SafetyGate blocked = SafetyGate(model: modelWith(telemetryTick()), dji: dji); + await expectLater(blocked.enableVirtualSticks(), throwsA(isA())); + expect(dji.calls, isEmpty); + await blocked.disableVirtualSticks(); + expect(dji.calls.single, 'setVirtualStickEnabled(false)'); + + final SafetyGate ok = SafetyGate(model: modelWith(telemetryTick()..['simulatorActive'] = true), dji: dji); + await ok.enableVirtualSticks(); + await ok.sendSticks(const StickCommand(forward: 2, right: -1, yaw: 30, up: 0.5)); + expect(dji.calls.skip(1), ['setVirtualStickEnabled(true)', 'sendVirtualStick(2.0, -1.0, 30, 0.5)']); + }); + }); + + group('AirSenseState', () { + test('nearest first; the level is the worst of the receiver and any plane', () { + final FlightModel m = modelWith(telemetryTick() + ..['airSense'] = { + 'warningLevel': 1, + 'airplanes': [plane('ABC123', distance: 9000), plane('DEF456', level: 3, distance: 2500, direction: 'SOUTH')], + }); + final AirSenseState s = m.airSense; + expect(s.reported, isTrue); + expect(s.planes.map((AirSensePlane p) => p.code), ['DEF456', 'ABC123']); + expect(s.level, 3); + expect(s.worst!.code, 'DEF456'); + expect(s.worst!.octant, 'S'); + expect(s.threats, hasLength(1)); + }); + + test('absent on aircraft without a receiver, cleared on disconnect', () { + final FlightModel m = modelWith(telemetryTick()); + expect(m.airSense.reported, isFalse); + TelemetryReducer(m).apply(telemetryTick()..['airSense'] = {'warningLevel': 0, 'airplanes': []}); + expect(m.airSense.reported, isTrue); + m.clearTelemetry(); + expect(m.airSense.reported, isFalse); + }); + }); + + group('FlyScreen (P8)', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlyPrefs.sticksInRealFlight.value = false; + FlyPrefs.showRadar.value = true; + }); + + Future pump(WidgetTester tester, FlightModel m) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService dji = FakeDjiService(); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(); + return dji; + } + + testWidgets('the radar draws on sensing aircraft and follows the display switch', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick(noseDistance: 2.0), profile: mavic2Zoom); + await pump(tester, m); + expect(find.byKey(const ValueKey('vision-radar')), findsOneWidget); + FlyPrefs.showRadar.value = false; + await tester.pump(); + expect(find.byKey(const ValueKey('vision-radar')), findsNothing); + }); + + testWidgets('STICKS: slide to hand over, the pads fly, the remote taking over closes them', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick()..['simulatorActive'] = true); + final FakeDjiService dji = await pump(tester, m); + expect(find.byKey(const ValueKey('stick-left')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('rail-sticks'))); + await tester.pumpAndSettle(); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(dji.calls, ['setVirtualStickEnabled(true)']); + expect(find.byKey(const ValueKey('stick-left')), findsOneWidget); + expect(find.byKey(const ValueKey('stick-right')), findsOneWidget); + + // Push the right stick fully forward (Mode 2) and hold. + final TestGesture g = await tester.startGesture(tester.getCenter(find.byKey(const ValueKey('stick-right'))) - const Offset(0, 6)); + await g.moveBy(const Offset(0, -80)); + await tester.pump(const Duration(milliseconds: 120)); + expect(dji.calls.where((String c) => c.startsWith('sendVirtualStick(5.0')), isNotEmpty); + await g.up(); + await tester.pump(); + expect(dji.calls.last, 'sendVirtualStick(0.0, 0.0, 0, 0.0)'); + + // The flight controller confirms, then the remote's mode switch ends it. + TelemetryReducer(m) + ..apply(telemetryTick()..['simulatorActive'] = true..['virtualStick'] = {'enabled': true, 'sending': true}) + ..apply(telemetryTick()..['simulatorActive'] = true..['virtualStick'] = {'enabled': false, 'sending': false}); + await tester.pump(); + expect(find.byKey(const ValueKey('stick-left')), findsNothing); + expect(find.text('On-screen sticks off: The remote took back control'), findsOneWidget); + expect(dji.calls.last, 'setVirtualStickEnabled(false)'); + }); + + testWidgets('an AirSense warning names the plane and opens the list', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick() + ..['airSense'] = { + 'warningLevel': 3, + 'airplanes': [plane('4CA1B2', level: 3, distance: 1800)], + }); + await pump(tester, m); + expect(find.text('Manned aircraft close: descend'), findsOneWidget); + expect(find.text('1.8 km · NE · 4CA1B2'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('banner-airsense'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('airsense-4CA1B2')), findsOneWidget); + }); + }); +}