diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt index 0e60170..212d227 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt @@ -94,6 +94,8 @@ class CalibrationBridge(private val ctx: BridgeCtx) : SubBridge { "stopPropellerCalibration" -> withFc(result) { it.stopPropellerCalibration(ctx.completion(result)) } "startLensCalibration" -> withCamera(result) { it.startCalibration(ctx.completion(result)) } "stopLensCalibration" -> withCamera(result) { it.stopCalibration(ctx.completion(result)) } + // Records the current focus position as infinity (lens focused on a far subject). + "calibrateInfinityFocus" -> withCamera(result) { it.calibratedInfinityFocus(ctx.completion(result)) } "initSensorCleaning" -> withCamera(result) { it.initSensorCleaningMode(ctx.completion(result)) } "startSensorCleaning" -> withCamera(result) { it.startSensorCleaning(ctx.completion(result)) } "exitSensorCleaning" -> withCamera(result) { it.exitSensorCleaningMode(ctx.completion(result)) } 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 9e36b76..ed524f4 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 @@ -115,6 +115,10 @@ class DjiSdkBridge( AirSenseBridge(ctx), FlyZoneBridge(ctx), LiveStreamBridge(ctx), + RemoteControllerBridge(ctx), + VideoCacheBridge(ctx), + HotpointBridge(ctx), + FollowMeBridge(ctx), ) init { diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt index 994fc2e..36d861e 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt @@ -1,6 +1,9 @@ package com.pilotvault.flyapp +import dji.common.flightcontroller.CompassState import dji.common.flightcontroller.FlightControllerState +import dji.common.flightcontroller.FlightOrientationMode +import dji.common.flightcontroller.RTKState import dji.common.flightcontroller.simulator.InitializationData import dji.common.model.LocationCoordinate2D import dji.sdk.base.BaseProduct @@ -93,6 +96,18 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { "setNoviceMode" -> guard(controller, result) { it.setNoviceModeEnabled(call.argument("value") ?: false, ctx.completion(result)) } + // Course Lock / Home Lock (plan P7): AIRCRAFT_HEADING / COURSE_LOCK / HOME_LOCK. + "setFlightOrientationMode" -> guard(controller, result) { + val mode = try { + FlightOrientationMode.valueOf(call.argument("mode") ?: "AIRCRAFT_HEADING") + } catch (_: IllegalArgumentException) { + FlightOrientationMode.AIRCRAFT_HEADING + } + it.setFlightOrientationMode(mode, ctx.completion(result)) + } + "lockCourseUsingCurrentHeading" -> guard(controller, result) { + it.lockCourseUsingCurrentHeading(ctx.completion(result)) + } "setObstacleAvoidance" -> { val assistant = controller?.flightAssistant if (assistant == null) { @@ -118,11 +133,40 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { override fun bind(product: BaseProduct?) { if (product !is Aircraft) return - product.flightController?.setStateCallback { state -> - val values = telemetry(state) + compass(product.flightController) + - ("simulatorActive" to (try { product.flightController?.simulator?.isSimulatorActive } catch (_: Throwable) { null })) + val controller = product.flightController ?: return + controller.setStateCallback { state -> + val values = telemetry(state) + compass(controller) + + ("simulatorActive" to (try { controller.simulator?.isSimulatorActive } catch (_: Throwable) { null })) ctx.mainHandler.post { ctx.hub.putFast(values) } } + // Magnetic interference per compass (P6b's interference meter): the + // sensor value is the field's modulus, graded by the sensor state. + try { + controller.compass?.setCompassStateCallback { s: CompassState? -> + if (s == null) return@setCompassStateCallback + val all = listOf(s) + s.subCompassStates.orEmpty().filterNotNull() + val values = all.map { c -> mapOf("index" to c.index, "state" to c.sensorState?.name, "value" to c.sensorValue) } + ctx.mainHandler.post { ctx.hub.putFast(mapOf("compassSensors" to values)) } + } + } catch (_: Throwable) { + } + // RTK (status-bar chip): only on aircraft that have the module. + try { + if (controller.isRTKSupported) { + controller.rtk?.setStateCallback { s: RTKState? -> + if (s == null) return@setStateCallback + val m = mapOf( + "solution" to s.positioningSolution?.name, + "inUse" to s.isRTKBeingUsed, + "satellites" to s.satelliteCount, + "headingValid" to s.isHeadingValid, + "error" to s.error?.description, + ) + ctx.mainHandler.post { ctx.hub.putSlow("rtk", m) } + } + } + } catch (_: Throwable) { + } } private fun telemetry(state: FlightControllerState): Map { 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 index 9fdd00b..ee20ec0 100644 --- 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 @@ -132,6 +132,7 @@ class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge { "getFlyZoneDatabase" -> { val m = manager() ?: return unavailable(result) val out = LinkedHashMap() + out["preciseRemoteVersion"] = preciseRemote var pending = 4 fun done() { if (--pending == 0) result.success(out) @@ -181,8 +182,23 @@ class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge { } catch (_: Throwable) { // No GEO database on this SDK build. } + // The newest precise database on DJI's server, for the "out of date" hint. + try { + m.setPreciseRemoteDbVersionCallback(object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(v: String?) { + preciseRemote = v + ctx.emit(mapOf("type" to "flyZoneDbRemote", "preciseRemoteVersion" to v)) + } + + override fun onFailure(error: DJIError?) = Unit + }) + } catch (_: Throwable) { + } } + /** Last remote precise-database version the SDK reported. */ + @Volatile private var preciseRemote: String? = null + private fun notification(n: FlySafeNotification?): Map? { if (n == null) return null val base = mapOf("type" to "flySafe", "kind" to n.type?.name) diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FollowMeBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FollowMeBridge.kt new file mode 100644 index 0000000..f723c14 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FollowMeBridge.kt @@ -0,0 +1,96 @@ +package com.pilotvault.flyapp + +import dji.common.error.DJIError +import dji.common.mission.followme.FollowMeHeading +import dji.common.mission.followme.FollowMeMission +import dji.common.mission.followme.FollowMeMissionEvent +import dji.common.model.LocationCoordinate2D +import dji.sdk.base.BaseProduct +import dji.sdk.mission.followme.FollowMeMissionOperator +import dji.sdk.mission.followme.FollowMeMissionOperatorListener +import dji.sdk.sdkmanager.DJISDKManager +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Follow Me (plan P7) → [FollowMeMissionOperator]: the aircraft keeps its + * distance to the phone. Dart streams the phone's fix with + * `updateFollowMeTarget {lat, lon}` (≈ 2 Hz while the mode runs; the + * operator coasts and then stops if the updates dry up). + * + * `startFollowMe {lat, lon, altitude, heading}` (heading + * `TOWARD_FOLLOW_POSITION` or `CONTROLLED_BY_REMOTE_CONTROLLER`), + * `stopFollowMe`. The listener goes out as `mission` events with + * `operator: 'followMe'`: `state`, `distance` (m to the target), `event` + * (`start` / `finish`) and `error`. + */ +class FollowMeBridge(private val ctx: BridgeCtx) : SubBridge { + + private fun op(): FollowMeMissionOperator? = DJISDKManager.getInstance().missionControl?.followMeMissionOperator + + private var listened: FollowMeMissionOperator? = null + + private val listener = object : FollowMeMissionOperatorListener { + override fun onExecutionUpdate(e: FollowMeMissionEvent) = + emit(e.currentState?.name, distance = e.distanceToTarget, error = e.error) + + override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start") + + override fun onExecutionFinish(error: DJIError?) = emit(op()?.currentState?.name, event = "finish", error = error) + } + + private fun emit(state: String?, distance: Float? = null, event: String? = null, error: DJIError? = null) { + val m = linkedMapOf("type" to "mission", "operator" to "followMe") + if (state != null) m["state"] = state + if (distance != null) m["distance"] = distance.toDouble() + if (event != null) m["event"] = event + if (error != null) m["error"] = error.description + ctx.emit(m) + } + + override fun bind(product: BaseProduct?) { + val o = op() ?: return + if (listened !== o) { + listened?.removeListener(listener) + o.addListener(listener) + listened = o + } + emit(o.currentState?.name) + } + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "startFollowMe" -> withOp(result) { o -> + val heading = try { + FollowMeHeading.valueOf(call.argument("heading") ?: "TOWARD_FOLLOW_POSITION") + } catch (_: IllegalArgumentException) { + FollowMeHeading.TOWARD_FOLLOW_POSITION + } + val mission = FollowMeMission( + heading, + call.argument("lat") ?: 0.0, + call.argument("lon") ?: 0.0, + (call.argument("altitude") ?: 10).toFloat(), + ) + o.startMission(mission, ctx.completion(result)) + } + "updateFollowMeTarget" -> withOp(result) { + val lat = call.argument("lat") + val lon = call.argument("lon") + if (lat == null || lon == null || !LocationCoordinate2D.isValid(lat, lon)) { + ctx.fail(result, "Invalid target") + } else { + it.updateFollowingTarget(LocationCoordinate2D(lat, lon), ctx.completion(result)) + } + } + "stopFollowMe" -> withOp(result) { it.stopMission(ctx.completion(result)) } + else -> return false + } + return true + } + + private inline fun withOp(result: MethodChannel.Result, block: (FollowMeMissionOperator) -> Unit) { + val o = op() ?: run { ctx.fail(result, "Follow Me unavailable"); return } + block(o) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/HotpointBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/HotpointBridge.kt new file mode 100644 index 0000000..069e297 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/HotpointBridge.kt @@ -0,0 +1,127 @@ +package com.pilotvault.flyapp + +import dji.common.error.DJIError +import dji.common.mission.hotpoint.HotpointHeading +import dji.common.mission.hotpoint.HotpointMission +import dji.common.mission.hotpoint.HotpointMissionEvent +import dji.common.mission.hotpoint.HotpointStartPoint +import dji.common.model.LocationCoordinate2D +import dji.common.util.CommonCallbacks +import dji.sdk.base.BaseProduct +import dji.sdk.mission.hotpoint.HotpointMissionOperator +import dji.sdk.mission.hotpoint.HotpointMissionOperatorListener +import dji.sdk.sdkmanager.DJISDKManager +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Point of Interest (plan P7) → [HotpointMissionOperator]. + * + * `startHotpoint {lat, lon, altitude, radius, angularVelocity, clockwise, + * startPoint, heading}` checks the mission (`checkParameters`: radius 5–500 m, + * altitude 5–500 m) and starts it; the aircraft flies to the circle first. + * In flight: `pauseHotpoint`, `resumeHotpoint`, `stopHotpoint`, + * `setHotpointRadius {radius}`, `setHotpointAngularVelocity {value}` (°/s, + * signed by direction) and `resetHotpointHeading`. + * `getHotpointMaxAngularVelocity {radius}` answers the fastest °/s the + * aircraft will circle at that radius. + * + * The listener goes out as `mission` events with `operator: 'hotpoint'`: + * `state` (HotpointMissionState), `radius`, `maxAngularVelocity`, `event` + * (`start` / `finish`) and `error`. + */ +class HotpointBridge(private val ctx: BridgeCtx) : SubBridge { + + private fun op(): HotpointMissionOperator? = DJISDKManager.getInstance().missionControl?.hotpointMissionOperator + + private var listened: HotpointMissionOperator? = null + + private val listener = object : HotpointMissionOperatorListener { + override fun onExecutionUpdate(e: HotpointMissionEvent) { + emit(e.currentState?.name, radius = e.radius, maxAngular = e.maxAngularVelocity, error = e.error) + } + + override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start") + + override fun onExecutionFinish(error: DJIError?) = emit(op()?.currentState?.name, event = "finish", error = error) + } + + private fun emit(state: String?, radius: Float? = null, maxAngular: Float? = null, event: String? = null, error: DJIError? = null) { + val m = linkedMapOf("type" to "mission", "operator" to "hotpoint") + if (state != null) m["state"] = state + if (radius != null) m["radius"] = radius.toDouble() + if (maxAngular != null) m["maxAngularVelocity"] = maxAngular.toDouble() + if (event != null) m["event"] = event + if (error != null) m["error"] = error.description + ctx.emit(m) + } + + override fun bind(product: BaseProduct?) { + val o = op() ?: return + if (listened !== o) { + listened?.removeListener(listener) + o.addListener(listener) + listened = o + } + emit(o.currentState?.name) + } + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "startHotpoint" -> withOp(result) { o -> + val mission = HotpointMission( + LocationCoordinate2D(call.argument("lat") ?: 0.0, call.argument("lon") ?: 0.0), + (call.argument("altitude") ?: 30).toDouble(), + (call.argument("radius") ?: 20).toDouble(), + (call.argument("angularVelocity") ?: 10).toFloat(), + call.argument("clockwise") ?: true, + enumArg(call, "startPoint", HotpointStartPoint.NEAREST), + enumArg(call, "heading", HotpointHeading.TOWARDS_HOT_POINT), + ) + mission.checkParameters()?.let { + ctx.fail(result, it.description) + return@withOp + } + o.startMission(mission, ctx.completion(result)) + } + "pauseHotpoint" -> withOp(result) { it.pause(ctx.completion(result)) } + "resumeHotpoint" -> withOp(result) { it.resume(ctx.completion(result)) } + "stopHotpoint" -> withOp(result) { it.stop(ctx.completion(result)) } + "setHotpointRadius" -> withOp(result) { + it.setRadius((call.argument("radius") ?: 20).toFloat(), ctx.completion(result)) + } + "setHotpointAngularVelocity" -> withOp(result) { + it.setAngularVelocity((call.argument("value") ?: 10).toFloat(), ctx.completion(result)) + } + "resetHotpointHeading" -> withOp(result) { it.resetHeading(ctx.completion(result)) } + "getHotpointMaxAngularVelocity" -> HotpointMissionOperator.getMaxAngularVelocityForRadius( + (call.argument("radius") ?: 20).toDouble(), + object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(v: Float?) { + ctx.mainHandler.post { result.success(v?.toDouble()) } + } + + override fun onFailure(e: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } + } + }, + ) + else -> return false + } + return true + } + + private inline fun withOp(result: MethodChannel.Result, block: (HotpointMissionOperator) -> Unit) { + val o = op() ?: run { ctx.fail(result, "Point of Interest unavailable"); return } + block(o) + } + + private inline fun > enumArg(call: MethodCall, name: String, fallback: T): T { + val raw = call.argument(name) ?: return fallback + return try { + java.lang.Enum.valueOf(T::class.java, raw) + } catch (_: IllegalArgumentException) { + fallback + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt index 766ba43..108ee5c 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt @@ -1,6 +1,9 @@ package com.pilotvault.flyapp +import dji.common.battery.WarningRecord import dji.common.camera.ResolutionAndFrameRate +import dji.common.remotecontroller.HardwareState +import dji.common.remotecontroller.Information import dji.common.error.DJIError import dji.common.flightcontroller.LEDsSettings import dji.common.flightcontroller.LocationCoordinate3D @@ -249,6 +252,20 @@ object KeyValues { "statusIndicator" to v.isStatusIndicatorOn(), "beacons" to v.areBeaconsOn(), ) + // Battery history (`HISTORICAL_WARNING_RECORDS`, `LATEST_WARNING_RECORD`). + is WarningRecord -> mapOf( + "error" to v.hasError(), + "overCurrent" to v.isCurrentOverloaded, + "overHeated" to v.isOverHeated, + "lowTemperature" to v.isLowTemperature, + "shortCircuit" to v.isShortCircuited, + "underVoltage" to v.isBatteryUnderVoltage, + "lowVoltageCell" to v.lowVoltageCellIndex, + "damagedCell" to v.damagedCellIndex, + ) + // Master / slave remotes (`SLAVE_LIST`, `AVAILABLE_MASTERS`). + is Information -> mapOf("id" to v.id, "name" to v.name, "signal" to v.signalQuality.toInt()) + is HardwareState.Button -> mapOf("present" to v.isPresent, "clicked" to v.isClicked) else -> v.toString() } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt index d0aa9bb..f3183b1 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt @@ -19,6 +19,7 @@ import dji.common.mission.waypoint.WaypointMissionFinishedAction import dji.common.mission.waypoint.WaypointMissionFlightPathMode import dji.common.mission.waypoint.WaypointMissionGotoWaypointMode import dji.common.mission.waypoint.WaypointMissionHeadingMode +import dji.common.mission.waypoint.WaypointMissionInterruption import dji.common.mission.waypoint.WaypointMissionUploadEvent import dji.common.mission.waypoint.WaypointTurnMode import dji.common.model.LocationCoordinate2D @@ -183,6 +184,7 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { } override fun bind(product: BaseProduct?) { + v2.bind() trackOp()?.let { op -> if (listenedTrack !== op) { listenedTrack?.removeListener(trackListener) @@ -209,20 +211,56 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { when (call.method) { - "uploadWaypointMission" -> uploadWaypoint(call, result) + "uploadWaypointMission" -> if (call.argument("v2") == true) v2.upload(call, result) else { + v2Active = false + uploadWaypoint(call, result) + } "getWaypointMissionState" -> { val op = waypointOp() result.success(op?.currentState?.name) if (op != null) emitWaypoint(state = op.currentState?.name) } - "startWaypointMission" -> waypoint(result) { it.startMission(ctx.completion(result)) } - "stopWaypointMission" -> waypoint(result) { it.stopMission(ctx.completion(result)) } - "pauseWaypointMission" -> waypoint(result) { it.pauseMission(ctx.completion(result)) } - "resumeWaypointMission" -> waypoint(result) { it.resumeMission(ctx.completion(result)) } - "cancelWaypointUpload" -> waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) } - "setWaypointAutoFlightSpeed" -> waypoint(result) { + // A V2 mission (Matrice 300) uploaded last answers these through its own operator. + "startWaypointMission" -> if (v2Active) v2.start(result) else waypoint(result) { it.startMission(ctx.completion(result)) } + "stopWaypointMission" -> if (v2Active) v2.stop(result) else waypoint(result) { it.stopMission(ctx.completion(result)) } + "pauseWaypointMission" -> if (v2Active) v2.pause(result) else waypoint(result) { it.pauseMission(ctx.completion(result)) } + "resumeWaypointMission" -> if (v2Active) v2.resume(result) else waypoint(result) { it.resumeMission(ctx.completion(result)) } + "cancelWaypointUpload" -> if (v2Active) v2.cancelUpload(result) else waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) } + "setWaypointAutoFlightSpeed" -> { val speed = (call.argument("speed") ?: 8).toFloat() - it.setAutoFlightSpeed(speed, ctx.completion(result)) + if (v2Active) v2.setSpeed(speed, result) else waypoint(result) { it.setAutoFlightSpeed(speed, ctx.completion(result)) } + } + // Where the last mission was cut short (RTH, low battery, a crash of the app), + // to resume from there: {missionId, index, lat, lon, altitude} or null. + "getWaypointInterruption" -> waypoint(result) { op -> + op.getPreviousInterruption(object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(i: WaypointMissionInterruption?) { + val m = i?.let { + mapOf( + "missionId" to it.missionID, + "index" to it.index, + "lat" to it.coordinate?.latitude, + "lon" to it.coordinate?.longitude, + "altitude" to it.altitude.toDouble(), + ) + } + ctx.mainHandler.post { result.success(m) } + } + + override fun onFailure(e: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } + } + }) + } + // ActiveTrack Circle's orbit speed, m/s (negative = anticlockwise). + "setActiveTrackCircularSpeed" -> track(result) { + it.setCircularSpeed((call.argument("speed") ?: 5).toFloat(), ctx.completion(result)) + } + "getActiveTrackCircularSpeed" -> track(result) { op -> + op.getCircularSpeed(object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(v: Float?) { ctx.mainHandler.post { result.success(v?.toDouble()) } } + override fun onFailure(e: DJIError) { ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } } + }) } "startActiveTrack" -> startActiveTrack(call, result) "stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) } @@ -404,6 +442,11 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { op.startTracking(mission, ctx.completion(result)) } + /** + * A QuickShot on the subject boxed at `{x, y, w, h}` (0..1 of the live + * view; the centre when omitted). The operator reports it as + * PERFORMING_QUICK_SHOT with `tracking.percent` until the aircraft is back. + */ private fun startQuickShot(call: MethodCall, result: MethodChannel.Result) { val op = trackOp() ?: run { ctx.fail(result, "QuickShots unavailable"); return } val name = call.argument("name")?.uppercase() ?: "" @@ -413,9 +456,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { ctx.fail(result, "$name is not supported on this aircraft") return } - val mission = ActiveTrackMission(RectF(0.4f, 0.35f, 0.6f, 0.65f), ActiveTrackMode.QUICK_SHOT) + val x = (call.argument("x") ?: 0.4).toFloat() + val y = (call.argument("y") ?: 0.35).toFloat() + val w = (call.argument("w") ?: 0.2).toFloat() + val h = (call.argument("h") ?: 0.3).toFloat() + val mission = ActiveTrackMission(RectF(x, y, x + w, y + h), ActiveTrackMode.QUICK_SHOT) mission.quickShotMode = quick - ctx.emit(mapOf("type" to "mission", "state" to "quickshot")) + op.canStartTracking(mission)?.let { + ctx.fail(result, it.description) + return + } op.startTracking(mission, ctx.completion(result)) } + + // ── Waypoints 2.0 (Matrice 300; plan P7) ───────────────────────────────────── + + @Volatile private var v2Active = false + private val v2 = WaypointV2(ctx) { v2Active = it } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt index 5b1f0f6..d7f8597 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt @@ -3,6 +3,7 @@ package com.pilotvault.flyapp import dji.common.gimbal.CapabilityKey import dji.common.util.DJIParamMinMaxCapability import dji.keysdk.CameraKey +import dji.keysdk.FlightControllerKey import dji.keysdk.KeyManager import dji.sdk.base.BaseProduct import dji.sdk.camera.Camera @@ -107,6 +108,10 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge { "accessLockerSupported" to probe { fc.isAccessLockerSupported }, "onboardSdkAvailable" to probe { fc.isOnboardSDKDeviceAvailable }, "simulatorAvailable" to (probe { fc.simulator } != null), + // From the key cache: null until the FC has answered it once. + "precisionTakeOffSupported" to probe { + KeyManager.getInstance()?.getValue(FlightControllerKey.create(FlightControllerKey.IS_PRECISION_TAKE_OFF_SUPPORTED)) as? Boolean + }, ) }, "remoteController" to aircraft?.remoteController?.let { rc -> diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/RemoteControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/RemoteControllerBridge.kt new file mode 100644 index 0000000..2120096 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/RemoteControllerBridge.kt @@ -0,0 +1,191 @@ +package com.pilotvault.flyapp + +import dji.common.airlink.ChannelInterference +import dji.common.airlink.FrequencyInterference +import dji.common.airlink.WifiChannelInterference +import dji.common.error.DJIError +import dji.common.remotecontroller.ConnectToMasterResult +import dji.common.remotecontroller.Credentials +import dji.common.remotecontroller.HardwareState +import dji.common.remotecontroller.Information +import dji.common.util.CommonCallbacks +import dji.keysdk.RemoteControllerKey +import dji.sdk.base.BaseProduct +import dji.sdk.products.Aircraft +import dji.sdk.remotecontroller.RemoteController +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * The remote controller's extras (plan P6) that are not plain key values: + * + * - **C1 / C2**: the consumer remotes only report the presses + * (`CUSTOM_BUTTON_1/2`); what they do is the app's, as in GO 4. Each press + * goes out as `{type: "rcButton", button: "C1" | "C2"}`. KeyManager listeners + * stack, so this never steals the hardware-state callback the RC + * calibration wizard uses. + * - **Master / slave** (dual operator): `searchMasters {on}` publishes the + * remotes in range, as the search finds them, as `rcMasters` events + * `{masters: [{id, name, signal}]}`; + * `connectToMaster {id, name, password}` answers the SDK's result name. + * - **Spectrum** for the video-link tab: `watchSpectrum {on}` registers the + * link's interference callback and publishes `spectrum` events, `{link, + * bands: [{from, to, rssi}]}` on OcuSync or `{link, channels: [{channel, + * power}]}` on Lightbridge / Wi-Fi. + * - **Wi-Fi credentials**: `setWifiCredentials {ssid?, password?}` then + * `rebootWifi` (the link only takes the new ones after a restart). + */ +class RemoteControllerBridge(private val ctx: BridgeCtx) : SubBridge { + + private val buttons = KeyWatch(ctx) + + /** Last press state per button, so one click is one event. */ + private val down = HashMap() + + private var spectrumOn = false + + private fun rc(): RemoteController? = ctx.aircraft()?.remoteController + + override fun bind(product: BaseProduct?) { + buttons.clear() + down.clear() + if (product !is Aircraft || product.remoteController == null) return + watchButton("C1", RemoteControllerKey.CUSTOM_BUTTON_1) + watchButton("C2", RemoteControllerKey.CUSTOM_BUTTON_2) + if (spectrumOn) setSpectrum(product, true) + } + + override fun reset() { + buttons.clear() + down.clear() + } + + private fun watchButton(name: String, key: String) { + buttons.watch(RemoteControllerKey.create(key)) { v -> + val b = v as? HardwareState.Button ?: return@watch + val pressed = b.isClicked + val was = down[name] ?: false + down[name] = pressed + // The rising edge only: holding the button is one press. + if (pressed && !was) ctx.emit(mapOf("type" to "rcButton", "button" to name)) + } + } + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "searchMasters" -> { + val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true } + if (call.argument("on") == true) { + var answered = false + r.startMasterSearching(object : RemoteController.MasterSearchingCallback { + override fun onResult(e: DJIError?) { + ctx.mainHandler.post { + if (answered) return@post + answered = true + if (e == null) result.success(null) else result.error("DJI_ERROR", e.description, null) + } + } + + override fun onMasterListUpdate(list: Array) { + ctx.emit(mapOf("type" to "rcMasters", "masters" to list.map(::info))) + } + }) + } else { + r.stopMasterSearching(ctx.completion(result)) + } + } + "getSlaves" -> { + val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true } + r.getSlaveList(object : CommonCallbacks.CompletionCallbackWith> { + override fun onSuccess(list: Array?) { + ctx.mainHandler.post { result.success(list.orEmpty().map(::info)) } + } + + override fun onFailure(e: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } + } + }) + } + "connectToMaster" -> { + val r = rc() ?: run { ctx.fail(result, "No remote controller"); return true } + val creds = Credentials( + call.argument("id") ?: 0, + call.argument("name") ?: "", + call.argument("password") ?: "", + ) + r.connectToMaster(creds, object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(v: ConnectToMasterResult?) { + ctx.mainHandler.post { result.success(v?.name ?: "UNKNOWN") } + } + + override fun onFailure(e: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } + } + }) + } + "watchSpectrum" -> { + spectrumOn = call.argument("on") == true + setSpectrum(ctx.product(), spectrumOn) + result.success(null) + } + "setWifiCredentials" -> { + val wifi = ctx.product()?.airLink?.wiFiLink ?: run { ctx.fail(result, "No Wi-Fi link"); return true } + val ssid = call.argument("ssid") + val password = call.argument("password") + val done = ctx.completion(result) + // SSID first, then the password: each is its own write on the link. + val setPassword = { + if (password == null) done.onResult(null) else wifi.setPassword(password, done) + } + if (ssid == null) { + setPassword() + } else { + wifi.setSSID(ssid) { e: DJIError? -> if (e != null) done.onResult(e) else setPassword() } + } + } + "rebootWifi" -> { + val wifi = ctx.product()?.airLink?.wiFiLink ?: run { ctx.fail(result, "No Wi-Fi link"); return true } + wifi.reboot(ctx.completion(result)) + } + else -> return false + } + return true + } + + private fun info(i: Information?): Map = + mapOf("id" to i?.id, "name" to i?.name, "signal" to i?.signalQuality?.toInt(), "gimbalControl" to i?.hasGimbalControlPermission()) + + private fun setSpectrum(product: BaseProduct?, on: Boolean) { + val link = product?.airLink ?: return + try { + link.ocuSyncLink?.setChannelInterferenceCallback( + if (!on) null else dji.sdk.airlink.OcuSyncLink.ChannelInterferenceCallback { bands: Array? -> + ctx.emit(mapOf("type" to "spectrum", "link" to "ocusync", "bands" to bands.orEmpty().map { b -> + mapOf("from" to b.frequencyFrom.toDouble(), "to" to b.frequencyTo.toDouble(), "rssi" to b.rssi.toInt()) + })) + }, + ) + } catch (_: Throwable) { + } + try { + link.lightbridgeLink?.setChannelInterferenceCallback( + if (!on) null else dji.sdk.airlink.LightbridgeLink.ChannelInterferenceCallback { channels: Array? -> + ctx.emit(mapOf("type" to "spectrum", "link" to "lightbridge", "channels" to channels.orEmpty().map { c -> + mapOf("channel" to c.channel, "power" to c.power) + })) + }, + ) + } catch (_: Throwable) { + } + try { + link.wiFiLink?.setChannelInterferenceCallback( + if (!on) null else dji.sdk.airlink.WiFiLink.ChannelInterferenceCallback { channels: Array? -> + ctx.emit(mapOf("type" to "spectrum", "link" to "wifi", "channels" to channels.orEmpty().map { c -> + mapOf("channel" to c.channel, "power" to c.power, "band" to c.band?.name) + })) + }, + ) + } catch (_: Throwable) { + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VideoCacheBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VideoCacheBridge.kt new file mode 100644 index 0000000..73f6b84 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/VideoCacheBridge.kt @@ -0,0 +1,119 @@ +package com.pilotvault.flyapp + +import dji.sdk.camera.VideoFeeder +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors + +/** + * GO 4's video cache (plan P6): the live feed recorded to the phone while the + * aircraft flies, so there is footage even when the SD card fails. + * + * The primary feed's raw H.264 elementary stream (what the decoder is fed) is + * written as is to `files/video_cache/.h264` — playable in + * VLC / ffmpeg, no transcoding on the phone. Dart decides when to record + * (`setVideoCacheRecording {on, limitMb}`, on take-off and landing while the + * pref is on); the oldest files are deleted once the folder outgrows the cap. + * `getVideoCacheInfo` answers `{bytes, files, recording}`, `clearVideoCache` + * empties it (not while recording). + */ +class VideoCacheBridge(private val ctx: BridgeCtx) : SubBridge { + + private val io = Executors.newSingleThreadExecutor() + private var out: BufferedOutputStream? = null + private var limitBytes = 2048L shl 20 + private var written = 0L + + private val dir: File get() = File(ctx.appContext.filesDir, "video_cache").apply { mkdirs() } + + private val listener = VideoFeeder.VideoDataListener { data: ByteArray?, size: Int -> + if (data == null || size <= 0) return@VideoDataListener + val copy = data.copyOf(size) + io.execute { + val o = out ?: return@execute + try { + o.write(copy) + written += copy.size + // Re-check the cap every ~8 MB rather than per packet. + if (written > 8L shl 20) { + written = 0 + trim() + } + } catch (_: Throwable) { + stopOnIo() + } + } + } + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "setVideoCacheRecording" -> { + val on = call.argument("on") == true + call.argument("limitMb")?.let { limitBytes = it.toLong() shl 20 } + if (on) start() else stop() + result.success(null) + } + "getVideoCacheInfo" -> io.execute { + val files = dir.listFiles().orEmpty().filter { it.isFile } + val info = mapOf("bytes" to files.sumOf { it.length() }, "files" to files.size, "recording" to (out != null)) + ctx.mainHandler.post { result.success(info) } + } + "clearVideoCache" -> io.execute { + val current = out != null + if (!current) dir.listFiles().orEmpty().forEach { it.delete() } + ctx.mainHandler.post { + if (current) result.error("BUSY", "Recording to the cache: land first", null) else result.success(null) + } + } + else -> return false + } + return true + } + + override fun reset() = stop() + + private fun start() { + val feed = VideoFeeder.getInstance()?.primaryVideoFeed ?: return + io.execute { + if (out != null) return@execute + val name = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".h264" + out = BufferedOutputStream(FileOutputStream(File(dir, name)), 1 shl 16) + trim() + ctx.mainHandler.post { feed.addVideoDataListener(listener) } + } + } + + private fun stop() { + try { + VideoFeeder.getInstance()?.primaryVideoFeed?.removeVideoDataListener(listener) + } catch (_: Throwable) { + } + io.execute { stopOnIo() } + } + + private fun stopOnIo() { + try { + out?.close() + } catch (_: Throwable) { + } + out = null + } + + /** Deletes the oldest recordings until the folder fits under the cap. */ + private fun trim() { + val files = dir.listFiles().orEmpty().filter { it.isFile }.sortedBy { it.lastModified() }.toMutableList() + var total = files.sumOf { it.length() } + // Never the newest: that is the one being written. + while (total > limitBytes && files.size > 1) { + val f = files.removeAt(0) + total -= f.length() + f.delete() + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/WaypointV2.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/WaypointV2.kt new file mode 100644 index 0000000..8909468 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/WaypointV2.kt @@ -0,0 +1,174 @@ +package com.pilotvault.flyapp + +import dji.common.error.DJIWaypointV2Error +import dji.common.mission.waypointv2.WaypointV2 +import dji.common.mission.waypointv2.WaypointV2Mission +import dji.common.mission.waypointv2.WaypointV2MissionDownloadEvent +import dji.common.mission.waypointv2.WaypointV2MissionExecutionEvent +import dji.common.mission.waypointv2.WaypointV2MissionTypes +import dji.common.mission.waypointv2.WaypointV2MissionUploadEvent +import dji.common.model.LocationCoordinate2D +import dji.common.util.CommonCallbacks +import dji.sdk.mission.waypoint.WaypointV2MissionOperator +import dji.sdk.mission.waypoint.WaypointV2MissionOperatorListener +import dji.sdk.sdkmanager.DJISDKManager +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Waypoints 2.0 for [MissionBridge] (Matrice 300, the only aircraft whose + * flight controller runs V2): the same resolved mission map the V1 upload + * takes (`WaypointMissionSpec.toBridge`), built into a [WaypointV2Mission]. + * + * Per waypoint: position, altitude, a custom heading when the map has one, + * its own speed when > 0, the turn direction and the corner radius as the + * damping distance. Camera actions are not carried over — V2 actions are a + * separate trigger/actuator list — and the Dart gate warns about that. + * + * Progress goes out as the V1 events do (`mission`, `operator: 'waypoint'`), + * so the run panel reads both; V2's `INTERRUPTED` is its paused state. + * Pause / resume are V2's interrupt / recover. + */ +class WaypointV2(private val ctx: BridgeCtx, private val setActive: (Boolean) -> Unit) { + + private fun op(): WaypointV2MissionOperator? = + try { + DJISDKManager.getInstance().missionControl?.waypointMissionV2Operator + } catch (_: Throwable) { + null + } + + private var listened: WaypointV2MissionOperator? = null + + private val listener = object : WaypointV2MissionOperatorListener { + override fun onDownloadUpdate(e: WaypointV2MissionDownloadEvent) {} + + override fun onUploadUpdate(e: WaypointV2MissionUploadEvent) { + val p = e.progress + emit( + e.currentState?.name, + upload = p?.let { mapOf("index" to it.lastUploadedWaypointIndex, "total" to it.totalWaypointCount, "summary" to it.isSummaryUploaded) }, + error = e.error, + ) + } + + override fun onExecutionUpdate(e: WaypointV2MissionExecutionEvent) { + val p = e.progress + emit( + e.currentState?.name, + execution = p?.let { + mapOf("target" to it.targetWaypointIndex, "reached" to it.isWaypointReached, "executeState" to it.executeState?.name) + }, + error = e.error, + ) + } + + override fun onExecutionStart() = emit(op()?.currentState?.name, event = "start") + + override fun onExecutionFinish(error: DJIWaypointV2Error?) = emit(op()?.currentState?.name, event = "finish", error = error) + + override fun onExecutionStopped() = emit(op()?.currentState?.name, event = "finish") + } + + private fun emit( + state: String?, + upload: Map? = null, + execution: Map? = null, + event: String? = null, + error: DJIWaypointV2Error? = null, + ) { + val m = linkedMapOf("type" to "mission", "operator" to "waypoint", "v2" to true) + // V2 calls a paused mission INTERRUPTED; the Dart side speaks V1. + if (state != null) m["state"] = if (state == "INTERRUPTED") "EXECUTION_PAUSED" else state + if (upload != null) m["upload"] = upload + if (execution != null) m["execution"] = execution + if (event != null) m["event"] = event + if (error != null) m["error"] = error.description + ctx.emit(m) + } + + fun bind() { + val o = op() ?: return + if (listened !== o) { + listened?.removeWaypointListener(listener) + o.addWaypointEventListener(listener) + listened = o + } + } + + private fun done(result: MethodChannel.Result) = CommonCallbacks.CompletionCallback { e -> + ctx.mainHandler.post { if (e == null) result.success(null) else result.error("DJI_ERROR", e.description, null) } + } + + fun upload(call: MethodCall, result: MethodChannel.Result) { + val o = op() ?: run { ctx.fail(result, "Waypoints 2.0 unavailable"); return } + val raw = call.argument>>("waypoints") ?: emptyList() + if (raw.size < 2) { + ctx.fail(result, "A route needs at least 2 waypoints") + return + } + val curved = call.argument("flightPathMode") == "CURVED" + val points = raw.map { p -> + val b = WaypointV2.Builder() + .setCoordinate(LocationCoordinate2D((p["lat"] as Number).toDouble(), (p["lon"] as Number).toDouble())) + .setAltitude((p["altitude"] as? Number)?.toDouble() ?: 50.0) + .setFlightPathMode( + if (curved) WaypointV2MissionTypes.WaypointV2FlightPathMode.CURVATURE_CONTINUOUS_PASSED + else WaypointV2MissionTypes.WaypointV2FlightPathMode.GOTO_POINT_STRAIGHT_LINE_AND_STOP, + ) + .setTurnMode( + if (p["turnMode"] == "COUNTER_CLOCKWISE") WaypointV2MissionTypes.WaypointV2TurnMode.COUNTER_CLOCKWISE + else WaypointV2MissionTypes.WaypointV2TurnMode.CLOCKWISE, + ) + val heading = (p["heading"] as? Number)?.toFloat() + if (heading != null) { + b.setHeadingMode(WaypointV2MissionTypes.WaypointV2HeadingMode.WAYPOINT_CUSTOM).setHeading(heading) + } else { + b.setHeadingMode(WaypointV2MissionTypes.WaypointV2HeadingMode.AUTO) + } + (p["speed"] as? Number)?.toFloat()?.takeIf { it > 0 }?.let { s -> + b.setUsingWaypointAutoFlightSpeed(true).setAutoFlightSpeed(s) + } + (p["cornerRadius"] as? Number)?.toFloat()?.let { b.setDampingDistance(it) } + b.build() + } + val finished = when (call.argument("finishedAction")) { + "GO_HOME" -> WaypointV2MissionTypes.MissionFinishedAction.GO_HOME + "AUTO_LAND" -> WaypointV2MissionTypes.MissionFinishedAction.AUTO_LAND + "GO_FIRST_WAYPOINT" -> WaypointV2MissionTypes.MissionFinishedAction.GO_FIRST_WAYPOINT + "CONTINUE_UNTIL_END" -> WaypointV2MissionTypes.MissionFinishedAction.CONTINUE_UNTIL_STOP + else -> WaypointV2MissionTypes.MissionFinishedAction.NO_ACTION + } + val builder = WaypointV2Mission.Builder() + .setMissionID(System.currentTimeMillis() and 0x7FFFFFFF) + .setAutoFlightSpeed((call.argument("autoFlightSpeed") ?: 8).toFloat()) + .setMaxFlightSpeed((call.argument("maxFlightSpeed") ?: 15).toFloat()) + .setFinishedAction(finished) + .setGotoFirstWaypointMode( + if (call.argument("gotoFirstWaypointMode") == "POINT_TO_POINT") WaypointV2MissionTypes.MissionGotoWaypointMode.POINT_TO_POINT + else WaypointV2MissionTypes.MissionGotoWaypointMode.SAFELY, + ) + .setExitMissionOnRCSignalLostEnabled(call.argument("exitMissionOnRCSignalLost") ?: true) + .setRepeatTimes(call.argument("repeatTimes")?.toInt() ?: 1) + .addwaypoints(points) + call.argument>("pointOfInterest")?.let { poi -> + builder.setPointOfInterest(LocationCoordinate2D((poi["lat"] as Number).toDouble(), (poi["lon"] as Number).toDouble())) + } + setActive(true) + o.loadMission(builder.build()) { e: DJIWaypointV2Error? -> + if (e != null) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.description, null) } + } else { + o.uploadMission(done(result)) + } + } + } + + fun start(result: MethodChannel.Result) = op()?.startMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") + fun stop(result: MethodChannel.Result) = op()?.stopMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") + fun pause(result: MethodChannel.Result) = op()?.interruptMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") + fun resume(result: MethodChannel.Result) = op()?.recoverMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") + fun cancelUpload(result: MethodChannel.Result) = op()?.cancelUploadingMission(done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") + fun setSpeed(speed: Float, result: MethodChannel.Result) = + op()?.setAutoFlightSpeed(speed, done(result)) ?: ctx.fail(result, "Waypoints 2.0 unavailable") +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index b7c4e99..5cc4d92 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -12,7 +12,8 @@ import 'package:flutter/services.dart'; /// carries maps keyed by `type`: `registration`, `connection`, `identity`, /// `telemetry` (the batched snapshot — see `TelemetryReducer`), `camera`, /// `exposure`, `histogram`, `diagnostics`, `mission`, `key`, `mediaList`, `mediaDownload`, -/// `djiAccount`, `productProfile`, `database`, `init`. +/// `djiAccount`, `productProfile`, `database`, `init`, `rcButton`, `rcMasters`, +/// `spectrum`, `flyZoneDbRemote`. class DjiService { static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); static const EventChannel _events = EventChannel('dji_msdk/events'); @@ -266,8 +267,11 @@ class DjiService { /// Loads and uploads a waypoint mission — `WaypointMissionSpec.toBridge()`'s /// map (see `MissionBridge.kt`). Resolves once every waypoint is on the /// aircraft; progress arrives as `mission` events with `operator: 'waypoint'`. - Future uploadWaypointMission(Map mission) => - _methods.invokeMethod('uploadWaypointMission', mission); + /// + /// [v2] sends it to the Waypoints 2.0 operator (Matrice 300) instead; the + /// start / pause / resume / stop below then address that one. + Future uploadWaypointMission(Map mission, {bool v2 = false}) => + _methods.invokeMethod('uploadWaypointMission', {...mission, if (v2) 'v2': true}); Future cancelWaypointUpload() => _methods.invokeMethod('cancelWaypointUpload'); /// The operator's `WaypointMissionState` name; also re-published as an event. @@ -405,11 +409,125 @@ class DjiService { 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. - Future startQuickShot(String name) => - _methods.invokeMethod('startQuickShot', {'name': name}); + /// A QuickShot by `QuickShotMode` name (DRONIE / ROCKET / CIRCLE / HELIX / + /// BOOMERANG / ASTEROID / DOLLY_ZOOM) on the subject boxed at [x], [y], [w], + /// [h] (0..1 of the live view); an aircraft without that shot answers a + /// typed error. + Future startQuickShot(String name, {double? x, double? y, double? w, double? h}) => + _methods.invokeMethod('startQuickShot', { + 'name': name, + if (x != null) 'x': x, + if (y != null) 'y': y, + if (w != null) 'w': w, + if (h != null) 'h': h, + }); + + /// ActiveTrack Circle's orbit speed (m/s, negative anticlockwise). + Future setActiveTrackCircularSpeed(double speed) => + _methods.invokeMethod('setActiveTrackCircularSpeed', {'speed': speed}); + Future getActiveTrackCircularSpeed() => _methods.invokeMethod('getActiveTrackCircularSpeed'); + + /// Where the last waypoint mission was cut short: `{missionId, index, lat, + /// lon, altitude}`, or null when it was not. + Future?> getWaypointInterruption() async { + final dynamic m = await _methods.invokeMethod('getWaypointInterruption'); + return m == null ? null : Map.from(m as Map); + } + + // ── Point of Interest (plan P7, `HotpointBridge.kt`) ──────────────────────── + /// Circles [lat]/[lon] at [radius] m and [altitude] m; [angularVelocity] °/s. + /// startPoint: NORTH / SOUTH / WEST / EAST / NEAREST; heading: + /// TOWARDS_HOT_POINT / AWAY_FROM_HOT_POINT / ALONG_CIRCLE_LOOKING_FORWARDS / + /// ALONG_CIRCLE_LOOKING_BACKWARDS / CONTROLLED_BY_REMOTE_CONTROLLER / USING_INITIAL_HEADING. + Future startHotpoint({ + required double lat, + required double lon, + required double altitude, + required double radius, + required double angularVelocity, + required bool clockwise, + String startPoint = 'NEAREST', + String heading = 'TOWARDS_HOT_POINT', + }) => + _methods.invokeMethod('startHotpoint', { + 'lat': lat, + 'lon': lon, + 'altitude': altitude, + 'radius': radius, + 'angularVelocity': angularVelocity, + 'clockwise': clockwise, + 'startPoint': startPoint, + 'heading': heading, + }); + Future pauseHotpoint() => _methods.invokeMethod('pauseHotpoint'); + Future resumeHotpoint() => _methods.invokeMethod('resumeHotpoint'); + Future stopHotpoint() => _methods.invokeMethod('stopHotpoint'); + Future setHotpointRadius(double radius) => + _methods.invokeMethod('setHotpointRadius', {'radius': radius}); + + /// °/s; the sign is the direction (positive clockwise). + Future setHotpointAngularVelocity(double value) => + _methods.invokeMethod('setHotpointAngularVelocity', {'value': value}); + Future resetHotpointHeading() => _methods.invokeMethod('resetHotpointHeading'); + + /// The fastest the aircraft circles at [radius] m, °/s. + Future getHotpointMaxAngularVelocity(double radius) => + _methods.invokeMethod('getHotpointMaxAngularVelocity', {'radius': radius}); + + // ── Follow Me (plan P7, `FollowMeBridge.kt`) ──────────────────────────────── + /// heading: TOWARD_FOLLOW_POSITION | CONTROLLED_BY_REMOTE_CONTROLLER. + Future startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) => + _methods.invokeMethod('startFollowMe', {'lat': lat, 'lon': lon, 'altitude': altitude, 'heading': heading}); + Future updateFollowMeTarget(double lat, double lon) => + _methods.invokeMethod('updateFollowMeTarget', {'lat': lat, 'lon': lon}); + Future stopFollowMe() => _methods.invokeMethod('stopFollowMe'); + + // ── Orientation modes (plan P7) ───────────────────────────────────────────── + /// mode: AIRCRAFT_HEADING | COURSE_LOCK | HOME_LOCK. + Future setFlightOrientationMode(String mode) => + _methods.invokeMethod('setFlightOrientationMode', {'mode': mode}); + + /// Course Lock's "forward" becomes the aircraft's current heading. + Future lockCourseUsingCurrentHeading() => _methods.invokeMethod('lockCourseUsingCurrentHeading'); + + // ── Remote controller extras (plan P6, `RemoteControllerBridge.kt`) ───────── + /// Looks for master remotes in range; they arrive as `rcMasters` events. + Future searchMasters(bool on) => _methods.invokeMethod('searchMasters', {'on': on}); + + /// Joins a master remote; answers `ConnectToMasterResult` (ACCEPTED, PASSWORD_ERROR, …). + Future connectToMaster({required int id, required String name, required String password}) => + _methods.invokeMethod('connectToMaster', {'id': id, 'name': name, 'password': password}); + + /// The slaves linked to this (master) remote: `[{id, name, signal}]`. + Future>> getSlaves() async { + final List? l = await _methods.invokeMethod>('getSlaves'); + return >[for (final Object? s in l ?? const []) if (s is Map) Map.from(s)]; + } + + /// The video link's interference while on, as `spectrum` events. + Future watchSpectrum(bool on) => _methods.invokeMethod('watchSpectrum', {'on': on}); + + /// The Wi-Fi link's network name and/or password; [rebootWifi] applies them. + Future setWifiCredentials({String? ssid, String? password}) => _methods.invokeMethod('setWifiCredentials', { + if (ssid != null) 'ssid': ssid, + if (password != null) 'password': password, + }); + Future rebootWifi() => _methods.invokeMethod('rebootWifi'); + + // ── Video cache (plan P6, `VideoCacheBridge.kt`) ──────────────────────────── + Future setVideoCacheRecording(bool on, {int? limitMb}) => + _methods.invokeMethod('setVideoCacheRecording', {'on': on, if (limitMb != null) 'limitMb': limitMb}); + + /// `{bytes, files, recording}`. + Future> getVideoCacheInfo() async { + final dynamic m = await _methods.invokeMethod('getVideoCacheInfo'); + return Map.from(m as Map); + } + + Future clearVideoCache() => _methods.invokeMethod('clearVideoCache'); + + /// Records the lens's current focus as infinity (focus on a far subject first). + Future calibrateInfinityFocus() => _methods.invokeMethod('calibrateInfinityFocus'); // ── Media (MediaManager) ──────────────────────────────────────────────────── Future refreshMediaList() => _methods.invokeMethod('refreshMediaList'); diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 2c63dd0..722f6f5 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -12,6 +12,7 @@ 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_extras.dart'; import 'fly/state/link_state.dart'; import 'fly/state/live_stream_state.dart'; import 'fly/state/mission_state.dart'; @@ -177,6 +178,15 @@ class FlightModel extends ChangeNotifier { /// native view letterboxes to it, and the touch layers map taps into it. final ValueNotifier videoSize = ValueNotifier(null); + /// The remote's latest C1 / C2 press (plan P6); the flight screen acts on it. + final ValueNotifier rcButton = ValueNotifier(null); + + /// Master remotes in range while the dual-operator search runs. + final ValueNotifier> rcMasters = ValueNotifier>(const []); + + /// The video link's interference while the spectrum chart watches it. + final ValueNotifier spectrum = ValueNotifier(null); + late final List _subs = [ aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, liveStream, // ]; @@ -306,6 +316,9 @@ class FlightModel extends ChangeNotifier { } videoSize.dispose(); mediaPlayback.dispose(); + rcButton.dispose(); + rcMasters.dispose(); + spectrum.dispose(); super.dispose(); } } diff --git a/Fly App/lib/fly/action_rail_state.dart b/Fly App/lib/fly/action_rail_state.dart index 54234ec..cae38a7 100644 --- a/Fly App/lib/fly/action_rail_state.dart +++ b/Fly App/lib/fly/action_rail_state.dart @@ -7,7 +7,7 @@ enum FlightCommand { takeOff, land, cancelLanding, confirmLanding, goHome, cance /// What is running when the rail offers [FlightCommand.stopMode], i.e. which /// operator "Stop" has to address. -enum ActiveMode { none, waypoint, activeTrack, tapFly, other } +enum ActiveMode { none, waypoint, activeTrack, tapFly, hotpoint, followMe, other } /// The left rail's slots for the current flight state (plan P3, GO 4's /// `fpv_leftmenu`): one primary button that walks Take-off → Land → Cancel @@ -36,7 +36,7 @@ class RailState { /// Flight-controller modes in which an intelligent mode is flying the aircraft. static const Set _waypointModes = {'GPS_WAYPOINT'}; static const Set _trackModes = {'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'}; - static const Set _otherModes = {'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'DRAW', 'TERRAIN_FOLLOW'}; + static const Set _otherModes = {'DRAW', 'TERRAIN_FOLLOW'}; static RailState of({required bool connected, required AircraftState a, MissionState? mission}) { final bool reported = connected && a.updatedAt != null; @@ -74,11 +74,17 @@ class RailState { return ActiveMode.activeTrack; case 'tapfly': return ActiveMode.tapFly; + case 'hotpoint': + return ActiveMode.hotpoint; + case 'followme': + return ActiveMode.followMe; } final String mode = a.flightModeEnum ?? ''; if (_waypointModes.contains(mode)) return ActiveMode.waypoint; if (_trackModes.contains(mode)) return ActiveMode.activeTrack; if (mode == 'TAP_FLY') return ActiveMode.tapFly; + if (mode == 'GPS_HOT_POINT') return ActiveMode.hotpoint; + if (mode == 'GPS_FOLLOW_ME') return ActiveMode.followMe; if (_otherModes.contains(mode)) return ActiveMode.other; return ActiveMode.none; } diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 16bad66..c4dca24 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -1,8 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'rc_buttons.dart'; +import 'units.dart'; import 'virtual_sticks.dart' show StickMode; +export 'rc_buttons.dart' show RcButton, RcButtonAction; +export 'units.dart' show UnitSystem; + /// The video overlay grid (camera menu → Other, and the rail's GRID button). enum GridStyle { off('Off'), @@ -39,6 +44,13 @@ abstract final class FlyPrefs { static const String _autoUploadKey = 'fly.autoUploadFlights'; static const String _liveUrlKey = 'fly.liveStreamUrl'; static const String _liveAudioKey = 'fly.liveStreamAudio'; + static const String _unitsKey = 'fly.units'; + static const String _c1Key = 'fly.rcC1'; + static const String _c2Key = 'fly.rcC2'; + static const String _videoCacheKey = 'fly.videoCache'; + static const String _videoCacheLimitKey = 'fly.videoCacheLimitMb'; + static const String _lensCalibratedKey = 'fly.lensCalibrated'; + static const String _tutorialsKey = 'fly.modeTutorials'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept /// reachable until P3's safety flows pass their simulator run (plan P2/P3). @@ -77,6 +89,26 @@ abstract final class FlyPrefs { /// Send the phone's microphone with the live stream. static final ValueNotifier liveStreamAudio = ValueNotifier(false); + /// Metric or imperial readouts on the HUD and in settings (plan P6). + static final ValueNotifier units = ValueNotifier(UnitSystem.metric); + + /// What the remote's C1 / C2 buttons do (plan P6); GO 4's defaults. + static final ValueNotifier c1 = ValueNotifier(RcButtonAction.gimbalRecenter); + static final ValueNotifier c2 = ValueNotifier(RcButtonAction.gimbalToggleDown); + + /// Record the live feed to the phone while flying (GO 4's video cache, plan P6). + static final ValueNotifier videoCache = ValueNotifier(false); + + /// The cache's size cap; the oldest recordings go first. + static final ValueNotifier videoCacheLimitMb = ValueNotifier(2048); + + /// Cameras (by display name) whose manual-focus lens was calibrated here, so + /// the first-MF prompt is not repeated (plan P6b). + static final ValueNotifier> lensCalibrated = ValueNotifier>({}); + + /// Intelligent modes whose tutorial the pilot has seen (plan P7), by name. + static final ValueNotifier> seenTutorials = ValueNotifier>({}); + static SharedPreferences? _prefs; static Future load() async { @@ -95,6 +127,13 @@ abstract final class FlyPrefs { autoUploadFlights.value = _prefs!.getBool(_autoUploadKey) ?? true; liveStreamUrl.value = _prefs!.getString(_liveUrlKey) ?? ''; liveStreamAudio.value = _prefs!.getBool(_liveAudioKey) ?? false; + units.value = _byName(UnitSystem.values, _prefs!.getString(_unitsKey)) ?? UnitSystem.metric; + c1.value = _byName(RcButtonAction.values, _prefs!.getString(_c1Key)) ?? RcButtonAction.gimbalRecenter; + c2.value = _byName(RcButtonAction.values, _prefs!.getString(_c2Key)) ?? RcButtonAction.gimbalToggleDown; + videoCache.value = _prefs!.getBool(_videoCacheKey) ?? false; + videoCacheLimitMb.value = _prefs!.getInt(_videoCacheLimitKey) ?? 2048; + lensCalibrated.value = (_prefs!.getStringList(_lensCalibratedKey) ?? const []).toSet(); + seenTutorials.value = (_prefs!.getStringList(_tutorialsKey) ?? const []).toSet(); } catch (_) { // Storage unavailable: run on the defaults. } @@ -166,6 +205,40 @@ abstract final class FlyPrefs { await _prefs?.setBool(_liveAudioKey, on); } + static Future setUnits(UnitSystem u) async { + units.value = u; + await _prefs?.setString(_unitsKey, u.name); + } + + static Future setRcButton(RcButton b, RcButtonAction a) async { + (b == RcButton.c1 ? c1 : c2).value = a; + await _prefs?.setString(b == RcButton.c1 ? _c1Key : _c2Key, a.name); + } + + static RcButtonAction rcButton(RcButton b) => (b == RcButton.c1 ? c1 : c2).value; + + static Future setVideoCache(bool on) async { + videoCache.value = on; + await _prefs?.setBool(_videoCacheKey, on); + } + + static Future setVideoCacheLimitMb(int mb) async { + videoCacheLimitMb.value = mb; + await _prefs?.setInt(_videoCacheLimitKey, mb); + } + + static Future markLensCalibrated(String camera) async { + if (lensCalibrated.value.contains(camera)) return; + lensCalibrated.value = {...lensCalibrated.value, camera}; + await _prefs?.setStringList(_lensCalibratedKey, lensCalibrated.value.toList()..sort()); + } + + static Future markTutorialSeen(String mode) async { + if (seenTutorials.value.contains(mode)) return; + seenTutorials.value = {...seenTutorials.value, mode}; + await _prefs?.setStringList(_tutorialsKey, seenTutorials.value.toList()..sort()); + } + 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/gimbal_control.dart b/Fly App/lib/fly/gimbal_control.dart index 7e194de..c7189af 100644 --- a/Fly App/lib/fly/gimbal_control.dart +++ b/Fly App/lib/fly/gimbal_control.dart @@ -120,6 +120,49 @@ class GimbalTuning { ]; } +/// GO 4's gimbal movement presets: one tap sets max speed and smoothness on +/// both axes, as fractions of each value's range on this gimbal (MSDK V4 has +/// no preset setter for aircraft gimbals, only the per-value ones). +enum GimbalPreset { + fast('Fast', 0.8, 0.2), + medium('Medium', 0.5, 0.5), + slow('Slow', 0.2, 0.8); + + const GimbalPreset(this.label, this.speed, this.smoothing); + final String label; + + /// Fraction of the max-speed range. + final double speed; + + /// Fraction of the smoothing range. + final double smoothing; + + static const List speedIds = ['pitchMaxSpeed', 'yawMaxSpeed']; + static const List smoothingIds = ['pitchSmoothing', 'yawSmoothing']; + + /// The values this preset writes, for the tuning ids [ranges] covers. + Map valuesFor(Map ranges) => { + for (final String id in speedIds) + if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * speed).round(), + for (final String id in smoothingIds) + if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * smoothing).round(), + }; + + /// The preset the current [settings] match (within a step), or null for a custom mix. + static GimbalPreset? matching(Map ranges, Object? Function(String id) setting) { + for (final GimbalPreset p in values) { + final Map want = p.valuesFor(ranges); + if (want.isEmpty) return null; + final bool all = want.entries.every((MapEntry e) { + final Object? v = setting(e.key); + return v is num && (v - e.value).abs() <= GimbalTuning.stepFor(ranges[e.key]!); + }); + if (all) return p; + } + return null; + } +} + /// The one-tap gimbal actions of the sheet and when they are refused. abstract final class GimbalActions { /// Fine-tune nudge per tap, degrees (GO 4's Adjust Gimbal step). diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index 71f5aba..720eb81 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -95,9 +95,11 @@ abstract final class HudFeatures { 'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing); static final HudFeature virtualSticks = HudFeature( 'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick); - /// 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); + /// Precision take-off records the spot with the downward cameras. The flight + /// controller's own `IS_PRECISION_TAKE_OFF_SUPPORTED` decides once it has + /// answered; until then downward vision is the stand-in. + static final HudFeature precisionTakeoff = HudFeature('control.precisionTakeoff', 'Precision Takeoff option', 'Control', + (ProductProfile p) => p.flightController?.precisionTakeOffSupported ?? 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); @@ -123,6 +125,9 @@ abstract final class HudFeatures { (ProductProfile p) => p.link == LinkType.lightbridge); static final HudFeature linkWifi = HudFeature( 'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi); + /// C1 / C2: every remote with custom buttons; Wi-Fi-only flying has none. + static final HudFeature customButtons = HudFeature( + 'settings.customButtons', 'C1 / C2 button actions', 'Settings', (ProductProfile p) => p.remoteController != null && p.hasRcLink); // ── Calibration & maintenance wizards (plan P6b) ────────────────────────── // Runtime support (`CalibrationState`) can still drop a wizard the profile allows. @@ -194,6 +199,7 @@ abstract final class HudFeatures { linkOcuSync, linkLightbridge, linkWifi, + customButtons, calibrationCompass, calibrationImu, calibrationRc, diff --git a/Fly App/lib/fly/modes/intelligent_modes.dart b/Fly App/lib/fly/modes/intelligent_modes.dart index 38eb813..ff0ecca 100644 --- a/Fly App/lib/fly/modes/intelligent_modes.dart +++ b/Fly App/lib/fly/modes/intelligent_modes.dart @@ -5,7 +5,7 @@ import '../product_profile.dart'; /// /// Which modes a product has comes from [ProductProfile.modes] (the product /// matrix until runtime capture confirms it); which of those this app can fly -/// yet is [ModeInfo.built]. A mode that is supported but not built is listed +/// is [ModeInfo.built]. A mode that is supported but not built is listed /// greyed out rather than hidden, so the pilot sees what is coming. class ModeInfo { const ModeInfo({ @@ -15,6 +15,7 @@ class ModeInfo { required this.group, required this.blurb, required this.terms, + this.tutorial = const [], this.built = false, }); @@ -33,6 +34,10 @@ class ModeInfo { /// The first-use disclaimer: what the pilot stays responsible for. final List terms; + /// GO 4's first-use tutorial: how to fly the mode, step by step. Shown once + /// after the terms, and again from the mode's panel. + final List tutorial; + /// This app can fly the mode. final bool built; } @@ -43,6 +48,13 @@ abstract final class ModeCatalog { 'The aircraft flies itself in this mode. Push the remote\'s pause button, or tap Stop, to make it hover.', ]; + static const List _waypointTutorial = [ + 'Tap the map to drop waypoints; tap a waypoint to edit its altitude, speed, heading, gimbal and camera action.', + 'Long-press and drag a waypoint to move it. The + between two waypoints inserts one there.', + 'ROUTE sets the cruise speed, the heading and what happens at the end; LIBRARY saves the route.', + 'START checks the route against the aircraft, uploads it and flies it. PAUSE holds the aircraft where it is.', + ]; + static const List all = [ ModeInfo( mode: IntelligentMode.waypoint, @@ -56,6 +68,7 @@ abstract final class ModeCatalog { 'The route flies straight between waypoints at the altitudes you set. It does not avoid terrain, trees or buildings: check the route and its altitudes before you start.', 'If the remote loses signal the aircraft follows the route\'s signal-lost setting.', ], + tutorial: _waypointTutorial, ), ModeInfo( mode: IntelligentMode.waypointV2, @@ -63,7 +76,12 @@ abstract final class ModeCatalog { icon: 'route', group: 'Map', blurb: 'Enterprise waypoint missions.', - terms: _common, + built: true, + terms: [ + ..._common, + 'Waypoints 2.0 flies the route\'s positions, altitudes, speeds and headings. Camera actions are not sent to the aircraft yet: run the camera yourself.', + ], + tutorial: _waypointTutorial, ), ModeInfo( mode: IntelligentMode.pointOfInterest, @@ -71,7 +89,17 @@ abstract final class ModeCatalog { icon: 'circle', group: 'Map', blurb: 'Circle a subject at a set radius.', - terms: _common, + built: true, + terms: [ + ..._common, + 'The aircraft circles at a fixed height and does not brake for obstacles on the circle: check the whole circle is clear.', + ], + tutorial: [ + 'Tap the map on the subject, or AIRCRAFT to use where the aircraft is now.', + 'Set the radius, the altitude and the speed; pick the direction and which way the aircraft faces.', + 'GO flies to the circle and starts circling. Radius and speed can change while it circles.', + 'The remote\'s sticks nudge the orbit; PAUSE holds position, STOP ends it.', + ], ), ModeInfo( mode: IntelligentMode.followMe, @@ -79,7 +107,18 @@ abstract final class ModeCatalog { icon: 'user', group: 'Map', blurb: 'The aircraft follows this phone.', - terms: _common, + built: true, + terms: [ + ..._common, + 'The aircraft follows the phone\'s GPS, which can be off by several metres, and it does not avoid obstacles while it follows.', + 'Keep the phone with you and its screen on: if the app stops sending your position the aircraft stops following and hovers.', + ], + tutorial: [ + 'Take off and climb to at least 10 m with open space around you.', + 'Set the height to follow at and whether the aircraft faces you or the remote steers its heading.', + 'GO: the aircraft keeps its distance as you walk. Keep the app open.', + 'STOP, or the remote\'s pause button, ends it.', + ], ), ModeInfo( mode: IntelligentMode.activeTrack, @@ -93,6 +132,12 @@ abstract final class ModeCatalog { 'ActiveTrack follows what the camera sees. It can lose the subject behind trees or in crowds, and it does not see obstacles to the sides or behind on every aircraft.', 'Do not use it to follow cars or boats at speed, or over water and roads you cannot see the end of.', ], + tutorial: [ + 'Drag a box round the subject on the video (or tap a person-sized one), then GO.', + 'Trace follows behind, Profile alongside, Spotlight keeps the camera on it while you fly.', + 'A green box is locked on, amber is unsure; confirm with ✓ when the aircraft asks.', + 'Gestures: with Gesture mode on (Settings → Vision), raise your arms to be tracked, frame your fingers for a photo.', + ], ), ModeInfo( mode: IntelligentMode.tapFly, @@ -105,6 +150,11 @@ abstract final class ModeCatalog { ..._common, 'TapFly keeps flying towards the point until you stop it. Obstacle avoidance only works in the direction the sensors face, and not at all in Free.', ], + tutorial: [ + 'Tap where to fly on the video, then GO.', + 'Forward flies towards the point; Backward away from it; Free lets you turn the aircraft while it flies.', + 'Set the speed with − / +; tap a new point to change course.', + ], ), ModeInfo( mode: IntelligentMode.quickShots, @@ -112,7 +162,16 @@ abstract final class ModeCatalog { icon: 'dronie', group: 'Shots', blurb: 'Dronie, Rocket, Circle, Helix…', - terms: _common, + built: true, + terms: [ + ..._common, + 'A QuickShot flies a set path around the subject, up to 120 m away and high: keep that space clear.', + ], + tutorial: [ + 'Pick the shot, draw a box round the subject on the video, then GO.', + 'The aircraft records the video, flies the path and comes back to where it started.', + 'STOP, the sticks or the remote\'s pause button end it early.', + ], ), ModeInfo( mode: IntelligentMode.hyperlapse, @@ -120,7 +179,16 @@ abstract final class ModeCatalog { icon: 'hyperlapse', group: 'Shots', blurb: 'A moving timelapse.', - terms: _common, + built: true, + terms: [ + 'You fly the aircraft during a Free hyperlapse; the camera only shoots the frames. Fly slowly and steadily.', + 'Stop the sequence before landing or flying home.', + ], + tutorial: [ + 'Set the interval between frames and how long the finished video should be.', + 'START shoots the frames; fly a slow, steady line meanwhile.', + 'The frames are on the SD card; assemble them into the video on a computer.', + ], ), ModeInfo( mode: IntelligentMode.courseLock, @@ -128,7 +196,15 @@ abstract final class ModeCatalog { icon: 'compass', group: 'Orientation', blurb: 'Forward stays the current heading.', - terms: _common, + built: true, + terms: [ + 'Course Lock changes what the sticks do: forward is the locked course, whichever way the aircraft faces. Turn it off before flying normally again.', + ], + tutorial: [ + 'Point the aircraft where "forward" should be, then turn Course Lock on.', + 'Turn the aircraft freely: the sticks keep flying the locked course.', + 'LOCK HEADING takes the current heading as the new course.', + ], ), ModeInfo( mode: IntelligentMode.homeLock, @@ -136,7 +212,14 @@ abstract final class ModeCatalog { icon: 'home', group: 'Orientation', blurb: 'Pulling back always flies home.', - terms: _common, + built: true, + terms: [ + 'Home Lock changes what the sticks do: back is always towards home, whichever way the aircraft faces. Turn it off before flying normally again.', + ], + tutorial: [ + 'Fly at least 10 m from home, then turn Home Lock on.', + 'Pull the stick back to come home, push it to fly away, whichever way the aircraft points.', + ], ), ModeInfo( mode: IntelligentMode.tripod, @@ -144,7 +227,14 @@ abstract final class ModeCatalog { icon: 'gauge', group: 'Orientation', blurb: 'Slow, precise control for framing.', - terms: _common, + built: true, + terms: [ + 'Tripod mode limits the speed to about 1 m/s and makes the sticks very gentle. Turn it off to fly away from danger quickly.', + ], + tutorial: [ + 'Tripod: about 1 m/s and gentle sticks, for framing and indoor-tight spaces.', + 'Cinematic: smooth braking and slow turns for steady video; set how gently it brakes and turns.', + ], ), ]; diff --git a/Fly App/lib/fly/modes/orbit_modes.dart b/Fly App/lib/fly/modes/orbit_modes.dart new file mode 100644 index 0000000..0c2b60c --- /dev/null +++ b/Fly App/lib/fly/modes/orbit_modes.dart @@ -0,0 +1,283 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../hud_geometry.dart'; + +/// The remaining Intelligent Flight Modes (plan P7) as pure data: Point of +/// Interest, Follow Me, QuickShots, Hyperlapse and the orientation / +/// Tripod / Cinematic switches. The SafetyGate checks them and the bridge +/// flies them; nothing here talks to the aircraft. + +// ── Point of Interest ───────────────────────────────────────────────────────── + +/// Which way the aircraft faces while circling (`HotpointHeading`). +enum PoiHeading { + toCenter('Towards POI', 'TOWARDS_HOT_POINT'), + away('Away from POI', 'AWAY_FROM_HOT_POINT'), + forward('Along the circle', 'ALONG_CIRCLE_LOOKING_FORWARDS'), + backward('Along, looking back', 'ALONG_CIRCLE_LOOKING_BACKWARDS'), + remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER'); + + const PoiHeading(this.label, this.sdk); + final String label; + final String sdk; +} + +/// Where on the circle the aircraft joins it (`HotpointStartPoint`). +enum PoiStart { + nearest('Nearest', 'NEAREST'), + north('North', 'NORTH'), + east('East', 'EAST'), + south('South', 'SOUTH'), + west('West', 'WEST'); + + const PoiStart(this.label, this.sdk); + final String label; + final String sdk; +} + +/// A Point of Interest orbit as the pilot sets it up (GO 4's POI panel). +@immutable +class PoiSpec { + const PoiSpec({ + this.center, + this.radius = 30, + this.altitude = 30, + this.speed = 3, + this.clockwise = true, + this.heading = PoiHeading.toCenter, + this.start = PoiStart.nearest, + }); + + // `HotpointMission` limits (MSDK 4.18 constants). + static const double minRadius = 5; + static const double maxRadius = 500; + static const double minAltitude = 5; + static const double maxAltitude = 500; + + /// The operator's ceiling for the orbit, whatever the radius allows. + static const double maxSpeed = 10; + static const double minSpeed = 0.5; + + /// Below this the pre-start check warns about trees and buildings. + static const double lowAltitude = 20; + + final LatLng? center; + final double radius; + final double altitude; + + /// Tangential speed, m/s; the bridge wants °/s ([angularVelocity]). + final double speed; + final bool clockwise; + final PoiHeading heading; + final PoiStart start; + + static const Object _keep = Object(); + + PoiSpec copyWith({ + Object? center = _keep, + double? radius, + double? altitude, + double? speed, + bool? clockwise, + PoiHeading? heading, + PoiStart? start, + }) => + PoiSpec( + center: identical(center, _keep) ? this.center : center as LatLng?, + radius: radius ?? this.radius, + altitude: altitude ?? this.altitude, + speed: speed ?? this.speed, + clockwise: clockwise ?? this.clockwise, + heading: heading ?? this.heading, + start: start ?? this.start, + ); + + /// Degrees per second for a tangential [speed] on a circle of [radius]. + static double angularFor(double speed, double radius) => speed / radius * 180 / math.pi; + + /// The tangential speed of [deg] °/s on [radius]. + static double speedFor(double deg, double radius) => deg * math.pi / 180 * radius; + + double get angularVelocity => angularFor(speed, radius); + + /// Seconds per lap. + int get lapSeconds => (2 * math.pi * radius / speed.clamp(minSpeed, maxSpeed)).round(); + + /// What would make the operator refuse the orbit (blocking) or what the + /// pilot should know (not). + List<(bool, String)> issues({double? maxAngular, LatLng? aircraft}) => <(bool, String)>[ + if (center == null) (true, 'Set the point of interest: tap the map, or use the aircraft\'s position.'), + if (radius < minRadius || radius > maxRadius) (true, 'The radius must be between 5 and 500 m.'), + if (altitude < minAltitude || altitude > maxAltitude) (true, 'The altitude must be between 5 and 500 m.'), + if (maxAngular != null && maxAngular > 0 && angularVelocity > maxAngular) + (false, 'At ${radius.round()} m the aircraft circles at most ${speedFor(maxAngular, radius).toStringAsFixed(1)} m/s; it flies that speed.'), + if (altitude < lowAltitude) (false, 'The orbit is only ${altitude.round()} m up: check for trees and buildings on the circle.'), + if (center != null && aircraft != null && HudGeometry.distance(aircraft.latitude, aircraft.longitude, center!.latitude, center!.longitude) > 1000) + (false, 'The point of interest is more than 1 km from the aircraft.'), + ]; + + /// `startHotpoint`'s arguments; [maxAngular] caps the speed the operator would refuse. + Map toBridge({double? maxAngular}) { + double w = angularVelocity; + if (maxAngular != null && maxAngular > 0) w = math.min(w, maxAngular); + return { + 'lat': center!.latitude, + 'lon': center!.longitude, + 'altitude': altitude.clamp(minAltitude, maxAltitude), + 'radius': radius.clamp(minRadius, maxRadius), + 'angularVelocity': w, + 'clockwise': clockwise, + 'startPoint': start.sdk, + 'heading': heading.sdk, + }; + } +} + +// ── Follow Me ───────────────────────────────────────────────────────────────── + +enum FollowMeHeading { + toPilot('Face me', 'TOWARD_FOLLOW_POSITION'), + remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER'); + + const FollowMeHeading(this.label, this.sdk); + final String label; + final String sdk; +} + +@immutable +class FollowMeSpec { + const FollowMeSpec({this.altitude = 15, this.heading = FollowMeHeading.toPilot}); + + static const double minAltitude = 10; + static const double maxAltitude = 120; + + /// How often the phone's fix is sent while following. + static const Duration updateEvery = Duration(milliseconds: 500); + + /// A phone fix worse than this (m) is not worth following. + static const double maxAccuracy = 15; + + final double altitude; + final FollowMeHeading heading; + + FollowMeSpec copyWith({double? altitude, FollowMeHeading? heading}) => + FollowMeSpec(altitude: altitude ?? this.altitude, heading: heading ?? this.heading); +} + +// ── QuickShots ──────────────────────────────────────────────────────────────── + +/// GO 4's QuickShots (`QuickShotMode` names); which ones an aircraft flies is +/// its firmware's call — an unsupported one is refused by the operator. +enum QuickShot { + dronie('Dronie', 'DRONIE', 'Flies up and back, camera on the subject'), + rocket('Rocket', 'ROCKET', 'Climbs straight up, camera looking down'), + circle('Circle', 'CIRCLE', 'Circles the subject at its height'), + helix('Helix', 'HELIX', 'Spirals up and out around the subject'), + boomerang('Boomerang', 'BOOMERANG', 'Swings out and back in an oval'), + asteroid('Asteroid', 'ASTEROID', 'Flies up and back, then a spherical pano'), + dollyZoom('Dolly zoom', 'DOLLY_ZOOM', 'Backs away while zooming in (zoom cameras)'); + + const QuickShot(this.label, this.sdk, this.blurb); + final String label; + final String sdk; + final String blurb; + + /// The shots GO 4 offers on this product: Boomerang and Asteroid came with + /// the omni-sensing aircraft, Dolly zoom needs a zoom lens. + static List offered({required bool omni, required bool zoom}) => [ + dronie, + rocket, + circle, + helix, + if (omni) boomerang, + if (omni) asteroid, + if (zoom) dollyZoom, + ]; +} + +// ── Hyperlapse ──────────────────────────────────────────────────────────────── + +/// A Free hyperlapse: the camera shoots an interval sequence while the pilot +/// flies, for a video of [videoSeconds] at [fps] (GO 4's Hyperlapse → Free). +/// MSDK V4 has no hyperlapse operator, so the flight is the pilot's and the +/// frames are assembled afterwards. +@immutable +class HyperlapseSpec { + const HyperlapseSpec({this.intervalSeconds = 2, this.videoSeconds = 10, this.fps = 25}); + + static const List intervals = [2, 3, 5, 10]; + static const List durations = [5, 10, 15, 20, 30]; + + final int intervalSeconds; + final int videoSeconds; + final int fps; + + int get frames => (videoSeconds * fps).clamp(2, 10000); + + /// The camera's interval count: at most 254 frames, else 255 = "until + /// stopped", and the gate stops it after [shootingSeconds]. + int get cameraCount => frames > 254 ? 255 : frames; + + /// How long the shooting takes, s. + int get shootingSeconds => frames * intervalSeconds; + + HyperlapseSpec copyWith({int? intervalSeconds, int? videoSeconds}) => + HyperlapseSpec(intervalSeconds: intervalSeconds ?? this.intervalSeconds, videoSeconds: videoSeconds ?? this.videoSeconds, fps: fps); +} + +// ── Orientation / Tripod / Cinematic ────────────────────────────────────────── + +/// Intelligent Orientation Control (`FlightOrientationMode`). +enum OrientationMode { + normal('Normal', 'AIRCRAFT_HEADING'), + courseLock('Course Lock', 'COURSE_LOCK'), + homeLock('Home Lock', 'HOME_LOCK'); + + const OrientationMode(this.label, this.sdk); + final String label; + final String sdk; + + static OrientationMode fromSdk(String? s) => switch (s) { + 'COURSE_LOCK' => courseLock, + 'HOME_LOCK' => homeLock, + _ => normal, + }; +} + +/// Home Lock refuses within this distance of home: "pull back" has no direction. +const double homeLockMinDistance = 10; + +/// What the pilot has set up in the POI / Follow Me / Hyperlapse panels, +/// shared by the panel, the map layer and the flight screen. +class ModeSetup extends ChangeNotifier { + PoiSpec poi = const PoiSpec(); + FollowMeSpec followMe = const FollowMeSpec(); + HyperlapseSpec hyperlapse = const HyperlapseSpec(); + + /// The aircraft's fastest orbit at [poi]'s radius (°/s), once asked. + double? poiMaxAngular; + + void setPoi(PoiSpec p) { + if (p.radius != poi.radius) poiMaxAngular = null; + poi = p; + notifyListeners(); + } + + void setPoiMaxAngular(double? v) { + poiMaxAngular = v; + notifyListeners(); + } + + void setFollowMe(FollowMeSpec f) { + followMe = f; + notifyListeners(); + } + + void setHyperlapse(HyperlapseSpec h) { + hyperlapse = h; + notifyListeners(); + } +} diff --git a/Fly App/lib/fly/modes/terrain.dart b/Fly App/lib/fly/modes/terrain.dart new file mode 100644 index 0000000..bd6be94 --- /dev/null +++ b/Fly App/lib/fly/modes/terrain.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:latlong2/latlong.dart' show LatLng; + +import '../hud_geometry.dart'; +import 'waypoint_mission.dart'; + +/// The route's clearance over the ground (plan P7's terrain check). +/// +/// Waypoint altitudes are relative to the take-off point, so a route that +/// climbs a hillside can fly into it while every waypoint reads "50 m". This +/// samples the ground along every leg (an elevation service, [ElevationSource]) +/// and compares it with the aircraft's planned height above sea level: take-off +/// elevation + the altitude interpolated along the leg. +/// +/// Pure apart from the source, so the maths is unit-tested with a fake. +abstract class ElevationSource { + /// Ground elevation in metres above sea level for each point, in order. + Future> elevations(List points); +} + +/// One sample along the route that comes too close to the ground. +class TerrainIssue { + const TerrainIssue({required this.leg, required this.clearance, required this.at}); + + /// The leg it is on: from waypoint [leg] to [leg] + 1 (0-based). + final int leg; + + /// Metres between the aircraft and the ground there (negative = underground). + final double clearance; + final LatLng at; + + MissionIssue toIssue() => clearance < 0 + ? MissionIssue.error( + 'Between waypoints ${leg + 1} and ${leg + 2} the ground rises ${(-clearance).round()} m above the route.', + point: leg + 1, + ) + : MissionIssue.warning( + 'Between waypoints ${leg + 1} and ${leg + 2} the route passes only ${clearance.round()} m over the ground.', + point: leg + 1, + ); +} + +class TerrainCheck { + const TerrainCheck(this.source, {this.spacing = 50, this.minClearance = 15, this.maxSamples = 100}); + + final ElevationSource source; + + /// Metres between samples along a leg. + final double spacing; + + /// Less clearance than this is reported. + final double minClearance; + + /// The service takes at most this many points per request. + final int maxSamples; + + /// Samples along the route: every waypoint, and points every [spacing] m + /// between them, each with its leg and planned altitude (relative to take-off). + List<(int leg, LatLng at, double altitude)> samples(WaypointMissionSpec spec) { + final List<(int, LatLng, double)> out = <(int, LatLng, double)>[]; + final List p = spec.points; + for (int i = 0; i + 1 < p.length; i++) { + final LatLng a = p[i].at, b = p[i + 1].at; + final double d = HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude); + final int n = (d / spacing).ceil().clamp(1, 1000); + for (int k = 0; k < n; k++) { + final double t = k / n; + out.add(( + i, + LatLng(a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t), + p[i].altitude + (p[i + 1].altitude - p[i].altitude) * t, + )); + } + } + if (p.isNotEmpty) out.add((p.length - 2 < 0 ? 0 : p.length - 2, p.last.at, p.last.altitude)); + // Evenly thinned to the service's limit. + if (out.length > maxSamples) { + final double step = out.length / maxSamples; + return <(int, LatLng, double)>[for (int k = 0; k < maxSamples; k++) out[(k * step).floor()]]; + } + return out; + } + + /// The places the route comes within [minClearance] of the ground; [home] + /// is the take-off point the altitudes are measured from. The worst per leg. + Future> run(WaypointMissionSpec spec, LatLng home) async { + final List<(int, LatLng, double)> s = samples(spec); + if (s.isEmpty) return const []; + final List ground = await source.elevations([home, for (final (int, LatLng, double) x in s) x.$2]); + if (ground.length != s.length + 1) throw const FormatException('The elevation service answered for a different number of points'); + final double base = ground.first; + final Map worst = {}; + for (int k = 0; k < s.length; k++) { + final (int leg, LatLng at, double alt) = s[k]; + final double clearance = base + alt - ground[k + 1]; + if (clearance >= minClearance) continue; + final TerrainIssue? w = worst[leg]; + if (w == null || clearance < w.clearance) worst[leg] = TerrainIssue(leg: leg, clearance: clearance, at: at); + } + final List out = worst.values.toList()..sort((TerrainIssue a, TerrainIssue b) => a.leg.compareTo(b.leg)); + return out; + } +} + +/// Open-Meteo's free elevation API (Copernicus 90 m DEM, no key). Sends the +/// sampled coordinates to api.open-meteo.com, so the check only runs when the +/// pilot asks for it. +class OpenMeteoElevation implements ElevationSource { + const OpenMeteoElevation(); + + @override + Future> elevations(List points) async { + final List out = []; + // 100 coordinates per request is the API's limit. + for (int i = 0; i < points.length; i += 100) { + final List chunk = points.sublist(i, (i + 100).clamp(0, points.length)); + final Uri uri = Uri.https('api.open-meteo.com', '/v1/elevation', { + 'latitude': chunk.map((LatLng p) => p.latitude.toStringAsFixed(5)).join(','), + 'longitude': chunk.map((LatLng p) => p.longitude.toStringAsFixed(5)).join(','), + }); + final HttpClient client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final HttpClientRequest req = await client.getUrl(uri); + final HttpClientResponse res = await req.close().timeout(const Duration(seconds: 15)); + final String body = await res.transform(utf8.decoder).join(); + if (res.statusCode != 200) throw HttpException('Elevation service answered ${res.statusCode}', uri: uri); + final Object? json = jsonDecode(body); + final Object? list = json is Map ? json['elevation'] : null; + if (list is! List) throw const FormatException('No elevation in the answer'); + out.addAll([for (final Object? e in list) (e as num).toDouble()]); + } finally { + client.close(); + } + } + return out; + } +} diff --git a/Fly App/lib/fly/modes/vision_modes.dart b/Fly App/lib/fly/modes/vision_modes.dart index 5dbb24f..82e0db9 100644 --- a/Fly App/lib/fly/modes/vision_modes.dart +++ b/Fly App/lib/fly/modes/vision_modes.dart @@ -2,6 +2,8 @@ import 'dart:ui' show Offset, Rect, Size; import 'package:flutter/foundation.dart'; +import 'orbit_modes.dart' show QuickShot; + /// What the pilot has chosen in an ActiveTrack or TapFly session, before and /// while the operator runs: shared by the video overlay and the mode panel. class VisionSession extends ChangeNotifier { @@ -9,6 +11,22 @@ class VisionSession extends ChangeNotifier { TapFlyDirection direction = TapFlyDirection.forward; double speed = 5; + /// ActiveTrack Circle's orbit, m/s; negative goes anticlockwise. + double circleSpeed = 3; + + /// The QuickShot the next box flies. + QuickShot quickShot = QuickShot.dronie; + + void setCircleSpeed(double v) { + circleSpeed = v; + notifyListeners(); + } + + void setQuickShot(QuickShot s) { + quickShot = s; + notifyListeners(); + } + /// A box drawn (or tapped) on the video, 0..1, waiting for GO. Rect? pendingBox; @@ -77,12 +95,24 @@ enum TrackMode { profile('Profile', 'PROFILE'), /// Stays put and keeps the camera on it; the pilot flies. - spotlight('Spotlight', 'SPOTLIGHT'); + spotlight('Spotlight', 'SPOTLIGHT'), + + /// Spotlight that also turns the aircraft (the gimbal alone cannot keep + /// up): Mavic 2-class aircraft. + spotlightPro('Spotlight Pro', 'SPOTLIGHT_PRO'), + + /// Orbits the subject at [VisionSession.circleSpeed]. + circle('Circle', 'CIRCLE'); const TrackMode(this.label, this.sdk); final String label; final String sdk; + /// The follow styles GO 4 offers: Spotlight Pro and Circle came with the + /// omni-sensing aircraft (Mavic 2); everything else has the first three. + static List offered({required bool omni}) => + [trace, profile, spotlight, if (omni) spotlightPro, if (omni) circle]; + static TrackMode? fromSdk(String? s) { for (final TrackMode m in values) { if (m.sdk == s) return m; diff --git a/Fly App/lib/fly/modes/waypoint_mission.dart b/Fly App/lib/fly/modes/waypoint_mission.dart index 566d73a..284906d 100644 --- a/Fly App/lib/fly/modes/waypoint_mission.dart +++ b/Fly App/lib/fly/modes/waypoint_mission.dart @@ -365,6 +365,21 @@ class WaypointMissionSpec { updatedAt: updatedAt ?? this.updatedAt, ); + /// The rest of the route from waypoint [index] on (0-based), to resume an + /// interrupted mission there. Keeps the route settings and POIs. + WaypointMissionSpec from(int index) => copyWith(points: points.sublist(index.clamp(0, points.length))); + + /// Whether an interruption at [at] (waypoint [index]) belongs to this route: + /// the route has that waypoint and it lies within 50 m of where it stopped. + bool matchesInterruption(int index, LatLng at) { + if (index < 0 || index >= points.length) return false; + // Interrupted between index−1 and index: near either end of that leg counts. + final LatLng p = points[index].at; + final LatLng q = index > 0 ? points[index - 1].at : p; + final double leg = _dist2(q, p); + return _dist2(at, p) <= leg + 50 && _dist2(at, q) <= leg + 50; + } + // ── Geometry and estimates ──────────────────────────────────────────────── /// 3-D length of each leg, m: leg i runs from point i to point i+1. diff --git a/Fly App/lib/fly/product_profile.dart b/Fly App/lib/fly/product_profile.dart index 5e77529..4a94313 100644 --- a/Fly App/lib/fly/product_profile.dart +++ b/Fly App/lib/fly/product_profile.dart @@ -226,9 +226,11 @@ class FlightControllerCaps { this.accessLockerSupported, this.onboardSdkAvailable, this.simulatorAvailable, + this.precisionTakeOffSupported, }); factory FlightControllerCaps.fromMap(Map m) => FlightControllerCaps( + precisionTakeOffSupported: m['precisionTakeOffSupported'] as bool?, flightAssistantSupported: m['flightAssistantSupported'] as bool?, virtualStickAvailable: m['virtualStickAvailable'] as bool?, rtkSupported: m['rtkSupported'] as bool?, @@ -251,6 +253,9 @@ class FlightControllerCaps { final bool? accessLockerSupported; final bool? onboardSdkAvailable; final bool? simulatorAvailable; + + /// `IS_PRECISION_TAKE_OFF_SUPPORTED`; null until the flight controller answered. + final bool? precisionTakeOffSupported; } class RemoteControllerCaps { diff --git a/Fly App/lib/fly/rc_buttons.dart b/Fly App/lib/fly/rc_buttons.dart new file mode 100644 index 0000000..8791050 --- /dev/null +++ b/Fly App/lib/fly/rc_buttons.dart @@ -0,0 +1,35 @@ +/// What a remote's custom button (C1 / C2) does (GO 4's RC → Button +/// Customization, plan P6). +/// +/// On the consumer remotes MSDK V4 only reports that C1 / C2 was pressed +/// (`RemoteControllerKey.CUSTOM_BUTTON_1/2`): the action is the app's, as it is +/// in GO 4. The flight screen performs it; nothing is written to the remote. +enum RcButtonAction { + none('Not set'), + gimbalRecenter('Recenter gimbal'), + gimbalToggleDown('Gimbal forward / down'), + mapSwap('Map / camera view'), + cameraSettings('Camera settings'), + aircraftStatus('Aircraft status'), + aeLock('AE lock on / off'), + radar('Radar chart on / off'); + + const RcButtonAction(this.label); + final String label; +} + +/// A press of one of the remote's custom buttons. +enum RcButton { + c1('C1'), + c2('C2'); + + const RcButton(this.label); + final String label; + + /// The `rcButton` event's `button` value. + static RcButton? fromEvent(Object? v) => switch (v) { + 'C1' => RcButton.c1, + 'C2' => RcButton.c2, + _ => null, + }; +} diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index d00e3e6..96595a2 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' show Offset, Rect; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import 'calibration.dart'; import 'flight_status.dart'; import 'fly_prefs.dart'; import 'hud_geometry.dart'; +import 'modes/orbit_modes.dart'; import 'modes/vision_modes.dart'; import 'modes/waypoint_mission.dart'; import 'product_profile.dart'; @@ -158,6 +160,8 @@ class SafetyGate { case ActiveMode.waypoint: case ActiveMode.activeTrack: case ActiveMode.tapFly: + case ActiveMode.hotpoint: + case ActiveMode.followMe: break; } } @@ -421,6 +425,8 @@ class SafetyGate { checks.add(const GateCheck.block('The mission system is not ready yet. Wait a moment.')); } if (model.mission.tracking) checks.add(const GateCheck.block('Stop ActiveTrack first.')); + if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.')); + if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.')); for (final MissionIssue i in spec.validate(limits)) { checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message)); @@ -472,6 +478,9 @@ class SafetyGate { if (shoots && model.camera.storage != null && !model.camera.storage!.usable) { checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved.")); } + if (usesWaypointsV2 && (shoots || spec.points.any((WaypointSpec p) => p.hoverSeconds > 0 || p.gimbalPitch != null))) { + checks.add(const GateCheck.warn('Waypoints 2.0: the route flies, but its camera actions, hovers and gimbal angles are not sent yet.')); + } return checks; } @@ -489,11 +498,42 @@ class SafetyGate { final int h = rthHeight.clamp(minRthHeight, maxRthHeight); if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h); } - await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax)); + await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax), v2: usesWaypointsV2); onUploaded?.call(); await dji.startWaypointMission(); } + /// The connected aircraft flies Waypoints 2.0 only (Matrice 300): the same + /// route goes to the V2 operator. + bool get usesWaypointsV2 => + model.profile.supportsMode(IntelligentMode.waypointV2) && !model.profile.supportsMode(IntelligentMode.waypoint); + + /// The cruise speed of the mission in flight, from the run panel. + Future setWaypointCruiseSpeed(double speed) async { + if (!model.mission.waypoint.active) { + throw const MissionRefused([GateCheck.block('No waypoint mission is flying.')]); + } + await dji.setWaypointAutoFlightSpeed(speed.clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed)); + } + + /// Where the last mission was cut short, if it belongs to [spec]: the + /// waypoint (0-based) to resume from, else null. + Future interruptionFor(WaypointMissionSpec spec) async { + if (!model.connected || spec.points.length < 2) return null; + try { + final Map? i = await dji.getWaypointInterruption(); + if (i == null) return null; + final int? index = (i['index'] as num?)?.toInt(); + final double? lat = (i['lat'] as num?)?.toDouble(), lon = (i['lon'] as num?)?.toDouble(); + if (index == null || lat == null || lon == null || index <= 0) return null; + // A resume needs two waypoints left to fly. + if (index > spec.points.length - 2) return null; + return spec.matchesInterruption(index, LatLng(lat, lon)) ? index : null; + } catch (_) { + return null; + } + } + /// Holds the aircraft where it is; the mission can resume from there. Future pauseWaypointMission() async { if (!model.mission.waypoint.executing) { @@ -544,6 +584,8 @@ class SafetyGate { 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.')); if (model.mission.waypoint.active) checks.add(const GateCheck.block('Stop the waypoint mission first.')); + if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.')); + if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.')); if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') { checks.add(const GateCheck.block('Switch the remote to P mode first.')); } @@ -667,6 +709,282 @@ class SafetyGate { await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed)); } + /// ActiveTrack Circle's orbit speed, m/s (negative anticlockwise). + Future setTrackCircleSpeed(double speed) async { + if (!model.connected) throw const MissionRefused([GateCheck.block('No aircraft connected.')]); + await dji.setActiveTrackCircularSpeed(speed.clamp(-10.0, 10.0)); + } + + // ── QuickShots (plan P7) ──────────────────────────────────────────────────── + + /// Preconditions for flying [shot] around a boxed subject: ActiveTrack's, + /// plus room for the aircraft to climb and fly away. + List checkQuickShot(QuickShot shot) { + 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.')); + if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly QuickShots.')); + _visionCommon(checks); + if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.')); + if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.')); + if (model.camera.storage != null && !model.camera.storage!.usable) { + checks.add(GateCheck.block('${model.camera.storage!.status}: the QuickShot video would not be saved.')); + } + final int? left = _a.goHome?.remainingFlightTime; + if (left != null && left > 0 && left < 120) checks.add(const GateCheck.warn('Under 2 minutes of flight left: the shot may be cut short.')); + switch (shot) { + case QuickShot.rocket: + case QuickShot.dronie: + case QuickShot.asteroid: + case QuickShot.helix: + checks.add(const GateCheck.warn('The aircraft climbs up to 120 m and flies away from the subject: keep the space above and behind clear.')); + case QuickShot.circle: + case QuickShot.boomerang: + checks.add(const GateCheck.warn('The aircraft flies around the subject: keep the circle clear.')); + case QuickShot.dollyZoom: + checks.add(const GateCheck.warn('The aircraft backs away from the subject: keep the space behind it clear.')); + } + if (!model.profile.hasObstacleSensing) { + checks.add(const GateCheck.warn('This aircraft cannot see obstacles on the way.')); + } + return checks; + } + + /// Re-checks and flies [shot] around the subject in [rect] (0..1 of the live view). + Future startQuickShot(Rect rect, QuickShot shot) async { + final List checks = checkQuickShot(shot); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1)); + await dji.startQuickShot(shot.sdk, x: r.left, y: r.top, w: r.width, h: r.height); + } + + // ── Point of Interest (plan P7) ───────────────────────────────────────────── + + /// What a mission-type mode needs before it may fly the aircraft on its own: + /// connected, airborne in P mode with GPS, nothing else in charge, battery. + void _autoCommon(List checks, String name) { + final String mode = _a.flightModeEnum ?? ''; + if (_a.isFlying != true) checks.add(GateCheck.block('Take off before starting $name.')); + 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.')); + if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') checks.add(const GateCheck.block('Switch the remote to P mode first.')); + final ActiveMode running = RailState.activeMode(_a, model.mission); + if (running != ActiveMode.none) checks.add(const GateCheck.block('Stop the intelligent flight mode that is running first.')); + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for $name.')); + final int? percent = model.battery.percent; + final int critical = model.battery.seriousLowWarningThreshold ?? 15; + final int low = model.battery.lowWarningThreshold ?? 30; + if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) { + checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.')); + } else if (_a.lowBattery == true || (percent != null && percent <= low)) { + checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}.')); + } + if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.')); + } + + /// Preconditions for circling [spec]; [maxAngular] is the aircraft's limit at its radius. + List checkPoi(PoiSpec spec, {double? maxAngular}) { + 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.')); + if (model.mission.hotpoint.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly Point of Interest.')); + _autoCommon(checks, 'Point of Interest'); + final double? lat = _a.latitude, lon = _a.longitude; + for (final (bool blocks, String m) in spec.issues(maxAngular: maxAngular, aircraft: lat == null || lon == null ? null : LatLng(lat, lon))) { + checks.add(blocks ? GateCheck.block(m) : GateCheck.warn(m)); + } + // POI flies sideways round the circle, where most aircraft have no sensors. + checks.add(const GateCheck.warn('The aircraft does not brake for obstacles on the circle: check it is clear.')); + final int? left = _a.goHome?.remainingFlightTime; + if (left != null && left > 0 && left < spec.lapSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) { + checks.add(GateCheck.warn('One lap takes about ${formatDuration(spec.lapSeconds)}: more than the battery has to spare.')); + } + return checks; + } + + Future startPoi(PoiSpec spec, {double? maxAngular}) async { + final List checks = checkPoi(spec, maxAngular: maxAngular); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + final Map m = spec.toBridge(maxAngular: maxAngular); + await dji.startHotpoint( + lat: m['lat']! as double, + lon: m['lon']! as double, + altitude: (m['altitude']! as num).toDouble(), + radius: (m['radius']! as num).toDouble(), + angularVelocity: (m['angularVelocity']! as num).toDouble(), + clockwise: m['clockwise']! as bool, + startPoint: m['startPoint']! as String, + heading: m['heading']! as String, + ); + } + + Future pausePoi() async { + if (!model.mission.hotpoint.active || model.mission.hotpoint.paused) { + throw const MissionRefused([GateCheck.block('Point of Interest is not circling.')]); + } + await dji.pauseHotpoint(); + } + + Future resumePoi() async { + final List checks = [ + if (!model.mission.hotpoint.paused) const GateCheck.block('Point of Interest is not paused.'), + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on.'), + ]; + if (checks.isNotEmpty) throw MissionRefused(checks); + await dji.resumeHotpoint(); + } + + /// Changes the orbit in flight; [speed] m/s becomes °/s on the current radius. + Future adjustPoi({double? radius, double? speed, required bool clockwise, required double currentRadius}) async { + if (!model.mission.hotpoint.active) throw const MissionRefused([GateCheck.block('Point of Interest is not running.')]); + if (radius != null) await dji.setHotpointRadius(radius.clamp(PoiSpec.minRadius, PoiSpec.maxRadius)); + if (speed != null) { + final double r = radius ?? currentRadius; + final double w = PoiSpec.angularFor(speed.clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed), r); + await dji.setHotpointAngularVelocity(clockwise ? w : -w); + } + } + + // ── Follow Me (plan P7) ───────────────────────────────────────────────────── + + /// Preconditions for following this phone. [phoneAccuracy] is its fix, m. + List checkFollowMe(FollowMeSpec spec, {double? phoneAccuracy}) { + 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.')); + if (model.mission.followMe.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run Follow Me.')); + _autoCommon(checks, 'Follow Me'); + final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude; + if (pLat == null || pLon == null) { + checks.add(const GateCheck.block('This phone has no location fix: Follow Me follows the phone.')); + } else { + if (phoneAccuracy != null && phoneAccuracy > FollowMeSpec.maxAccuracy) { + checks.add(GateCheck.warn('The phone\'s fix is only good to ${phoneAccuracy.round()} m: the aircraft will wander.')); + } + final double? lat = _a.latitude, lon = _a.longitude; + if (lat != null && lon != null) { + final double d = HudGeometry.distance(lat, lon, pLat, pLon); + if (d > 200) checks.add(GateCheck.warn('The aircraft is ${d.round()} m from you: it flies to you first.')); + } + } + if (spec.altitude < FollowMeSpec.minAltitude) checks.add(const GateCheck.block('Follow at 10 m or higher.')); + if (spec.altitude < 20) checks.add(const GateCheck.warn('Low: trees and lamp posts along your way are not avoided.')); + checks.add(const GateCheck.warn('The aircraft does not avoid obstacles while it follows you.')); + return checks; + } + + Future startFollowMe(FollowMeSpec spec) async { + final List checks = checkFollowMe(spec); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.startFollowMe(lat: model.phoneLatitude!, lon: model.phoneLongitude!, altitude: spec.altitude, heading: spec.heading.sdk); + } + + /// Sends the phone's fix while following; ignored once it is not. + Future updateFollowMe() async { + final double? lat = model.phoneLatitude, lon = model.phoneLongitude; + if (!model.mission.followMe.active || lat == null || lon == null) return; + await dji.updateFollowMeTarget(lat, lon); + } + + // ── Orientation, Tripod, Cinematic (plan P7) ──────────────────────────────── + + /// Preconditions for switching Intelligent Orientation Control to [mode]. + List checkOrientation(OrientationMode mode) { + 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.')); + if (mode == OrientationMode.normal) return checks; + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for ${mode.label}.')); + if (mode == OrientationMode.homeLock) { + if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.')); + final double? d = _a.homeDistance; + if (d != null && d < homeLockMinDistance) checks.add(const GateCheck.block('Fly 10 m from home first: Home Lock needs a direction to pull back along.')); + } + final String fm = _a.flightModeEnum ?? ''; + if (fm == 'GPS_SPORT' || fm == 'ATTI') checks.add(const GateCheck.block('Switch the remote to P mode first.')); + checks.add(GateCheck.warn(mode == OrientationMode.courseLock + ? 'Forward on the stick is now the locked course, whichever way the aircraft faces.' + : 'Pulling back now always flies towards home, whichever way the aircraft faces.')); + return checks; + } + + Future setOrientation(OrientationMode mode) async { + final List checks = checkOrientation(mode); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.setFlightOrientationMode(mode.sdk); + } + + /// Course Lock takes the current heading as "forward". + Future lockCourse() async { + if (_a.orientationMode != 'COURSE_LOCK') throw const MissionRefused([GateCheck.block('Course Lock is not on.')]); + await dji.lockCourseUsingCurrentHeading(); + } + + /// Tripod (slow, fine control) or Cinematic (smooth braking) on or off. + /// Turning one on is a speed change the pilot feels at once, so it warns. + List checkSlowMode({required bool cinematic, required bool on}) => [ + if (!model.connected) const GateCheck.block('No aircraft connected.'), + if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none) + const GateCheck.block('Stop the intelligent flight mode that is running first.'), + if (model.connected && on) + GateCheck.warn(cinematic + ? 'The aircraft brakes and turns slowly: give it room to stop.' + : 'Speed drops to about 1 m/s and the sticks become very gentle.'), + ]; + + Future setSlowMode({required bool cinematic, required bool on}) async { + final List checks = checkSlowMode(cinematic: cinematic, on: on); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.setKey(DjiKey.flightController(cinematic ? 'CINEMATIC_MODE_ENABLED' : 'TRIPOD_MODE_ENABLED'), on); + } + + // ── Hyperlapse (plan P7) ──────────────────────────────────────────────────── + + /// Preconditions for a Free hyperlapse: a camera that shoots intervals, a + /// card with room, and nothing else shooting. + List checkHyperlapse(HyperlapseSpec spec) { + final List checks = []; + if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.')); + if (!model.profile.hasCamera) return checks..add(const GateCheck.block('This aircraft has no camera.')); + if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.')); + if (model.camera.shootingContinuous) checks.add(const GateCheck.block('The camera is already shooting a sequence.')); + final int? shots = model.camera.storage?.availableCaptureCount; + if (model.camera.storage != null && !model.camera.storage!.usable) { + checks.add(GateCheck.block('${model.camera.storage!.status}: the frames would not be saved.')); + } else if (shots != null && shots < spec.frames) { + checks.add(GateCheck.block('The card has room for $shots photos; this hyperlapse needs ${spec.frames}.')); + } + final int? left = _a.goHome?.remainingFlightTime; + if (left != null && left > 0 && left < spec.shootingSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) { + checks.add(GateCheck.warn('Shooting takes ${formatDuration(spec.shootingSeconds)}: more than the battery has to spare.')); + } + if (_a.isFlying != true) checks.add(const GateCheck.warn('The aircraft is on the ground: take off, then fly slowly and steadily.')); + return checks; + } + + /// Ends an open-ended (255) interval sequence once it has its frames. + Timer? _hyperlapseStop; + + /// Starts the interval sequence; the camera's own interval mode runs it. + Future startHyperlapse(HyperlapseSpec spec) async { + final List checks = checkHyperlapse(spec); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.setCameraMode('photo'); + await dji.setShootPhotoMode('INTERVAL'); + await dji.setPhotoTimeInterval(count: spec.cameraCount, seconds: spec.intervalSeconds); + await dji.startShootPhoto(); + _hyperlapseStop?.cancel(); + if (spec.cameraCount == 255) { + _hyperlapseStop = Timer(Duration(seconds: spec.shootingSeconds), () => dji.stopShootPhoto().catchError((Object _) {})); + } + } + + Future stopHyperlapse() { + _hyperlapseStop?.cancel(); + _hyperlapseStop = null; + return dji.stopShootPhoto(); + } + // ── Virtual sticks (plan P8) ──────────────────────────────────────────────── /// Preconditions for handing the aircraft to the on-screen sticks. @@ -691,6 +1009,10 @@ class SafetyGate { checks.add(const GateCheck.block('Stop ActiveTrack first.')); case ActiveMode.tapFly: checks.add(const GateCheck.block('Stop TapFly first.')); + case ActiveMode.hotpoint: + checks.add(const GateCheck.block('Stop Point of Interest first.')); + case ActiveMode.followMe: + checks.add(const GateCheck.block('Stop Follow Me first.')); case ActiveMode.other: checks.add(const GateCheck.block('Stop the intelligent flight mode first.')); case ActiveMode.none: @@ -791,6 +1113,10 @@ class SafetyGate { await dji.stopActiveTrack(); case ActiveMode.tapFly: await dji.stopTapFly(); + case ActiveMode.hotpoint: + await dji.stopHotpoint(); + case ActiveMode.followMe: + await dji.stopFollowMe(); case ActiveMode.none: case ActiveMode.other: break; // refused by check() diff --git a/Fly App/lib/fly/settings/settings_spec.dart b/Fly App/lib/fly/settings/settings_spec.dart index d8309f5..0bf7943 100644 --- a/Fly App/lib/fly/settings/settings_spec.dart +++ b/Fly App/lib/fly/settings/settings_spec.dart @@ -214,6 +214,41 @@ class KeyReadoutSpec extends SettingSpec { } } +/// A string the pilot types (aircraft name, a remote's name). [secret] masks +/// it and never shows the stored value; [maxLength] caps what is typed; +/// [send] replaces the plain `setKey` for values the SDK only takes through a +/// bespoke method. +class KeyTextSpec extends SettingSpec { + const KeyTextSpec({ + required super.id, + required super.label, + required DjiKey key, + this.secret = false, + this.maxLength = 32, + this.send, + super.hint, + super.requires, + super.visibleWhen, + super.groundOnly, + }) : _key = key; + + final DjiKey _key; + final bool secret; + final int maxLength; + final Future Function(DjiService dji, String value)? send; + + @override + DjiKey get key => _key; + + String? current(KeyState s) => asString(s.value(_key)); + + String shown(KeyState s) { + final String? v = current(s); + if (secret) return v == null || v.isEmpty ? '—' : '••••••'; + return v == null || v.isEmpty ? '—' : v; + } +} + /// A button that runs an argument-less action key (`START_PAIRING`). class KeyActionSpec extends SettingSpec { const KeyActionSpec({ @@ -295,6 +330,35 @@ String formatValue(Object? v) => switch (v) { /// `≈ 394 ft` for [m] metres. String feetHint(int m) => '≈ ${(m * 3.28084).round()} ft'; +/// One battery `WarningRecord` (as `KeyBridge.kt` maps it) → what went wrong. +String formatWarningRecord(Object? v) { + final Map? m = asMap(v); + if (m == null) return formatValue(v); + final List found = [ + if (asBool(m['overCurrent']) == true) 'over-current', + if (asBool(m['overHeated']) == true) 'overheated', + if (asBool(m['lowTemperature']) == true) 'too cold', + if (asBool(m['shortCircuit']) == true) 'short circuit', + if (asBool(m['underVoltage']) == true) 'under-voltage', + if ((asInt(m['lowVoltageCell']) ?? 0) > 0) 'low cell ${asInt(m['lowVoltageCell'])}', + if ((asInt(m['damagedCell']) ?? 0) > 0) 'damaged cell ${asInt(m['damagedCell'])}', + ]; + if (found.isEmpty) return asBool(m['error']) == true ? 'Error' : 'None'; + return found.join(', '); +} + +/// A list of `WarningRecord`s → how many flights had a warning, and the worst. +String formatWarningHistory(Object? v) { + if (v is! List) return formatValue(v); + if (v.isEmpty) return 'None'; + final List bad = [ + for (final Object? r in v) + if (formatWarningRecord(r) case final String s when s != 'None') s, + ]; + if (bad.isEmpty) return 'None in ${v.length} records'; + return '${bad.length} of ${v.length}: ${bad.first}'; +} + /// Millivolt cell list → `3.85 3.86 3.84 V`. String formatCells(Object? v) { if (v is! List || v.isEmpty) return '—'; diff --git a/Fly App/lib/fly/settings/settings_tabs.dart b/Fly App/lib/fly/settings/settings_tabs.dart index 2976510..f671bbc 100644 --- a/Fly App/lib/fly/settings/settings_tabs.dart +++ b/Fly App/lib/fly/settings/settings_tabs.dart @@ -31,6 +31,7 @@ abstract final class SettingsKeys { static const DjiKey lowBattery = DjiKey.flightController('LOW_BATTERY_WARNING_THRESHOLD'); static const DjiKey criticalBattery = DjiKey.flightController('SERIOUS_LOW_BATTERY_WARNING_THRESHOLD'); static const DjiKey fcSerial = DjiKey.flightController('SERIAL_NUMBER'); + static const DjiKey aircraftName = DjiKey.flightController('AIRCRAFT_NAME'); // Flight assistant (vision) static const DjiKey collision = DjiKey.flightAssistant('COLLISION_AVOIDANCE_ENABLED'); @@ -56,6 +57,25 @@ abstract final class SettingsKeys { static const DjiKey rcName = DjiKey.remoteController('DISPLAY_NAME'); static const DjiKey rcSerial = DjiKey.remoteController('SERIAL_NUMBER'); static const DjiKey rcFirmware = DjiKey.remoteController('FIRMWARE_VERSION'); + static const DjiKey customButton1 = DjiKey.remoteController('CUSTOM_BUTTON_1'); + + // Dual operator (master / slave) + static const DjiKey rcRole = DjiKey.remoteController('MODE'); + static const DjiKey rcLinkName = DjiKey.remoteController('NAME'); + static const DjiKey rcLinkPassword = DjiKey.remoteController('PASSWORD'); + static const DjiKey masterSlaveSupported = DjiKey.remoteController('IS_MASTER_SLAVE_MODE_SUPPORTED'); + static const DjiKey masterSlaveState = DjiKey.remoteController('MASTER_SLAVE_STATE'); + + // Secondary video output (Lightbridge remotes / CrystalSky HDMI, Cendence SDI) + static const DjiKey secondaryOut = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_ENABLED'); + static const DjiKey secondaryPort = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_PORT'); + static const DjiKey secondaryDisplay = DjiKey.remoteController('SECONDARY_VIDEO_DISPLAY_MODE'); + static const DjiKey secondaryOsd = DjiKey.remoteController('SECONDARY_VIDEO_OSD_ENABLED'); + static const DjiKey secondaryOsdUnit = DjiKey.remoteController('SECONDARY_VIDEO_OSD_UNIT'); + static const DjiKey hdmiFormat = DjiKey.remoteController('HDMI_OUTPUT_FORMAT'); + static const DjiKey sdiFormat = DjiKey.remoteController('SDI_OUTPUT_FORMAT'); + static const DjiKey pipPosition = DjiKey.remoteController('PIP_POSITION'); + static DjiKey osdMargin(String side) => DjiKey.remoteController('SECONDARY_VIDEO_OSD_${side}_MARGIN'); // Image transmission static const DjiKey uplink = DjiKey.airLink('UPLINK_SIGNAL_QUALITY'); @@ -76,6 +96,8 @@ abstract final class SettingsKeys { static const DjiKey wifiSsid = DjiKey.wifi('SSID'); static const DjiKey wifiBand = DjiKey.wifi('WIFI_FREQUENCY_BAND'); static const DjiKey wifiChannel = DjiKey.wifi('CHANNEL_NUMBER'); + static const DjiKey wifiPassword = DjiKey.wifi('PASSWORD'); + static const DjiKey wifiReboot = DjiKey.wifi('REBOOT'); // Product static const DjiKey firmwarePackage = DjiKey.product('FIRMWARE_PACKAGE_VERSION'); @@ -111,12 +133,38 @@ abstract final class SettingsTabs { static const String liveStream = 'liveStream'; static const String about = 'about'; static const String developer = 'developer'; + static const String units = 'units'; + static const String customButtons = 'customButtons'; + static const String masterSearch = 'masterSearch'; + static const String slaveList = 'slaveList'; + static const String spectrum = 'spectrum'; + static const String videoCache = 'videoCache'; + static const String flightLogs = 'flightLogs'; + static const String batteryHistory = 'batteryHistory'; static bool _isOn(KeyState s, DjiKey k) => asBool(s.value(k)) == true; static bool _manual(KeyState s, DjiKey k) => asString(s.value(k)) == 'MANUAL'; + /// The remote's dual-operator role; `NORMAL` until it says otherwise. + static String _role(KeyState s) => asString(s.value(SettingsKeys.rcRole)) ?? 'NORMAL'; + static const List<(String, String)> _channelModes = <(String, String)>[('AUTO', 'Auto'), ('MANUAL', 'Custom')]; + static const List<(String, String)> _videoFormats = <(String, String)>[ + ('RESOLUTION_1080P_60FPS', '1080p60'), + ('RESOLUTION_1080P_50FPS', '1080p50'), + ('RESOLUTION_1080P_30FPS', '1080p30'), + ('RESOLUTION_1080P_25FPS', '1080p25'), + ('RESOLUTION_1080P_24FPS', '1080p24'), + ('RESOLUTION_1080I_60FPS', '1080i60'), + ('RESOLUTION_1080I_50FPS', '1080i50'), + ('RESOLUTION_720P_60FPS', '720p60'), + ('RESOLUTION_720P_50FPS', '720p50'), + ('RESOLUTION_720P_30FPS', '720p30'), + ('RESOLUTION_720P_25FPS', '720p25'), + ('RESOLUTION_720P_24FPS', '720p24'), + ]; + /// The tabs for [p]; battery groups repeat per battery. static List all(ProductProfile p) => [ SettingsTab(id: safety, label: 'Safety', icon: 'shield', rows: _safety), @@ -311,6 +359,135 @@ abstract final class SettingsTabs { max: 100, step: 5, ), + GroupSpec('BUTTONS', requires: HudFeatures.customButtons), + CustomSpec(id: customButtons, requires: HudFeatures.customButtons, keys: const [SettingsKeys.customButton1]), + // ── Dual operator ───────────────────────────────────────────────────────── + GroupSpec('DUAL OPERATOR', requires: HudFeatures.masterSlaveBadge), + KeyChoiceSpec( + id: 'rcRole', + label: 'Remote role', + hint: 'Master flies; slave runs the gimbal and camera', + key: SettingsKeys.rcRole, + options: const <(String, String)>[('NORMAL', 'Single'), ('MASTER', 'Master'), ('SLAVE', 'Slave')], + requires: HudFeatures.masterSlaveBadge, + groundOnly: true, + ), + KeyTextSpec( + id: 'rcLinkName', + label: 'Remote name', + hint: 'How the other remote finds this one', + key: SettingsKeys.rcLinkName, + maxLength: 6, + requires: HudFeatures.masterSlaveBadge, + visibleWhen: (SettingsContext c) => _role(c.keys) != 'NORMAL', + ), + KeyTextSpec( + id: 'rcLinkPassword', + label: 'Connection password', + hint: 'Four digits the slave types to join', + key: SettingsKeys.rcLinkPassword, + secret: true, + maxLength: 4, + requires: HudFeatures.masterSlaveBadge, + visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER', + ), + CustomSpec( + id: masterSearch, + requires: HudFeatures.masterSlaveBadge, + visibleWhen: (SettingsContext c) => _role(c.keys) == 'SLAVE', + ), + CustomSpec( + id: slaveList, + requires: HudFeatures.masterSlaveBadge, + visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER', + ), + // ── Secondary video output (HDMI / SDI) ─────────────────────────────────── + GroupSpec('VIDEO OUTPUT', requires: HudFeatures.linkLightbridge), + KeySwitchSpec( + id: 'secondaryOut', + label: 'HDMI / SDI output', + hint: "The remote's video out to a monitor or recorder", + key: SettingsKeys.secondaryOut, + requires: HudFeatures.linkLightbridge, + ), + KeyChoiceSpec( + id: 'secondaryPort', + label: 'Output port', + key: SettingsKeys.secondaryPort, + options: const <(String, String)>[('HDMI', 'HDMI'), ('SDI', 'SDI')], + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut), + ), + KeyChoiceSpec( + id: 'hdmiFormat', + label: 'HDMI format', + key: SettingsKeys.hdmiFormat, + options: _videoFormats, + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) != 'SDI', + ), + KeyChoiceSpec( + id: 'sdiFormat', + label: 'SDI format', + key: SettingsKeys.sdiFormat, + options: _videoFormats, + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) == 'SDI', + ), + KeyChoiceSpec( + id: 'secondaryDisplay', + label: 'Picture', + hint: 'Which feed, and which one inset (dual-camera aircraft)', + key: SettingsKeys.secondaryDisplay, + options: const <(String, String)>[ + ('SOURCE_1_ONLY', 'Feed 1'), + ('SOURCE_2_ONLY', 'Feed 2'), + ('SOURCE_1_MAIN', 'Feed 1 + inset'), + ('SOURCE_2_MAIN', 'Feed 2 + inset'), + ], + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut), + ), + KeyChoiceSpec( + id: 'pipPosition', + label: 'Inset position', + key: SettingsKeys.pipPosition, + options: const <(String, String)>[ + ('TOP_LEFT', 'Top left'), + ('TOP_RIGHT', 'Top right'), + ('BOTTOM_LEFT', 'Bottom left'), + ('BOTTOM_RIGHT', 'Bottom right'), + ], + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => + _isOn(c.keys, SettingsKeys.secondaryOut) && (asString(c.keys.value(SettingsKeys.secondaryDisplay)) ?? '').endsWith('_MAIN'), + ), + KeySwitchSpec( + id: 'secondaryOsd', + label: 'Show flight data (OSD)', + key: SettingsKeys.secondaryOsd, + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut), + ), + KeyChoiceSpec( + id: 'secondaryOsdUnit', + label: 'OSD units', + key: SettingsKeys.secondaryOsdUnit, + options: const <(String, String)>[('METRIC', 'Metric'), ('IMPERIAL', 'Imperial')], + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd), + ), + for (final (String side, String label) in const <(String, String)>[('TOP', 'top'), ('BOTTOM', 'bottom'), ('LEFT', 'left'), ('RIGHT', 'right')]) + KeyNumberSpec( + id: 'osdMargin$side', + label: 'OSD margin, $label', + key: SettingsKeys.osdMargin(side), + min: 0, + max: 50, + unit: 'px', + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd), + ), const GroupSpec('CONTROLLER'), const KeyChoiceSpec( id: 'chargeMobile', @@ -347,6 +524,7 @@ abstract final class SettingsTabs { static final List _transmission = [ const KeyReadoutSpec(id: 'uplink', label: 'Control signal', key: SettingsKeys.uplink, format: _percent), const KeyReadoutSpec(id: 'downlink', label: 'Video signal', key: SettingsKeys.downlink, format: _percent), + const CustomSpec(id: spectrum), // OcuSync GroupSpec('OCUSYNC', requires: HudFeatures.linkOcuSync), KeyReadoutSpec(id: 'osDataRate', label: 'Video bitrate', key: SettingsKeys.osDataRate, format: _mbps, requires: HudFeatures.linkOcuSync), @@ -433,7 +611,40 @@ abstract final class SettingsTabs { ), // Wi-Fi GroupSpec('WI-FI', requires: HudFeatures.linkWifi), - KeyReadoutSpec(id: 'wifiSsid', label: 'Network', key: SettingsKeys.wifiSsid, requires: HudFeatures.linkWifi), + KeyTextSpec( + id: 'wifiSsid', + label: 'Network name', + hint: 'Takes effect after Restart Wi-Fi', + key: SettingsKeys.wifiSsid, + requires: HudFeatures.linkWifi, + groundOnly: true, + send: (DjiService dji, String v) => dji.setWifiCredentials(ssid: v), + ), + KeyTextSpec( + id: 'wifiPassword', + label: 'Wi-Fi password', + hint: 'At least 8 characters; takes effect after Restart Wi-Fi', + key: SettingsKeys.wifiPassword, + secret: true, + maxLength: 63, + requires: HudFeatures.linkWifi, + groundOnly: true, + send: (DjiService dji, String v) { + if (v.length < 8) throw ArgumentError('The password needs at least 8 characters'); + return dji.setWifiCredentials(password: v); + }, + ), + KeyActionSpec( + id: 'wifiReboot', + label: 'Restart Wi-Fi', + hint: 'Applies a new name or password; the phone has to join the network again', + key: SettingsKeys.wifiReboot, + button: 'Restart', + requires: HudFeatures.linkWifi, + groundOnly: true, + confirmTitle: 'Restart the aircraft Wi-Fi', + confirmText: 'The connection drops. Join the network again in the phone\'s Wi-Fi settings, with the new name or password if you changed them.', + ), KeyChoiceSpec( id: 'wifiBand', label: 'Frequency band', @@ -481,6 +692,66 @@ abstract final class SettingsTabs { }, ), for (int i = 0; i < n; i++) ..._batteryRows(i, n), + // Inspire 2 / M200: the two packs fly as a pair and must be matched. + GroupSpec('BATTERY PAIR', requires: HudFeatures.dualBattery), + KeyReadoutSpec( + id: 'pairingState', + label: 'Pairing', + key: SettingsKeys.battery('PAIRING_STATE', 0), + requires: HudFeatures.dualBattery, + ), + KeyActionSpec( + id: 'pairBatteries', + label: 'Pair the two batteries', + hint: 'After swapping a pack: the pair then ages and balances together', + key: SettingsKeys.battery('PAIR_BATTERIES', 0), + button: 'Pair', + requires: HudFeatures.dualBattery, + groundOnly: true, + confirmTitle: 'Pair batteries', + confirmText: 'The two batteries in the aircraft are recorded as a pair. Use two packs of similar age and charge.', + visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.battery('PAIRING_STATE', 0))) != 'PAIRED', + ), + // Non-smart packs (A3 / N3 / M600 on plain LiPos): the FC judges cells by voltage. + GroupSpec('CELL VOLTAGE', requires: HudFeatures.nonSmartBattery), + KeyNumberSpec( + id: 'cellCount', + label: 'Number of cells', + key: SettingsKeys.battery('NUMBER_OF_CELLS', 0), + min: 3, + max: 12, + unit: 'S', + requires: HudFeatures.nonSmartBattery, + groundOnly: true, + ), + for (final int level in const [1, 2]) ...[ + KeyNumberSpec( + id: 'cellLevel$level', + label: level == 1 ? 'Low cell voltage' : 'Critical cell voltage', + key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_THRESHOLD', 0), + min: level == 1 ? 3500 : 3300, + max: level == 1 ? 3900 : 3700, + step: 10, + unit: 'mV', + requires: HudFeatures.nonSmartBattery, + bounds: level == 1 + ? (KeyState s, int lo, int hi) { + final int? l2 = asInt(s.value(SettingsKeys.battery('LEVEL_2_CELL_VOLTAGE_THRESHOLD', 0))); + return (l2 == null ? lo : (l2 + 50).clamp(lo, hi), hi); + } + : (KeyState s, int lo, int hi) { + final int? l1 = asInt(s.value(SettingsKeys.battery('LEVEL_1_CELL_VOLTAGE_THRESHOLD', 0))); + return (lo, l1 == null ? hi : (l1 - 50).clamp(lo, hi)); + }, + ), + KeyChoiceSpec( + id: 'cellBehavior$level', + label: level == 1 ? 'At low voltage' : 'At critical voltage', + key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_BEHAVIOR', 0), + options: const <(String, String)>[('FLASH_LED', 'Flash LEDs'), ('GO_HOME', 'Return home'), ('LAND', 'Land')], + requires: HudFeatures.nonSmartBattery, + ), + ], ]; } @@ -504,19 +775,42 @@ abstract final class SettingsTabs { ), KeyReadoutSpec(id: 'batterySerial$i', label: 'Serial number', key: k('SERIAL_NUMBER')), KeyReadoutSpec(id: 'batteryFirmware$i', label: 'Firmware', key: k('FIRMWARE_VERSION')), + KeyReadoutSpec(id: 'batteryLatest$i', label: 'Latest warning', key: k('LATEST_WARNING_RECORD'), format: formatWarningRecord), + KeyReadoutSpec( + id: 'batteryHistory$i', + label: 'Warning history', + hint: 'What the pack recorded over its last flights', + key: k('HISTORICAL_WARNING_RECORDS'), + format: formatWarningHistory, + ), ]; } // ── General ───────────────────────────────────────────────────────────────── static final List _general = [ + const GroupSpec('UNITS'), + const CustomSpec(id: units), const GroupSpec('MAP'), const CustomSpec(id: showRoute), const CustomSpec(id: clearRoute), + GroupSpec('GEO UNLOCKS', requires: HudFeatures.flyZones), + CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones), + GroupSpec('VIDEO CACHE', requires: HudFeatures.cameraControls), + CustomSpec(id: videoCache, requires: HudFeatures.cameraControls), + const GroupSpec('FLIGHT RECORDS'), + const CustomSpec(id: flightLogs), GroupSpec('LIVE STREAMING', requires: HudFeatures.liveStream), CustomSpec(id: liveStream, requires: HudFeatures.liveStream), GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks), CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks), const GroupSpec('ABOUT'), + const KeyTextSpec( + id: 'aircraftName', + label: 'Aircraft name', + hint: 'Shown in the logbook and to other DJI apps', + key: SettingsKeys.aircraftName, + maxLength: 32, + ), const CustomSpec(id: about, keys: [SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]), const GroupSpec('DEVELOPER'), const CustomSpec(id: developer), diff --git a/Fly App/lib/fly/state/aircraft_state.dart b/Fly App/lib/fly/state/aircraft_state.dart index 873b582..525588c 100644 --- a/Fly App/lib/fly/state/aircraft_state.dart +++ b/Fly App/lib/fly/state/aircraft_state.dart @@ -59,6 +59,70 @@ class GoHomeAssessment { batteryNeededToLand, maxRadiusCanFlyAndGoHome, smartRTHState, smartRTHCountdown); } +/// The RTK module's fix (`rtk` group), on aircraft that have one. +@immutable +class RtkStatus { + const RtkStatus({this.solution, this.inUse, this.satellites, this.error}); + + factory RtkStatus.fromMap(Map m) => RtkStatus( + solution: asString(m['solution']), + inUse: asBool(m['inUse']), + satellites: asInt(m['satellites']), + error: asString(m['error']), + ); + + /// `NONE` / `SINGLE_POINT` / `FLOAT` / `FIXED_POINT`. + final String? solution; + final bool? inUse; + final int? satellites; + final String? error; + + /// GO 4's chip text: FIX, FLOAT, SINGLE or a dash. + String get label => switch (solution) { + 'FIXED_POINT' => 'FIX', + 'FLOAT' => 'FLOAT', + 'SINGLE_POINT' => 'SINGLE', + _ => '—', + }; + + bool get fixed => solution == 'FIXED_POINT'; + + @override + bool operator ==(Object other) => + other is RtkStatus && other.solution == solution && other.inUse == inUse && other.satellites == satellites && other.error == error; + + @override + int get hashCode => Object.hash(solution, inUse, satellites, error); +} + +/// One compass's magnetic reading (`compassSensors`), for the interference meter. +@immutable +class CompassSensor { + const CompassSensor(this.index, this.state, this.value); + + final int index; + + /// `NORMAL_MODULUS` / `WEAK_MODULUS` / `SERIOUS_MODULUS` / `UNCALIBRATED` / … + final String? state; + + /// The field's modulus as the flight controller reports it (raw units). + final int? value; + + /// 0 = clean, 1 = weak interference, 2 = serious, null = not graded. + int? get level => switch (state) { + 'NORMAL_MODULUS' => 0, + 'WEAK_MODULUS' => 1, + 'SERIOUS_MODULUS' || 'DATA_EXCEPTION' || 'DIRECTION_EXCEPTION' => 2, + _ => null, + }; + + @override + bool operator ==(Object other) => other is CompassSensor && other.index == index && other.state == state && other.value == value; + + @override + int get hashCode => Object.hash(index, state, value); +} + /// The flight controller's state: position, motion, attitude, flight mode and /// the safety flags. Fed from the flat keys of every `telemetry` tick (~10 Hz). class AircraftState extends ChangeNotifier { @@ -128,6 +192,12 @@ class AircraftState extends ChangeNotifier { /// `NOT_CALIBRATING` / `HORIZONTAL` / `VERTICAL` / `SUCCESSFUL` / `FAILED`. String? compassCalibrationState; + /// Per-compass interference readings; empty until the compass reports. + List compassSensors = const []; + + /// The RTK fix, on aircraft with the module; null otherwise. + RtkStatus? rtk; + DateTime? updatedAt; /// VPS height when the sensor is actually in use, else null. @@ -180,6 +250,11 @@ class AircraftState extends ChangeNotifier { compassHasError = asBool(t['compassHasError']); compassCalibrating = asBool(t['compassCalibrating']); compassCalibrationState = asString(t['compassCalibrationState']); + compassSensors = [ + for (final Map c in asMapList(t['compassSensors'])) CompassSensor(asInt(c['index']) ?? 0, asString(c['state']), asInt(c['value'])), + ]; + final Map? r = asMap(t['rtk']); + rtk = r == null ? null : RtkStatus.fromMap(r); updatedAt = DateTime.now(); notifyListeners(); } @@ -196,6 +271,8 @@ class AircraftState extends ChangeNotifier { horizontalSpeed = verticalSpeed = heading = pitch = roll = velocityX = velocityY = velocityZ = null; goHomeHeight = ultrasonicHeight = null; goHome = null; + compassSensors = const []; + rtk = null; updatedAt = null; notifyListeners(); } diff --git a/Fly App/lib/fly/state/fly_zone_state.dart b/Fly App/lib/fly/state/fly_zone_state.dart index 8214058..143e945 100644 --- a/Fly App/lib/fly/state/fly_zone_state.dart +++ b/Fly App/lib/fly/state/fly_zone_state.dart @@ -322,7 +322,15 @@ class FlyZoneState extends ChangeNotifier { } void applyDatabase(Map m) { - database = FlyZoneDatabase.fromMap(m); + final FlyZoneDatabase next = FlyZoneDatabase.fromMap(m); + // A read that came before the remote check keeps the version heard since. + database = next.preciseRemoteVersion == null ? next.withRemote(database?.preciseRemoteVersion) : next; + notifyListeners(); + } + + /// The SDK heard which precise database DJI's server has (`flyZoneDbRemote`). + void applyRemoteVersion(String? v) { + database = (database ?? const FlyZoneDatabase()).withRemote(v); notifyListeners(); } @@ -424,13 +432,14 @@ class FlyZoneLicence { /// The GEO database's versions and state (`FlyZoneDatabaseState` names). @immutable class FlyZoneDatabase { - const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState}); + const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState, this.preciseRemoteVersion}); factory FlyZoneDatabase.fromMap(Map m) => FlyZoneDatabase( basicVersion: asString(m['basicVersion']), basicState: asString(m['basicState']), preciseVersion: asString(m['preciseVersion']), preciseState: asString(m['preciseState']), + preciseRemoteVersion: asString(m['preciseRemoteVersion']), ); final String? basicVersion; @@ -438,7 +447,22 @@ class FlyZoneDatabase { final String? preciseVersion; final String? preciseState; - bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE'; + /// The newest precise database on DJI's server, once the SDK has asked. + final String? preciseRemoteVersion; + + FlyZoneDatabase withRemote(String? v) => FlyZoneDatabase( + basicVersion: basicVersion, + basicState: basicState, + preciseVersion: preciseVersion, + preciseState: preciseState, + preciseRemoteVersion: v, + ); + + /// DJI has a newer precise database than the aircraft's (it updates with the firmware). + bool get preciseBehind => + preciseRemoteVersion != null && preciseVersion != null && preciseRemoteVersion!.isNotEmpty && preciseRemoteVersion != preciseVersion; + + bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE' || preciseBehind; static String stateLabel(String? s) => switch (s) { 'UP_TO_DATE' => 'Up to date', diff --git a/Fly App/lib/fly/state/link_extras.dart b/Fly App/lib/fly/state/link_extras.dart new file mode 100644 index 0000000..8fc0014 --- /dev/null +++ b/Fly App/lib/fly/state/link_extras.dart @@ -0,0 +1,84 @@ +import 'package:flutter/foundation.dart'; + +import '../rc_buttons.dart'; +import 'parse.dart'; + +/// One press of a remote's custom button (`rcButton` event). [seq] grows with +/// every press, so two presses of the same button are two values. +@immutable +class RcButtonPress { + const RcButtonPress(this.button, this.seq); + + final RcButton button; + final int seq; + + @override + bool operator ==(Object other) => other is RcButtonPress && other.button == button && other.seq == seq; + + @override + int get hashCode => Object.hash(button, seq); +} + +/// A remote seen by master search, or linked as a slave (`rcMasters`, `getSlaves`). +@immutable +class RcPeer { + const RcPeer({required this.id, this.name, this.signal}); + + factory RcPeer.fromMap(Map m) => RcPeer(id: asInt(m['id']) ?? 0, name: asString(m['name']), signal: asInt(m['signal'])); + + final int id; + final String? name; + + /// 0–100. + final int? signal; + + @override + bool operator ==(Object other) => other is RcPeer && other.id == id && other.name == name && other.signal == signal; + + @override + int get hashCode => Object.hash(id, name, signal); +} + +/// One bar of the video link's spectrum chart. +@immutable +class SpectrumBar { + const SpectrumBar(this.label, this.power); + + /// Channel number, or the band's centre in MHz. + final String label; + + /// dBm (OcuSync RSSI, Lightbridge / Wi-Fi channel power); more negative is quieter. + final double power; +} + +/// The video link's interference (`spectrum` events, while the chart is up). +@immutable +class Spectrum { + const Spectrum(this.link, this.bars); + + /// `ocusync` / `lightbridge` / `wifi`. + final String link; + final List bars; + + factory Spectrum.fromEvent(Map e) { + final List bars = [ + for (final Map b in asMapList(e['bands'])) + if (finite(b['from']) case final double from) + if (finite(b['to']) case final double to) + if (finite(b['rssi']) case final double rssi) SpectrumBar(((from + to) / 2).toStringAsFixed(0), rssi), + for (final Map c in asMapList(e['channels'])) + if (asInt(c['channel']) case final int ch) + if (finite(c['power']) case final double p) SpectrumBar('$ch', p), + ]; + return Spectrum(asString(e['link']) ?? '', bars); + } + + /// The quietest bar, the channel to pick in Custom mode. + SpectrumBar? get quietest { + SpectrumBar? best; + for (final SpectrumBar b in bars) { + if (best == null || b.power < best.power) best = b; + } + return best; + } +} diff --git a/Fly App/lib/fly/state/mission_state.dart b/Fly App/lib/fly/state/mission_state.dart index 67f6236..e4b6e16 100644 --- a/Fly App/lib/fly/state/mission_state.dart +++ b/Fly App/lib/fly/state/mission_state.dart @@ -42,7 +42,7 @@ class WaypointProgress { final int? target; final bool reached; - /// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …). + /// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …). final String? executeState; bool get uploading => state == 'UPLOADING'; @@ -57,7 +57,7 @@ class WaypointProgress { bool get available => state == 'READY_TO_UPLOAD' || state == 'READY_TO_EXECUTE'; bool get unsupported => state == 'NOT_SUPPORTED'; - /// 0…1 through the upload, or null outside it. + /// 0…1 through the upload, or null outside it. double? get uploadFraction { if (!uploading || total == null || total == 0) return null; return (((uploaded ?? -1) + 1) / total!).clamp(0.0, 1.0); @@ -100,8 +100,12 @@ class ActiveTrackProgress { this.subjects = const [], this.autoSensingSupported = false, this.autoSensing = false, + this.percent, }); + /// A QuickShot's progress, 0–100, while it flies. + final int? percent; + /// Subjects the aircraft found itself (auto-sensing), to tap instead of drawing. final List subjects; final bool autoSensingSupported; @@ -128,6 +132,7 @@ class ActiveTrackProgress { ], autoSensingSupported: asBool(e['autoSensingSupported']) ?? previous.autoSensingSupported, autoSensing: asBool(e['autoSensing']) ?? previous.autoSensing, + percent: t == null ? (keep ? previous.percent : null) : asInt(t['percent']), ); } @@ -150,10 +155,10 @@ class ActiveTrackProgress { /// MSDK `ActiveTrackState`. final String state; - /// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM. + /// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM. final String? targetState; - /// HUMAN, CAR, VAN, BIKE, BOAT, … + /// HUMAN, CAR, VAN, BIKE, BOAT, … final String? targetType; /// The subject's box, 0..1 across the live view. @@ -206,10 +211,11 @@ class ActiveTrackProgress { other.reason == reason && listEquals(other.subjects, subjects) && other.autoSensingSupported == autoSensingSupported && - other.autoSensing == autoSensing; + other.autoSensing == autoSensing && + other.percent == percent; @override - int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing); + int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing, percent); } /// One subject auto-sensing found: its operator index and box (0..1 of the picture). @@ -284,6 +290,71 @@ class TapFlyProgress { int get hashCode => Object.hash(state, image, bypass, speed, relativeHeading); } +/// The Point of Interest operator as last reported (plan P7). +@immutable +class HotpointProgress { + const HotpointProgress({this.state = 'UNKNOWN', this.radius, this.maxAngularVelocity}); + + factory HotpointProgress.fromMap(Map e, HotpointProgress previous) => HotpointProgress( + state: asString(e['state']) ?? previous.state, + radius: finite(e['radius']) ?? previous.radius, + maxAngularVelocity: finite(e['maxAngularVelocity']) ?? previous.maxAngularVelocity, + ); + + /// `HotpointMissionState`: UNKNOWN, DISCONNECTED, NOT_SUPPORTED, RECOVERING, + /// READY_TO_EXECUTE, INITIAL_PHASE (flying to the circle), EXECUTING, EXECUTION_PAUSED. + final String state; + final double? radius; + final double? maxAngularVelocity; + + bool get active => state == 'INITIAL_PHASE' || state == 'EXECUTING' || state == 'EXECUTION_PAUSED'; + bool get paused => state == 'EXECUTION_PAUSED'; + bool get unsupported => state == 'NOT_SUPPORTED'; + + String get label => switch (state) { + 'INITIAL_PHASE' => 'Flying to the circle', + 'EXECUTING' => 'Circling', + 'EXECUTION_PAUSED' => 'Paused', + 'READY_TO_EXECUTE' => 'Ready', + 'NOT_SUPPORTED' => 'Not supported', + _ => 'Set up', + }; + + @override + bool operator ==(Object other) => + other is HotpointProgress && other.state == state && other.radius == radius && other.maxAngularVelocity == maxAngularVelocity; + + @override + int get hashCode => Object.hash(state, radius, maxAngularVelocity); +} + +/// The Follow Me operator as last reported (plan P7). +@immutable +class FollowMeProgress { + const FollowMeProgress({this.state = 'UNKNOWN', this.distance}); + + factory FollowMeProgress.fromMap(Map e, FollowMeProgress previous) => FollowMeProgress( + state: asString(e['state']) ?? previous.state, + distance: finite(e['distance']) ?? (asString(e['state']) == 'EXECUTING' ? previous.distance : null), + ); + + /// `FollowMeMissionState`: … READY_TO_EXECUTE, EXECUTING, GOT_STUCK, NOT_READY. + final String state; + + /// Metres from the aircraft to the phone, while following. + final double? distance; + + bool get active => state == 'EXECUTING' || state == 'GOT_STUCK'; + bool get stuck => state == 'GOT_STUCK'; + bool get unsupported => state == 'NOT_SUPPORTED'; + + @override + bool operator ==(Object other) => other is FollowMeProgress && other.state == state && other.distance == distance; + + @override + int get hashCode => Object.hash(state, distance); +} + /// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events. /// /// Waypoint events carry `operator: 'waypoint'` and the operator's own state @@ -293,6 +364,12 @@ class MissionState extends ChangeNotifier { WaypointProgress waypoint = const WaypointProgress(); ActiveTrackProgress activeTrack = const ActiveTrackProgress(); TapFlyProgress tapFly = const TapFlyProgress(); + HotpointProgress hotpoint = const HotpointProgress(); + FollowMeProgress followMe = const FollowMeProgress(); + + /// How the last POI / Follow Me ended, like [waypointResult]; bumped in [modeFinishes]. + String? modeResult; + int modeFinishes = 0; /// How the last waypoint mission ended: null while none has, `''` when it /// finished cleanly, else the SDK's error. @@ -304,11 +381,13 @@ class MissionState extends ChangeNotifier { String _legacy = 'idle'; String? error; - /// idle / ready / uploading / executing / tracking / quickshot / tapfly. + /// idle / ready / uploading / executing / tracking / quickshot / tapfly / hotpoint / followme. String get state { if (waypoint.active) return 'executing'; if (activeTrack.active) return activeTrack.state == 'PERFORMING_QUICK_SHOT' ? 'quickshot' : 'tracking'; if (tapFly.active) return 'tapfly'; + if (hotpoint.active) return 'hotpoint'; + if (followMe.active) return 'followme'; if (_legacy != 'idle') return _legacy; if (waypoint.uploading) return 'uploading'; if (waypoint.readyToExecute) return 'ready'; @@ -346,12 +425,36 @@ class MissionState extends ChangeNotifier { if (err != null) error = err; notifyListeners(); return; + case 'hotpoint': + final HotpointProgress next = HotpointProgress.fromMap(e, hotpoint); + if (!_finish(e) && next == hotpoint) return; + hotpoint = next; + notifyListeners(); + return; + case 'followMe': + final FollowMeProgress next = FollowMeProgress.fromMap(e, followMe); + if (!_finish(e) && next == followMe) return; + followMe = next; + notifyListeners(); + return; } _legacy = asString(e['state']) ?? 'idle'; error = asString(e['error']); notifyListeners(); } + /// Records a POI / Follow Me `finish` (and its error); true when the event said anything. + bool _finish(Map e) { + final String? err = asString(e['error']); + if (err != null) error = err; + if (asString(e['event']) == 'finish') { + modeResult = err ?? ''; + modeFinishes++; + return true; + } + return err != null; + } + void _applyWaypoint(Map e) { final WaypointProgress next = WaypointProgress.fromMap(e, waypoint); final String? event = asString(e['event']); @@ -374,6 +477,8 @@ class MissionState extends ChangeNotifier { waypoint = const WaypointProgress(); activeTrack = const ActiveTrackProgress(); tapFly = const TapFlyProgress(); + hotpoint = const HotpointProgress(); + followMe = const FollowMeProgress(); notifyListeners(); } } diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index 318a864..5b99bbb 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -1,6 +1,8 @@ import 'dart:ui' show Size; import '../flight_model.dart'; +import 'rc_buttons.dart'; +import 'state/link_extras.dart'; import 'state/live_stream_state.dart' show MediaPlayback; import 'state/parse.dart'; @@ -66,6 +68,19 @@ class TelemetryReducer { case 'mediaPlayback': model.mediaPlayback.value = MediaPlayback.fromEvent(event); return true; + case 'rcButton': + final RcButton? b = RcButton.fromEvent(event['button']); + if (model.connected && b != null) model.rcButton.value = RcButtonPress(b, (model.rcButton.value?.seq ?? 0) + 1); + return true; + case 'rcMasters': + model.rcMasters.value = [for (final Map m in asMapList(event['masters'])) RcPeer.fromMap(m)]; + return true; + case 'spectrum': + model.spectrum.value = Spectrum.fromEvent(event); + return true; + case 'flyZoneDbRemote': + model.flyZones.applyRemoteVersion(asString(event['preciseRemoteVersion'])); + 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()); diff --git a/Fly App/lib/fly/units.dart b/Fly App/lib/fly/units.dart new file mode 100644 index 0000000..6d72a77 --- /dev/null +++ b/Fly App/lib/fly/units.dart @@ -0,0 +1,48 @@ +/// Metric or imperial readouts (GO 4's General → Units, plan P6). +/// +/// The aircraft always reports metres and m/s, and every setting is still +/// written in metres; only what the pilot reads changes. +enum UnitSystem { + metric('Metric (m, m/s)'), + imperial('Imperial (ft, mph)'); + + const UnitSystem(this.label); + final String label; +} + +abstract final class Units { + static const double feetPerMetre = 3.28084; + static const double mphPerMs = 2.23694; + static const double kmhPerMs = 3.6; + + /// A height or short distance: `12 m` / `39 ft`. + static String length(double? m, UnitSystem u, {int decimals = 0}) { + if (m == null) return '—'; + return u == UnitSystem.metric ? '${m.toStringAsFixed(decimals)} m' : '${(m * feetPerMetre).toStringAsFixed(decimals)} ft'; + } + + /// A distance that can run long: metres then km, or feet then miles. + static String distance(double? m, UnitSystem u) { + if (m == null) return '—'; + if (u == UnitSystem.metric) return m < 1000 ? '${m.round()} m' : '${(m / 1000).toStringAsFixed(m < 10000 ? 2 : 1)} km'; + final double ft = m * feetPerMetre; + return ft < 5280 ? '${ft.round()} ft' : '${(ft / 5280).toStringAsFixed(ft < 52800 ? 2 : 1)} mi'; + } + + /// A speed: `8.2 m/s` / `18.3 mph`. + static String speed(double? ms, UnitSystem u, {int decimals = 1}) { + if (ms == null) return '—'; + return u == UnitSystem.metric ? '${ms.toStringAsFixed(decimals)} m/s' : '${(ms * mphPerMs).toStringAsFixed(decimals)} mph'; + } + + /// The bare number for a HUD readout whose unit is drawn apart. + static double lengthValue(double m, UnitSystem u) => u == UnitSystem.metric ? m : m * feetPerMetre; + static double speedValue(double ms, UnitSystem u) => u == UnitSystem.metric ? ms : ms * mphPerMs; + + static String lengthUnit(UnitSystem u) => u == UnitSystem.metric ? 'm' : 'ft'; + static String speedUnit(UnitSystem u) => u == UnitSystem.metric ? 'm/s' : 'mph'; + + /// The other system's equivalent beside a metric setting: `≈ 394 ft` / `≈ 120 m`. + static String altHint(int metres, UnitSystem u) => + u == UnitSystem.metric ? '≈ ${(metres * feetPerMetre).round()} ft' : '${(metres * feetPerMetre).round()} ft'; +} diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 4b10b76..c2a273f 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -379,7 +379,13 @@ class _HomePageState extends State { Navigator.of(context).push(MaterialPageRoute( builder: (_) => FlyPrefs.classicHud.value ? FlightControlPage(model: _model, dji: _dji) - : FlyScreen(model: _model, dji: _dji), + : FlyScreen( + model: _model, + dji: _dji, + onOpenFlightLogs: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model), + )), + ), )); } diff --git a/Fly App/lib/ui/fly/bottom/telemetry_strip.dart b/Fly App/lib/ui/fly/bottom/telemetry_strip.dart index 08ae585..63e8418 100644 --- a/Fly App/lib/ui/fly/bottom/telemetry_strip.dart +++ b/Fly App/lib/ui/fly/bottom/telemetry_strip.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import '../../../fly/fly_prefs.dart'; import '../../../fly/hud_features.dart'; import '../../../fly/state/aircraft_state.dart'; +import '../../../fly/units.dart'; import '../hud_kit.dart'; -/// The bottom telemetry strip (plan P2): H · D · VS (with arrow) · HS · VPS. +/// The bottom telemetry strip (plan P2): H · D · VS (with arrow) · HS · VPS, +/// in metres or feet as Settings → General → Units says. class TelemetryStrip extends StatelessWidget { const TelemetryStrip({super.key, required this.aircraft}); @@ -13,21 +16,25 @@ class TelemetryStrip extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: aircraft, + listenable: Listenable.merge([aircraft, FlyPrefs.units]), builder: (BuildContext context, _) { final AircraftState a = aircraft; + final UnitSystem u = FlyPrefs.units.value; final double? vs = a.verticalSpeed; // Under 0.1 m/s is hover noise; no arrow for it. final int dir = vs == null || vs.abs() < 0.1 ? 0 : vs.sign.toInt(); + double? len(double? m) => m == null ? null : Units.lengthValue(m, u); + double? spd(double? ms) => ms == null ? null : Units.speedValue(ms, u); + final String l = Units.lengthUnit(u), s = Units.speedUnit(u); return GlassPill( height: 40, padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(mainAxisSize: MainAxisSize.min, children: [ - _Field('H', fmtNum(a.altitude), 'm'), - _Field('D', fmtNum(a.homeDistance, digits: 0), 'm'), - _Field('VS', fmtNum(vs?.abs()), 'm/s', arrow: dir), - _Field('HS', fmtNum(a.horizontalSpeed), 'm/s'), - Requires(HudFeatures.vpsReadout, child: _Field('VPS', fmtNum(a.vpsHeight), 'm')), + _Field('H', fmtNum(len(a.altitude)), l), + _Field('D', fmtNum(len(a.homeDistance), digits: 0), l), + _Field('VS', fmtNum(spd(vs?.abs())), s, arrow: dir), + _Field('HS', fmtNum(spd(a.horizontalSpeed)), s), + Requires(HudFeatures.vpsReadout, child: _Field('VPS', fmtNum(len(a.vpsHeight)), l)), ]), ); }, diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index ad9ab61..2313065 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -1,16 +1,23 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart' show LatLng; import '../../dji_service.dart'; import '../../flight_model.dart'; +import '../../fly/calibration.dart' show CalibrationKind; import '../../fly/flight_trail.dart'; import '../../fly/fly_zone_loader.dart'; import '../../fly/fly_prefs.dart'; +import '../../fly/gimbal_control.dart' show GimbalActions; import '../../fly/hud_features.dart'; import '../../fly/modes/mission_library.dart'; import '../../fly/modes/mission_sync.dart' show ApiMissionRemote; +import '../../fly/modes/orbit_modes.dart'; import '../../fly/modes/vision_modes.dart'; +import '../../fly/state/link_extras.dart' show RcButtonPress; +import '../../fly/state/parse.dart' show asString; import '../../fly/modes/waypoint_editor.dart'; import '../../fly/modes/waypoint_mission.dart' show MissionLimits; import '../../fly/product_profile.dart'; @@ -30,6 +37,7 @@ import 'hud_kit.dart'; import 'layers/video_layer.dart'; import 'left/action_rail.dart'; import 'left/gimbal_slider.dart'; +import 'modes/mode_panels.dart'; import 'modes/mode_picker.dart'; import 'modes/vision/vision_layers.dart'; import 'modes/vision/vision_panel.dart'; @@ -51,7 +59,9 @@ 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'; +import 'panels/camera_write.dart'; import 'panels/gimbal_sheet.dart'; +import 'panels/settings/calibration/calibration_hub.dart' show openCalibration; import 'panels/settings/settings_sheet.dart'; import 'right/camera_column.dart'; import 'right/zoom_column.dart'; @@ -100,7 +110,7 @@ import 'top/status_bar.dart'; /// album over the live view (videos play on the aircraft into the feed), and /// a LIVE pill heads the left column while an RTMP stream runs. class FlyScreen extends StatefulWidget { - const FlyScreen({super.key, required this.model, required this.dji, this.waypoints}); + const FlyScreen({super.key, required this.model, required this.dji, this.waypoints, this.onOpenFlightLogs}); final FlightModel model; final DjiService dji; @@ -108,6 +118,9 @@ class FlyScreen extends StatefulWidget { /// The Waypoints editor to use; the screen makes (and disposes) its own when null. final WaypointEditor? waypoints; + /// Opens the flight records (Settings → General → Flight logs). + final VoidCallback? onOpenFlightLogs; + @override State createState() => _FlyScreenState(); } @@ -140,6 +153,23 @@ class _FlyScreenState extends State { /// Keeps the GEO zones around the aircraft loaded (plan P8). late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji); + /// POI / Follow Me / Hyperlapse set-up (plan P7). + final ModeSetup _setup = ModeSetup(); + + /// Sends the phone's position to Follow Me while it runs. + Timer? _followTimer; + + /// The waypoint the current route was interrupted before, if it was. + int? _resumeFrom; + int _seenModeFinishes = 0; + + /// The live-feed cache is recording (plan P6): the aircraft flies and the pref is on. + bool _caching = false; + + /// The last focus mode seen, for the first-manual-focus lens prompt. + String? _focusMode; + bool _lensPromptShown = false; + /// Waypoints is open on the big map and nothing is flying: taps edit. bool get _editing { final WaypointProgress run = _m.mission.waypoint; @@ -166,13 +196,100 @@ class _FlyScreenState extends State { _wasConnected = _m.connected; _m.addListener(_onModel); _seenFinishes = _m.mission.waypointFinishes; + _seenModeFinishes = _m.mission.modeFinishes; _m.mission.addListener(_onMission); _m.aircraft.addListener(_onSticksAircraft); + _m.aircraft.addListener(_onCacheAircraft); + FlyPrefs.videoCache.addListener(_onCacheAircraft); + _m.rcButton.addListener(_onRcButton); + _focusMode = asString(_m.camera.setting('focusMode')); + _m.camera.addListener(_onCamera); _sticks.addListener(_onSticks); FlyPrefs.stickMode.addListener(_onStickMode); _zones.start(); } + // ── Video cache (plan P6) ─────────────────────────────────────────────────── + + /// Records the live feed while the aircraft flies, when the pref is on. + void _onCacheAircraft() { + final bool want = FlyPrefs.videoCache.value && _m.connected && _m.aircraft.isFlying == true; + if (want == _caching) return; + _caching = want; + _dji.setVideoCacheRecording(want, limitMb: FlyPrefs.videoCacheLimitMb.value).catchError((Object _) {}); + } + + // ── C1 / C2 (plan P6) ─────────────────────────────────────────────────────── + + void _onRcButton() { + final RcButtonPress? p = _m.rcButton.value; + if (p == null || !mounted) return; + _runButtonAction(FlyPrefs.rcButton(p.button)); + } + + Future _runButtonAction(RcButtonAction a) async { + try { + switch (a) { + case RcButtonAction.none: + break; + case RcButtonAction.gimbalRecenter: + await _dji.resetGimbal(); + case RcButtonAction.gimbalToggleDown: + // Down when roughly level, level when pointing down. + final double pitch = _m.gimbal.pitch ?? 0; + await _dji.rotateGimbalPitch(pitch < -45 ? 0 : GimbalActions.faceDownPitch(_m.profile.gimbal?.pitch)); + case RcButtonAction.mapSwap: + setState(() => _mapBig = !_mapBig); + case RcButtonAction.cameraSettings: + await showCameraMenu(context, model: _m, dji: _dji, onError: _error); + case RcButtonAction.aircraftStatus: + await _openStatus(context); + case RcButtonAction.aeLock: + final bool on = _m.camera.aeLock != true; + await writeCameraSetting(_m.camera, label: 'AE lock', pending: {'aeLock': on}, send: () => _dji.setCameraValue('aeLock', on), onError: _error); + case RcButtonAction.radar: + await FlyPrefs.setShowRadar(!FlyPrefs.showRadar.value); + } + } catch (e) { + _error(a.label, e); + } + } + + // ── First manual focus: offer the lens calibration (plan P6b) ────────────── + + void _onCamera() { + final String? mode = asString(_m.camera.setting('focusMode')); + if (mode == _focusMode) return; + final bool toManual = mode == 'MANUAL' && _focusMode != null; + _focusMode = mode; + if (!toManual || _lensPromptShown || !mounted) return; + final String camera = _m.profile.camera?.displayName ?? _m.model ?? 'camera'; + if (!HudFeatures.calibrationLens.enabledFor(_m.profile) || FlyPrefs.lensCalibrated.value.contains(camera)) return; + _lensPromptShown = true; + _offerLensCalibration(camera); + } + + Future _offerLensCalibration(String camera) async { + final bool? go = await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + backgroundColor: const Color(0xFF10203F), + title: Text('Calibrate the lens?', style: HudText.sans.copyWith(fontSize: 15)), + content: Text( + 'Manual focus is only accurate once the lens has been calibrated on this camera. It takes a minute, on the ground.', + style: HudText.sans.copyWith(fontWeight: FontWeight.w400), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')), + FilledButton(key: const ValueKey('lens-prompt-go'), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Calibrate')), + ], + ), + ); + if (go != true || !mounted) return; + await openCalibration(context, CalibrationKind.lens, model: _m, dji: _dji, onError: _error); + if (_m.calibration.lensResult == 'COMPLETED') await FlyPrefs.markLensCalibrated(camera); + } + void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value; /// The flight controller can end virtual-stick mode on its own (the remote's @@ -235,6 +352,25 @@ class _FlyScreenState extends State { ..clearSnackBars() ..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r'))); } + // A mission cut short may be resumed from where it stopped. + _checkResume(); + } + if (ms.modeFinishes != _seenModeFinishes) { + _seenModeFinishes = ms.modeFinishes; + final String? r = ms.modeResult; + if (mounted && r != null && r.isNotEmpty) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text('Mode ended: $r'))); + } + } + // Follow Me needs the phone's position for as long as it follows. + final bool following = ms.followMe.active; + if (following && _followTimer == null) { + _followTimer = Timer.periodic(FollowMeSpec.updateEvery, (_) => _gate.updateFollowMe().catchError((Object _) {})); + } else if (!following && _followTimer != null) { + _followTimer!.cancel(); + _followTimer = null; } // Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off. final bool editing = _editing; @@ -248,15 +384,18 @@ class _FlyScreenState extends State { if (m == null || !mounted) return; switch (m) { case IntelligentMode.waypoint: + case IntelligentMode.waypointV2: setState(() { - _mode = m; + _mode = IntelligentMode.waypoint; _mapBig = true; }); _minimap.setFollow(_waypoints.mission.points.isEmpty); final MissionLimits limits = await _gate.missionLimits(); if (mounted) setState(() => _limits = limits); + _checkResume(); case IntelligentMode.activeTrack: case IntelligentMode.tapFly: + case IntelligentMode.quickShots: // Vision modes work on the live video. _vision.clearPending(); setState(() { @@ -270,18 +409,46 @@ class _FlyScreenState extends State { _vision.setRetreat(null); // not reported: the chip starts off } } - default: - break; // the picker only returns built modes + case IntelligentMode.pointOfInterest: + // The centre is picked on the map; it stays big until GO. + setState(() { + _mode = m; + _mapBig = !_m.mission.hotpoint.active; + }); + if (_setup.poi.center == null && _m.aircraft.latitude != null && _m.aircraft.longitude != null) { + _minimap.setFollow(true); + } + case IntelligentMode.followMe: + case IntelligentMode.courseLock: + case IntelligentMode.homeLock: + case IntelligentMode.tripod: + case IntelligentMode.hyperlapse: + setState(() { + _mode = m; + _mapBig = false; + }); } } + /// Asks whether the route on screen was cut short, to offer RESUME. + Future _checkResume() async { + final int? i = await _gate.interruptionFor(_waypoints.mission); + if (mounted && i != _resumeFrom) setState(() => _resumeFrom = i); + } + + void _resume(BuildContext context) { + final int? i = _resumeFrom; + if (i == null) return; + startWaypointFlow(context, gate: _gate, spec: _waypoints.mission.from(i), onError: _error); + } + void _exitMode() => setState(() { _mode = null; _mapBig = false; _waypoints.select(null); }); - bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly; + bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly || _mode == IntelligentMode.quickShots; /// Leaving a vision mode while it flies stops it first (GO 4's Exit). Future _exitVision(BuildContext context) async { @@ -292,6 +459,36 @@ class _FlyScreenState extends State { if (mounted) setState(() => _mode = null); } + /// Leaving POI or Follow Me while it flies stops it first, like the vision modes. + Future _exitAuto(BuildContext context) async { + final MissionState ms = _m.mission; + final bool running = (_mode == IntelligentMode.pointOfInterest && ms.hotpoint.active) || (_mode == IntelligentMode.followMe && ms.followMe.active); + if (running) await _flow.request(context, FlightCommand.stopMode); + if (mounted) { + setState(() { + _mode = null; + _mapBig = false; + }); + } + } + + void _goQuickShot(BuildContext context, Rect box) { + final QuickShot shot = _vision.quickShot; + startVisionMode( + context, + title: 'QuickShot · ${shot.label}', + icon: 'dronie', + description: '${shot.blurb}. The aircraft records, flies the shot and comes back. Stop, the sticks or the remote\'s pause button end it.', + // QuickShots always get the slider: the aircraft flies far on its own. + checks: [..._gate.checkQuickShot(shot), if (_gate.checkQuickShot(shot).isEmpty) const GateCheck.warn('The aircraft flies the whole shot on its own.')], + start: () async { + await _gate.startQuickShot(box, shot); + _vision.setPendingBox(null); + }, + onError: _error, + ); + } + void _goTrack(BuildContext context, Rect box, {int? subject}) { final TrackMode mode = _vision.trackMode; startVisionMode( @@ -354,7 +551,15 @@ class _FlyScreenState extends State { _m.removeListener(_onModel); _m.mission.removeListener(_onMission); _m.aircraft.removeListener(_onSticksAircraft); + _m.aircraft.removeListener(_onCacheAircraft); + FlyPrefs.videoCache.removeListener(_onCacheAircraft); + _m.rcButton.removeListener(_onRcButton); + _m.camera.removeListener(_onCamera); FlyPrefs.stickMode.removeListener(_onStickMode); + _followTimer?.cancel(); + // The cache records only while this screen flies. + if (_caching) _dji.setVideoCacheRecording(false).catchError((Object _) {}); + _setup.dispose(); _zones.dispose(); // Leaving the screen hands the aircraft back to the remote. if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {}); @@ -406,10 +611,15 @@ class _FlyScreenState extends State { flyZoneLicenceLayer(_m.flyZones), flyZoneHeightLayer(_m.flyZones), ...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing), + if (_mode == IntelligentMode.pointOfInterest || _m.mission.hotpoint.active) ...poiMapLayers(_setup, _m.mission), airSenseMapLayer(_m.airSense), ], onTap: (LatLng at) { - if (_editing) _waypoints.tapMap(at); + if (_editing) { + _waypoints.tapMap(at); + } else if (_mode == IntelligentMode.pointOfInterest && !_m.mission.hotpoint.active) { + _setup.setPoi(_setup.poi.copyWith(center: at)); + } }, ); @@ -420,13 +630,14 @@ class _FlyScreenState extends State { Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), Positioned.fill(child: VisualOverlays(video: _m.videoSize)), // A vision mode owns every touch on the video: a box or a point. - if (_mode == IntelligentMode.activeTrack) + if (_mode == IntelligentMode.activeTrack || _mode == IntelligentMode.quickShots) Positioned.fill( child: ActiveTrackLayer( mission: _m.mission, session: _vision, video: _m.videoSize, - onGo: (Rect box, int? subject) => _goTrack(context, box, subject: subject), + onGo: (Rect box, int? subject) => + _mode == IntelligentMode.quickShots ? _goQuickShot(context, box) : _goTrack(context, box, subject: subject), onConfirm: _confirmTrack, ), ) @@ -502,6 +713,28 @@ class _FlyScreenState extends State { ); } + /// The open mode's panel at the head of the left column, for the modes + /// that are neither Waypoints nor a vision mode. + Widget? _modePanel(BuildContext context) { + final MissionState ms = _m.mission; + // A POI or Follow Me flying keeps its panel even after the mode was left. + final IntelligentMode? m = _mode ?? + (ms.hotpoint.active + ? IntelligentMode.pointOfInterest + : ms.followMe.active + ? IntelligentMode.followMe + : null); + return switch (m) { + IntelligentMode.pointOfInterest => + PoiPanel(model: _m, setup: _setup, gate: _gate, flow: _flow, dji: _dji, onExit: () => _exitAuto(context), onError: _error), + IntelligentMode.followMe => FollowMePanel(model: _m, setup: _setup, gate: _gate, flow: _flow, onExit: () => _exitAuto(context), onError: _error), + IntelligentMode.courseLock || IntelligentMode.homeLock => OrientationPanel(mode: m!, model: _m, gate: _gate, onExit: _exitMode, onError: _error), + IntelligentMode.tripod => SlowModePanel(model: _m, dji: _dji, gate: _gate, onExit: _exitMode, onError: _error), + IntelligentMode.hyperlapse => HyperlapsePanel(model: _m, setup: _setup, gate: _gate, onExit: _exitMode, onError: _error), + _ => null, + }; + } + Widget _hud(BuildContext context, Size canvas, double scale) { return Stack(clipBehavior: Clip.none, children: [ // ── Camera column (right) ───────────────────────────────────────────── @@ -592,6 +825,7 @@ class _FlyScreenState extends State { onError: _error, onOpenStatus: () => _openStatus(context), onClearRoute: _trail.clear, + onOpenFlightLogs: widget.onOpenFlightLogs, ), ), ), @@ -688,6 +922,7 @@ class _FlyScreenState extends State { padding: const EdgeInsets.only(bottom: 6), child: VisionModePanel( tapFly: _mode == IntelligentMode.tapFly, + quickShots: _mode == IntelligentMode.quickShots, model: _m, session: _vision, gate: _gate, @@ -696,6 +931,7 @@ class _FlyScreenState extends State { onError: _error, ), ), + if (_modePanel(context) case final Widget panel) Padding(padding: const EdgeInsets.only(bottom: 6), child: panel), if (_editing) Padding( padding: const EdgeInsets.only(bottom: 6), @@ -703,6 +939,8 @@ class _FlyScreenState extends State { editor: _waypoints, limits: _limits, onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits), + resumeFrom: _resumeFrom, + onResume: () => _resume(context), ), ), Requires( diff --git a/Fly App/lib/ui/fly/modes/mode_panels.dart b/Fly App/lib/ui/fly/modes/mode_panels.dart new file mode 100644 index 0000000..58598f3 --- /dev/null +++ b/Fly App/lib/ui/fly/modes/mode_panels.dart @@ -0,0 +1,697 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../../dji_service.dart'; +import '../../../flight_model.dart'; +import '../../../fly/hud_geometry.dart' show formatDuration; +import '../../../fly/modes/intelligent_modes.dart'; +import '../../../fly/modes/orbit_modes.dart'; +import '../../../fly/product_profile.dart' show IntelligentMode; +import '../../../fly/safety_gate.dart'; +import '../../../fly/settings/settings_spec.dart'; +import '../../../fly/state/key_state.dart'; +import '../../../fly/state/mission_state.dart'; +import '../../../fly/state/parse.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../dialogs/slide_to_confirm.dart'; +import '../flight_command_flow.dart'; +import '../hud_kit.dart'; +import '../panels/settings/setting_tiles.dart'; +import 'mode_picker.dart' show ensureModeTutorial; +import 'waypoints/point_editor.dart' show HudChip; + +/// Confirms a mission-type mode with its gate checks and starts it: always +/// slide-to-confirm, since the aircraft goes off on its own; blockers show +/// their reasons and no slider. +Future confirmAndStart( + BuildContext context, { + required String title, + required String icon, + required String description, + required List checks, + required Future Function() start, + required void Function(String label, Object error) onError, + String slider = 'Slide to start', +}) async { + final SlideResult? r = await showSlideToConfirm( + context, + SlideConfirmSpec(icon: icon, title: title, description: description, sliderLabel: slider, checks: checks), + ); + if (r == null) return; + try { + await start(); + } catch (e) { + onError(title, e); + } +} + +/// The frame every mode panel shares: icon, title, status, Stop while it +/// flies, a "?" for the tutorial, Exit — then the panel's own rows. +class ModePanelFrame extends StatelessWidget { + const ModePanelFrame({ + super.key, + required this.mode, + required this.title, + required this.icon, + required this.status, + required this.statusColor, + required this.children, + required this.onExit, + this.onStop, + this.note, + }); + + final IntelligentMode mode; + final String title; + final String icon; + final String status; + final Color statusColor; + final List children; + final VoidCallback onExit; + + /// Shown while the mode flies the aircraft. + final VoidCallback? onStop; + final String? note; + + @override + Widget build(BuildContext context) { + return Container( + key: ValueKey('mode-panel-${mode.name}'), + constraints: const BoxConstraints(maxWidth: 420), + padding: const EdgeInsets.fromLTRB(10, 6, 6, 6), + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + PVIcon(icon, size: 13, color: Glass.ink), + const SizedBox(width: 6), + Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)), + const SizedBox(width: 8), + Flexible(child: Text(status, overflow: TextOverflow.ellipsis, style: HudText.label.copyWith(color: statusColor, fontWeight: FontWeight.w700))), + const SizedBox(width: 8), + if (onStop != null) ...[ + ModeButton(id: 'stop', icon: 'stop', label: 'STOP', color: HudColors.danger, onTap: onStop), + const SizedBox(width: 4), + ], + ModeButton( + id: 'help', + icon: 'info', + label: '?', + color: Glass.pill, + onTap: () => ensureModeTutorial(context, ModeCatalog.of(mode), force: true), + ), + const SizedBox(width: 4), + ModeButton(id: 'exit', icon: 'close', label: 'EXIT', color: Glass.pill, onTap: onExit), + ]), + if (note != null) Padding(padding: const EdgeInsets.only(top: 3), child: Text(note!, style: HudText.label.copyWith(color: HudColors.caution))), + const SizedBox(height: 5), + ...children, + ]), + ); + } +} + +class ModeButton extends StatelessWidget { + const ModeButton({super.key, required this.id, required this.icon, required this.label, required this.color, required this.onTap}); + + final String id; + final String icon; + final String label; + final Color color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Opacity( + opacity: onTap == null ? 0.4 : 1, + child: GestureDetector( + key: ValueKey('mode-$id'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + PVIcon(icon, size: 11, color: Colors.white), + const SizedBox(width: 3), + Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ]), + ), + ), + ); +} + +/// A compact − value + for the mode panels. +class ModeStepper extends StatelessWidget { + const ModeStepper({super.key, required this.id, required this.label, required this.value, required this.onStep, this.canDown = true, this.canUp = true}); + + final String id; + final String label; + final String value; + final ValueChanged onStep; + final bool canDown; + final bool canUp; + + @override + Widget build(BuildContext context) { + Widget b(String l, int d, bool on) => Opacity( + opacity: on ? 1 : 0.35, + child: GestureDetector( + key: ValueKey('$id-${d > 0 ? 'plus' : 'minus'}'), + behavior: HitTestBehavior.opaque, + onTap: on ? () => onStep(d) : null, + child: Container( + width: 24, + height: 22, + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)), + ), + ), + ); + return Row(mainAxisSize: MainAxisSize.min, children: [ + Text(label, style: HudText.label), + const SizedBox(width: 4), + b('−', -1, canDown), + SizedBox(width: 58, child: Text(value, key: ValueKey(id), textAlign: TextAlign.center, style: HudText.mono)), + b('+', 1, canUp), + ]); + } +} + +Future _guard(String label, Future Function() f, void Function(String, Object) onError) async { + try { + await f(); + } catch (e) { + onError(label, e); + } +} + +// ── Point of Interest ───────────────────────────────────────────────────────── + +/// Point of Interest (plan P7): set the centre (tap the map, or the +/// aircraft's position), radius, altitude, speed, direction and heading; GO; +/// then radius and speed live, Pause / Resume and Stop. +class PoiPanel extends StatelessWidget { + const PoiPanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.dji, required this.onExit, required this.onError}); + + final FlightModel model; + final ModeSetup setup; + final SafetyGate gate; + final FlightCommandFlow flow; + final DjiService dji; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + Future _go(BuildContext context) async { + final PoiSpec p = setup.poi; + double? max = setup.poiMaxAngular; + if (max == null && model.connected) { + try { + max = await dji.getHotpointMaxAngularVelocity(p.radius); + setup.setPoiMaxAngular(max); + } catch (_) {} + } + if (!context.mounted) return; + await confirmAndStart( + context, + title: 'Point of Interest', + icon: 'circle', + description: '${p.clockwise ? 'Clockwise' : 'Anticlockwise'} at ${p.radius.round()} m, ${p.altitude.round()} m up, ' + '${p.speed.toStringAsFixed(1)} m/s (a lap in about ${formatDuration(p.lapSeconds)}). Facing: ${p.heading.label.toLowerCase()}.', + checks: gate.checkPoi(p, maxAngular: max), + start: () => gate.startPoi(p, maxAngular: max), + onError: onError, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.mission, setup]), + builder: (BuildContext context, _) { + final HotpointProgress run = model.mission.hotpoint; + final PoiSpec p = setup.poi; + final bool active = run.active; + void set(PoiSpec n) => setup.setPoi(n); + void live({double? radius, double? speed}) => _guard( + 'Point of Interest', + () => gate.adjustPoi(radius: radius, speed: speed, clockwise: p.clockwise, currentRadius: run.radius ?? p.radius), + onError, + ); + return ModePanelFrame( + mode: IntelligentMode.pointOfInterest, + title: 'POINT OF INTEREST', + icon: 'circle', + status: active ? run.label.toUpperCase() : (p.center == null ? 'TAP THE MAP ON THE SUBJECT' : 'READY'), + statusColor: active ? (run.paused ? HudColors.caution : HudColors.ready) : HudText.dim, + onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null, + onExit: onExit, + children: [ + Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + if (!active) + HudChip( + key: const ValueKey('poi-aircraft'), + label: 'Centre: aircraft', + selected: false, + onTap: model.aircraft.latitude == null + ? null + : () => set(p.copyWith(center: LatLng(model.aircraft.latitude!, model.aircraft.longitude!))), + ), + ModeStepper( + id: 'poi-radius', + label: 'R', + value: '${(active ? (run.radius ?? p.radius) : p.radius).round()} m', + canDown: p.radius > PoiSpec.minRadius, + canUp: p.radius < PoiSpec.maxRadius, + onStep: (int d) { + final double r = (p.radius + d * 5).clamp(PoiSpec.minRadius, PoiSpec.maxRadius); + set(p.copyWith(radius: r)); + if (active) live(radius: r); + }, + ), + if (!active) + ModeStepper( + id: 'poi-altitude', + label: 'H', + value: '${p.altitude.round()} m', + canDown: p.altitude > PoiSpec.minAltitude, + canUp: p.altitude < PoiSpec.maxAltitude, + onStep: (int d) => set(p.copyWith(altitude: (p.altitude + d * 5).clamp(PoiSpec.minAltitude, PoiSpec.maxAltitude))), + ), + ModeStepper( + id: 'poi-speed', + label: 'V', + value: '${p.speed.toStringAsFixed(1)} m/s', + canDown: p.speed > PoiSpec.minSpeed, + canUp: p.speed < PoiSpec.maxSpeed, + onStep: (int d) { + final double v = (p.speed + d * 0.5).clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed); + set(p.copyWith(speed: v)); + if (active) live(speed: v); + }, + ), + ]), + if (!active) ...[ + const SizedBox(height: 4), + Wrap(spacing: 4, runSpacing: 4, children: [ + HudChip(key: const ValueKey('poi-cw'), label: 'Clockwise', selected: p.clockwise, onTap: () => set(p.copyWith(clockwise: true))), + HudChip(key: const ValueKey('poi-ccw'), label: 'Anticlockwise', selected: !p.clockwise, onTap: () => set(p.copyWith(clockwise: false))), + for (final PoiHeading h in PoiHeading.values) + HudChip(key: ValueKey('poi-heading-${h.name}'), label: h.label, selected: p.heading == h, onTap: () => set(p.copyWith(heading: h))), + ]), + const SizedBox(height: 4), + Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Text('JOIN', style: HudText.label), + for (final PoiStart s in PoiStart.values) + HudChip(key: ValueKey('poi-start-${s.name}'), label: s.label, selected: p.start == s, onTap: () => set(p.copyWith(start: s))), + const SizedBox(width: 2), + ModeButton(id: 'poi-go', icon: 'play2', label: 'GO', color: Glass.accent, onTap: p.center == null ? null : () => _go(context)), + ]), + ] else ...[ + const SizedBox(height: 4), + Row(mainAxisSize: MainAxisSize.min, children: [ + ModeButton( + id: run.paused ? 'poi-resume' : 'poi-pause', + icon: run.paused ? 'play2' : 'pause', + label: run.paused ? 'RESUME' : 'PAUSE', + color: Glass.accent, + onTap: () => _guard(run.paused ? 'Resume' : 'Pause', run.paused ? gate.resumePoi : gate.pausePoi, onError), + ), + const SizedBox(width: 6), + ModeButton( + id: 'poi-reset-heading', + icon: 'compass', + label: 'FACE POI', + color: Glass.pill, + onTap: () => _guard('Reset heading', dji.resetHotpointHeading, onError), + ), + ]), + ], + ], + ); + }, + ); + } +} + +/// The orbit on the map: the circle and its centre, editable before GO. +List poiMapLayers(ModeSetup setup, MissionState mission) => [ + ListenableBuilder( + listenable: Listenable.merge([setup, mission]), + builder: (BuildContext context, _) { + final LatLng? c = setup.poi.center; + if (c == null) return const SizedBox.shrink(); + final double r = mission.hotpoint.active ? (mission.hotpoint.radius ?? setup.poi.radius) : setup.poi.radius; + return PolylineLayer(polylines: >[ + Polyline(points: _circle(c, r), color: const Color(0xCC8E6CFF), strokeWidth: 2.5), + ]); + }, + ), + ListenableBuilder( + listenable: setup, + builder: (BuildContext context, _) { + final LatLng? c = setup.poi.center; + if (c == null) return const SizedBox.shrink(); + return MarkerLayer(markers: [ + Marker( + point: c, + width: 22, + height: 22, + child: Container( + key: const ValueKey('poi-center'), + decoration: BoxDecoration(color: const Color(0xFF8E6CFF), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)), + ), + ), + ]); + }, + ), + ]; + +/// The orbit as a 72-sided polygon; flat-earth offsets are exact enough at 500 m. +List _circle(LatLng c, double r) { + const double metresPerDegree = 111320; + final double cosLat = math.cos(c.latitude * math.pi / 180); + return [ + for (int i = 0; i <= 72; i++) + LatLng( + c.latitude + r * math.cos(i * 5 * math.pi / 180) / metresPerDegree, + c.longitude + r * math.sin(i * 5 * math.pi / 180) / (metresPerDegree * cosLat), + ), + ]; +} + +// ── Follow Me ───────────────────────────────────────────────────────────────── + +class FollowMePanel extends StatelessWidget { + const FollowMePanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.onExit, required this.onError}); + + final FlightModel model; + final ModeSetup setup; + final SafetyGate gate; + final FlightCommandFlow flow; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.mission, setup]), + builder: (BuildContext context, _) { + final FollowMeProgress run = model.mission.followMe; + final FollowMeSpec f = setup.followMe; + final bool active = run.active; + return ModePanelFrame( + mode: IntelligentMode.followMe, + title: 'FOLLOW ME', + icon: 'user', + status: active + ? (run.stuck ? 'STUCK — STEER WITH THE STICKS' : 'FOLLOWING${run.distance == null ? '' : ' · ${run.distance!.round()} m'}') + : (model.phoneLatitude == null ? 'WAITING FOR THE PHONE\'S GPS' : 'READY'), + statusColor: active ? (run.stuck ? HudColors.caution : HudColors.ready) : HudText.dim, + onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null, + onExit: onExit, + children: [ + if (!active) + Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + ModeStepper( + id: 'follow-altitude', + label: 'H', + value: '${f.altitude.round()} m', + canDown: f.altitude > FollowMeSpec.minAltitude, + canUp: f.altitude < FollowMeSpec.maxAltitude, + onStep: (int d) => setup.setFollowMe(f.copyWith(altitude: (f.altitude + d * 5).clamp(FollowMeSpec.minAltitude, FollowMeSpec.maxAltitude))), + ), + for (final FollowMeHeading h in FollowMeHeading.values) + HudChip(key: ValueKey('follow-${h.name}'), label: h.label, selected: f.heading == h, onTap: () => setup.setFollowMe(f.copyWith(heading: h))), + ModeButton( + id: 'follow-go', + icon: 'play2', + label: 'GO', + color: Glass.accent, + onTap: () => confirmAndStart( + context, + title: 'Follow Me', + icon: 'user', + description: 'The aircraft follows this phone at ${f.altitude.round()} m. Keep the app open; STOP or the remote\'s pause button ends it.', + checks: gate.checkFollowMe(f), + start: () => gate.startFollowMe(f), + onError: onError, + ), + ), + ]), + ], + ); + }, + ); + } +} + +// ── Course Lock / Home Lock ─────────────────────────────────────────────────── + +class OrientationPanel extends StatelessWidget { + const OrientationPanel({super.key, required this.mode, required this.model, required this.gate, required this.onExit, required this.onError}); + + /// The picker tile it was opened from (Course Lock or Home Lock). + final IntelligentMode mode; + final FlightModel model; + final SafetyGate gate; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + Future _set(BuildContext context, OrientationMode m) async { + final List checks = gate.checkOrientation(m); + if (m == OrientationMode.normal) { + await _guard('Orientation', () => gate.setOrientation(m), onError); + return; + } + await confirmAndStart( + context, + title: m.label, + icon: m == OrientationMode.homeLock ? 'home' : 'compass', + description: m == OrientationMode.homeLock + ? 'Back on the stick flies towards home; turn it off to fly normally again.' + : 'Forward on the stick is the current heading; turn it off to fly normally again.', + checks: checks, + start: () => gate.setOrientation(m), + onError: onError, + slider: 'Slide to turn on', + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: model.aircraft, + builder: (BuildContext context, _) { + final OrientationMode now = OrientationMode.fromSdk(model.aircraft.orientationMode); + return ModePanelFrame( + mode: mode, + title: 'ORIENTATION', + icon: mode == IntelligentMode.homeLock ? 'home' : 'compass', + status: now.label.toUpperCase(), + statusColor: now == OrientationMode.normal ? HudText.dim : HudColors.caution, + onExit: onExit, + children: [ + Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + for (final OrientationMode m in OrientationMode.values) + HudChip(key: ValueKey('ioc-${m.name}'), label: m.label, selected: now == m, onTap: now == m ? null : () => _set(context, m)), + if (now == OrientationMode.courseLock) + ModeButton( + id: 'ioc-lock', + icon: 'compass', + label: 'LOCK HEADING', + color: Glass.accent, + onTap: () => _guard('Lock heading', gate.lockCourse, onError), + ), + ]), + ], + ); + }, + ); + } +} + +// ── Tripod / Cinematic ──────────────────────────────────────────────────────── + +/// Tripod and Cinematic (plan P7): switches plus Cinematic's brake and yaw +/// tuning, each row hidden where the flight controller says it has no such key. +class SlowModePanel extends StatefulWidget { + const SlowModePanel({super.key, required this.model, required this.dji, required this.gate, required this.onExit, required this.onError}); + + final FlightModel model; + final DjiService dji; + final SafetyGate gate; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + static const DjiKey tripod = DjiKey.flightController('TRIPOD_MODE_ENABLED'); + static const DjiKey cinematic = DjiKey.flightController('CINEMATIC_MODE_ENABLED'); + static const DjiKey brake = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY'); + static const DjiKey brakeRange = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY_RANGE'); + static const DjiKey yaw = DjiKey.flightController('CINEMATIC_YAW_SPEED'); + static const DjiKey yawRange = DjiKey.flightController('CINEMATIC_YAW_SPEED_RANGE'); + + static const List tuning = [ + KeyNumberSpec(id: 'cinematicBrake', label: 'Brake sensitivity', key: brake, rangeKey: brakeRange, min: 10, max: 100, step: 10), + KeyNumberSpec(id: 'cinematicYaw', label: 'Yaw speed', key: yaw, rangeKey: yawRange, min: 10, max: 100, step: 5, unit: '°/s'), + ]; + + @override + State createState() => _SlowModePanelState(); +} + +class _SlowModePanelState extends State { + late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys); + + @override + void initState() { + super.initState(); + if (widget.model.connected) { + _subs.watch([SlowModePanel.tripod, SlowModePanel.cinematic, SlowModePanel.brake, SlowModePanel.brakeRange, SlowModePanel.yaw, SlowModePanel.yawRange]); + } + } + + @override + void dispose() { + _subs.dispose(); + super.dispose(); + } + + Future _toggle(BuildContext context, {required bool cinematic, required bool on}) async { + final KeyState k = widget.model.keys; + final DjiKey key = cinematic ? SlowModePanel.cinematic : SlowModePanel.tripod; + Future send() async { + k.setPending({key.id: on}); + try { + await widget.gate.setSlowMode(cinematic: cinematic, on: on); + } finally { + k.dropPending([key.id]); + } + } + + if (!on) return _guard(cinematic ? 'Cinematic' : 'Tripod', send, widget.onError); + await confirmAndStart( + context, + title: cinematic ? 'Cinematic mode' : 'Tripod mode', + icon: 'gauge', + description: cinematic ? 'Smooth, slow braking and turns for steady video.' : 'About 1 m/s and very gentle sticks, for precise framing.', + checks: widget.gate.checkSlowMode(cinematic: cinematic, on: true), + start: send, + onError: widget.onError, + slider: 'Slide to turn on', + ); + } + + @override + Widget build(BuildContext context) { + final FlightModel m = widget.model; + return ListenableBuilder( + listenable: m.keys, + builder: (BuildContext context, _) { + final KeyState k = m.keys; + final bool tripod = asBool(k.value(SlowModePanel.tripod)) == true; + final bool cinematic = asBool(k.value(SlowModePanel.cinematic)) == true; + final SettingsContext c = SettingsContext(profile: m.profile, keys: k, aircraft: m.aircraft); + return ModePanelFrame( + mode: IntelligentMode.tripod, + title: 'TRIPOD · CINEMATIC', + icon: 'gauge', + status: tripod ? 'TRIPOD ON' : (cinematic ? 'CINEMATIC ON' : 'OFF'), + statusColor: tripod || cinematic ? HudColors.caution : HudText.dim, + onExit: widget.onExit, + children: [ + Wrap(spacing: 4, runSpacing: 4, children: [ + if (k.supported(SlowModePanel.tripod) != false) + HudChip( + key: const ValueKey('slow-tripod'), + label: tripod ? 'Tripod on' : 'Tripod off', + selected: tripod, + onTap: () => _toggle(context, cinematic: false, on: !tripod), + ), + if (k.supported(SlowModePanel.cinematic) != false) + HudChip( + key: const ValueKey('slow-cinematic'), + label: cinematic ? 'Cinematic on' : 'Cinematic off', + selected: cinematic, + onTap: () => _toggle(context, cinematic: true, on: !cinematic), + ), + ]), + if (cinematic) + SizedBox( + width: 380, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final SettingSpec s in SlowModePanel.tuning) + if (s.visible(c)) + SettingRowTile(spec: s, settings: c, dji: widget.dji, onError: widget.onError, custom: (CustomSpec _) => const SizedBox.shrink()), + ]), + ), + ], + ); + }, + ); + } +} + +// ── Hyperlapse ──────────────────────────────────────────────────────────────── + +class HyperlapsePanel extends StatelessWidget { + const HyperlapsePanel({super.key, required this.model, required this.setup, required this.gate, required this.onExit, required this.onError}); + + final FlightModel model; + final ModeSetup setup; + final SafetyGate gate; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.camera, setup]), + builder: (BuildContext context, _) { + final HyperlapseSpec h = setup.hyperlapse; + final bool shooting = model.camera.shootingInterval; + return ModePanelFrame( + mode: IntelligentMode.hyperlapse, + title: 'HYPERLAPSE · FREE', + icon: 'hyperlapse', + status: shooting ? 'SHOOTING — FLY SLOWLY' : '${h.frames} FRAMES · ${formatDuration(h.shootingSeconds)}', + statusColor: shooting ? HudColors.ready : HudText.dim, + onStop: shooting ? () => _guard('Hyperlapse', gate.stopHyperlapse, onError) : null, + onExit: onExit, + children: [ + if (!shooting) + Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Text('EVERY', style: HudText.label), + for (final int s in HyperlapseSpec.intervals) + HudChip(key: ValueKey('hl-interval-$s'), label: '$s s', selected: h.intervalSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(intervalSeconds: s))), + const SizedBox(width: 6), + Text('VIDEO', style: HudText.label), + for (final int s in HyperlapseSpec.durations) + HudChip(key: ValueKey('hl-length-$s'), label: '$s s', selected: h.videoSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(videoSeconds: s))), + ModeButton( + id: 'hl-start', + icon: 'play2', + label: 'START', + color: Glass.accent, + onTap: () => confirmAndStart( + context, + title: 'Hyperlapse', + icon: 'hyperlapse', + description: '${h.frames} photos, one every ${h.intervalSeconds} s (${formatDuration(h.shootingSeconds)}), ' + 'for ${h.videoSeconds} s of video at ${h.fps} fps. You fly; keep it slow and steady.', + checks: gate.checkHyperlapse(h), + start: () => gate.startHyperlapse(h), + onError: onError, + ), + ), + ]), + ], + ); + }, + ); + } +} diff --git a/Fly App/lib/ui/fly/modes/mode_picker.dart b/Fly App/lib/ui/fly/modes/mode_picker.dart index 362345f..5c9d5c6 100644 --- a/Fly App/lib/ui/fly/modes/mode_picker.dart +++ b/Fly App/lib/ui/fly/modes/mode_picker.dart @@ -24,8 +24,87 @@ Future showModePicker(BuildContext context, {required ProductP ), ); if (picked == null || !context.mounted) return null; - final bool ok = await ensureModeTerms(context, ModeCatalog.of(picked)); - return ok ? picked : null; + final ModeInfo info = ModeCatalog.of(picked); + final bool ok = await ensureModeTerms(context, info); + if (!ok || !context.mounted) return null; + await ensureModeTutorial(context, info); + return picked; +} + +/// GO 4's first-use tutorial, once per mode (and on demand with [force]). +Future ensureModeTutorial(BuildContext context, ModeInfo info, {bool force = false}) async { + if (info.tutorial.isEmpty) return; + if (!force && FlyPrefs.seenTutorials.value.contains(info.mode.name)) return; + await showDialog( + context: context, + barrierColor: const Color(0x99050A14), + builder: (BuildContext context) => ModeTutorialDialog(info: info), + ); + await FlyPrefs.markTutorialSeen(info.mode.name); +} + +/// The tutorial: one step per page, Back / Next, Done on the last. +class ModeTutorialDialog extends StatefulWidget { + const ModeTutorialDialog({super.key, required this.info}); + + final ModeInfo info; + + @override + State createState() => _ModeTutorialDialogState(); +} + +class _ModeTutorialDialogState extends State { + int _page = 0; + + @override + Widget build(BuildContext context) { + final List steps = widget.info.tutorial; + final bool last = _page >= steps.length - 1; + return Dialog( + backgroundColor: const Color(0xF20B1426), + insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Row(children: [ + PVIcon(widget.info.icon, size: 18, color: Glass.ink), + const SizedBox(width: 8), + Expanded(child: Text('How to fly ${widget.info.label}', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + Text('${_page + 1}/${steps.length}', style: HudText.label), + ]), + const SizedBox(height: 14), + Text(steps[_page], key: const ValueKey('tutorial-step'), style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w400, height: 1.4)), + const SizedBox(height: 14), + Row(children: [ + for (int i = 0; i < steps.length; i++) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration(color: i == _page ? Glass.accent : HudText.dim, shape: BoxShape.circle), + ), + const Spacer(), + if (_page > 0) + TextButton( + onPressed: () => setState(() => _page--), + child: Text('Back', style: HudText.sans.copyWith(color: HudText.dim)), + ), + const SizedBox(width: 6), + FilledButton( + key: const ValueKey('tutorial-next'), + style: FilledButton.styleFrom(backgroundColor: PV.accent), + onPressed: last ? () => Navigator.of(context).pop() : () => setState(() => _page++), + child: Text(last ? 'Done' : 'Next', style: HudText.sans.copyWith(color: Colors.white)), + ), + ]), + ]), + ), + ), + ); + } } /// Shows the mode's terms the first time it is used; true once accepted. diff --git a/Fly App/lib/ui/fly/modes/vision/vision_panel.dart b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart index 6593312..5bdd19c 100644 --- a/Fly App/lib/ui/fly/modes/vision/vision_panel.dart +++ b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import '../../../../flight_model.dart'; +import '../../../../fly/modes/orbit_modes.dart' show QuickShot; import '../../../../fly/modes/vision_modes.dart'; +import '../../../../fly/product_profile.dart' show VisionSensors; import '../../../../fly/safety_gate.dart'; import '../../../../fly/state/mission_state.dart'; import '../../../../theme.dart'; @@ -50,10 +52,14 @@ class VisionModePanel extends StatelessWidget { required this.flow, required this.onExit, required this.onError, + this.quickShots = false, }); /// TapFly's panel, or ActiveTrack's. final bool tapFly; + + /// QuickShots' panel: ActiveTrack's operator, the shot instead of the follow style. + final bool quickShots; final FlightModel model; final VisionSession session; final SafetyGate gate; @@ -76,11 +82,23 @@ class VisionModePanel extends StatelessWidget { builder: (BuildContext context, _) { final MissionState ms = model.mission; final bool active = tapFly ? ms.tapFly.active : ms.activeTrack.active; - final String title = tapFly ? 'TAPFLY' : 'ACTIVETRACK'; + final String title = tapFly ? 'TAPFLY' : (quickShots ? 'QUICKSHOTS' : 'ACTIVETRACK'); final String status; final Color statusColor; String? note; - if (tapFly) { + if (quickShots) { + final ActiveTrackProgress t = ms.activeTrack; + final bool filming = t.state == 'PERFORMING_QUICK_SHOT'; + status = filming + ? 'FILMING${t.percent == null ? '' : ' ${t.percent}%'}' + : t.active + ? t.stateLabel.toUpperCase() + : session.pendingBox != null + ? 'TAP GO' + : 'DRAW A BOX ROUND THE SUBJECT'; + statusColor = t.cannotConfirm ? HudColors.danger : (filming ? HudColors.ready : (t.active ? HudColors.caution : HudText.dim)); + note = t.cannotConfirm ? t.reasonLabel : (active ? null : session.quickShot.blurb); + } else if (tapFly) { final TapFlyProgress f = ms.tapFly; status = f.active ? (f.paused ? 'PAUSED' : 'FLYING') : (session.pendingPoint != null ? 'TAP GO' : 'TAP WHERE TO FLY'); statusColor = f.active ? (f.paused ? HudColors.caution : HudColors.ready) : HudText.dim; @@ -98,7 +116,21 @@ class VisionModePanel extends StatelessWidget { note = t.cannotConfirm ? t.reasonLabel : null; } - final List chips = tapFly + final List chips = quickShots + ? [ + for (final QuickShot s in QuickShot.offered( + omni: model.profile.vision == VisionSensors.omni || !model.profile.connected, + zoom: model.profile.hasZoom || !model.profile.connected, + )) + HudChip( + key: ValueKey('quickshot-${s.name}'), + label: s.label, + selected: session.quickShot == s, + // A shot cannot change once it flies. + onTap: active ? null : () => session.setQuickShot(s), + ), + ] + : tapFly ? [ for (final TapFlyDirection d in gate.tapFlyDirections()) HudChip( @@ -110,7 +142,7 @@ class VisionModePanel extends StatelessWidget { ), ] : [ - for (final TrackMode m in TrackMode.values) + for (final TrackMode m in TrackMode.offered(omni: model.profile.vision == VisionSensors.omni || !model.profile.connected)) HudChip( key: ValueKey('track-${m.name}'), label: m.label, @@ -134,6 +166,19 @@ class VisionModePanel extends StatelessWidget { }); }, ), + // Circle: the orbit speed, live while circling (negative = anticlockwise). + if (session.trackMode == TrackMode.circle) + _SpeedStepper( + speed: session.circleSpeed, + min: -10, + max: 10, + keyPrefix: 'track-circle', + onStep: (int d) { + final double v = (session.circleSpeed + d).clamp(-10.0, 10.0); + session.setCircleSpeed(v); + _guard('Circle speed', () => gate.setTrackCircleSpeed(v)); + }, + ), // The aircraft finds subjects itself; tap one instead of drawing. if (ms.activeTrack.autoSensingSupported && !active) HudChip( @@ -145,13 +190,13 @@ class VisionModePanel extends StatelessWidget { ]; return Container( - key: ValueKey(tapFly ? 'tapfly-panel' : 'track-panel'), + key: ValueKey(tapFly ? 'tapfly-panel' : (quickShots ? 'quickshot-panel' : 'track-panel')), constraints: const BoxConstraints(maxWidth: 390), padding: const EdgeInsets.fromLTRB(10, 6, 6, 6), decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(mainAxisSize: MainAxisSize.min, children: [ - PVIcon(tapFly ? 'chevronsRight' : 'crosshair', size: 13, color: Glass.ink), + PVIcon(tapFly ? 'chevronsRight' : (quickShots ? 'dronie' : 'crosshair'), size: 13, color: Glass.ink), const SizedBox(width: 6), Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)), const SizedBox(width: 8), @@ -202,17 +247,26 @@ class VisionModePanel extends StatelessWidget { } class _SpeedStepper extends StatelessWidget { - const _SpeedStepper({required this.speed, required this.onStep}); + const _SpeedStepper({ + required this.speed, + required this.onStep, + this.min = SafetyGate.tapFlyMinSpeed, + this.max = SafetyGate.tapFlyMaxSpeed, + this.keyPrefix = 'tapfly-speed', + }); final double speed; final ValueChanged onStep; + final double min; + final double max; + final String keyPrefix; @override Widget build(BuildContext context) { Widget b(String l, int d, bool on) => Opacity( opacity: on ? 1 : 0.35, child: GestureDetector( - key: ValueKey('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'), + key: ValueKey('$keyPrefix-${d > 0 ? 'plus' : 'minus'}'), behavior: HitTestBehavior.opaque, onTap: on ? () => onStep(d) : null, child: Container( @@ -225,9 +279,9 @@ class _SpeedStepper extends StatelessWidget { ), ); return Row(mainAxisSize: MainAxisSize.min, children: [ - b('−', -1, speed > SafetyGate.tapFlyMinSpeed), + b('−', -1, speed > min), SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)), - b('+', 1, speed < SafetyGate.tapFlyMaxSpeed), + b('+', 1, speed < max), ]); } } diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart index bcec5ec..ca0218b 100644 --- a/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart @@ -15,7 +15,7 @@ import '../../hud_kit.dart'; /// The route summary across the top of the map while editing (GO 4's top /// board): name, waypoints, length, time, cruise speed, and the issue count. class WaypointBoard extends StatelessWidget { - const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap}); + const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap, this.resumeFrom, this.onResume}); final WaypointEditor editor; final MissionLimits limits; @@ -23,6 +23,11 @@ class WaypointBoard extends StatelessWidget { /// Opens the route settings (where the issues are listed). final VoidCallback onTap; + /// The waypoint (0-based) this route was interrupted before, when it was: + /// a RESUME chip flies the rest of it. + final int? resumeFrom; + final VoidCallback? onResume; + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -67,6 +72,19 @@ class WaypointBoard extends StatelessWidget { _sep(), Text(hint, style: HudText.label.copyWith(color: Glass.ink)), ], + if (resumeFrom != null && onResume != null && resumeFrom! < m.points.length) ...[ + const SizedBox(width: 8), + GestureDetector( + key: const ValueKey('wp-resume'), + behavior: HitTestBehavior.opaque, + onTap: onResume, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)), + child: Text('RESUME FROM ${resumeFrom! + 1}', style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ), + ], ]), ); }, @@ -254,6 +272,8 @@ class WaypointRunPanel extends StatelessWidget { const SizedBox(width: 8), Text(left >= 1000 ? '${(left / 1000).toStringAsFixed(2)} km left' : '${left.round()} m left', style: HudText.label), ], + const SizedBox(width: 8), + _CruiseSpeed(initial: editor.mission.cruiseSpeed, gate: gate, onError: onError), const SizedBox(width: 10), _RunButton( id: run.paused ? 'resume' : 'pause', @@ -290,6 +310,59 @@ class WaypointRunPanel extends StatelessWidget { } } +/// The mission's cruise speed, changed in flight (`setAutoFlightSpeed`): − / + +/// by 1 m/s. Starts at the route's speed; the operator does not report it back. +class _CruiseSpeed extends StatefulWidget { + const _CruiseSpeed({required this.initial, required this.gate, required this.onError}); + + final double initial; + final SafetyGate gate; + final void Function(String label, Object error) onError; + + @override + State<_CruiseSpeed> createState() => _CruiseSpeedState(); +} + +class _CruiseSpeedState extends State<_CruiseSpeed> { + late double _speed = widget.initial; + + Future _step(int d) async { + final double before = _speed; + final double next = (_speed + d).clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed); + setState(() => _speed = next); + try { + await widget.gate.setWaypointCruiseSpeed(next); + } catch (e) { + if (mounted) setState(() => _speed = before); + widget.onError('Cruise speed', e); + } + } + + @override + Widget build(BuildContext context) { + Widget b(String l, int d, bool on) => Opacity( + opacity: on ? 1 : 0.35, + child: GestureDetector( + key: ValueKey('wp-speed-${d > 0 ? 'plus' : 'minus'}'), + behavior: HitTestBehavior.opaque, + onTap: on ? () => _step(d) : null, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)), + ), + ), + ); + return Row(mainAxisSize: MainAxisSize.min, children: [ + b('−', -1, _speed > WaypointMissionSpec.minSpeed), + SizedBox(width: 50, child: Text('${_speed.toStringAsFixed(0)} m/s', key: const ValueKey('wp-speed'), textAlign: TextAlign.center, style: HudText.mono)), + b('+', 1, _speed < WaypointMissionSpec.maxSpeed), + ]); + } +} + class _RunButton extends StatelessWidget { const _RunButton({required this.id, required this.icon, required this.label, required this.onTap, this.color = Glass.accent}); diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart index d747879..10f54b9 100644 --- a/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart' show LatLng; import '../../../../fly/hud_geometry.dart'; import '../../../../fly/modes/mission_library.dart'; +import '../../../../fly/modes/terrain.dart'; import '../../../../fly/modes/waypoint_editor.dart'; import '../../../../fly/modes/waypoint_mission.dart'; import '../../../../theme.dart'; @@ -139,6 +141,7 @@ class RouteSettingsList extends StatelessWidget { onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(repeatTimes: (m.repeatTimes + d).clamp(1, 10))), ), ValueTile(label: 'Length', value: '${m.length.round()} m · ≈${formatDuration(m.estimatedSeconds)}'), + TerrainCheckRow(editor: editor, home: limits.home), if (issues.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), @@ -166,6 +169,76 @@ class RouteSettingsList extends StatelessWidget { } } +/// "Check terrain": samples the ground along the route online and lists where +/// it comes within 15 m. Runs only on tap — it sends the route's coordinates +/// to the elevation service — and from the home point, or waypoint 1 when the +/// aircraft has none yet (then altitudes are taken as above waypoint 1's ground). +class TerrainCheckRow extends StatefulWidget { + const TerrainCheckRow({super.key, required this.editor, this.home, this.source = const OpenMeteoElevation()}); + + final WaypointEditor editor; + final LatLng? home; + final ElevationSource source; + + @override + State createState() => _TerrainCheckRowState(); +} + +class _TerrainCheckRowState extends State { + bool _running = false; + List? _found; + String? _error; + + /// The route the result belongs to; editing it makes the result stale. + WaypointMissionSpec? _checked; + + Future _run() async { + final WaypointMissionSpec m = widget.editor.mission; + setState(() { + _running = true; + _error = null; + }); + try { + final List found = await TerrainCheck(widget.source).run(m, widget.home ?? m.points.first.at); + if (mounted) { + setState(() { + _found = found; + _checked = m; + }); + } + } catch (_) { + if (mounted) setState(() => _error = 'Could not reach the elevation service'); + } finally { + if (mounted) setState(() => _running = false); + } + } + + @override + Widget build(BuildContext context) { + final WaypointMissionSpec m = widget.editor.mission; + final bool stale = _checked != null && !identical(_checked, m); + final List? f = stale ? null : _found; + final String hint = _running + ? 'Checking…' + : _error ?? + (f == null + ? 'Sends the route to open-meteo.com for ground heights${widget.home == null ? ' (no home point yet: measured from waypoint 1)' : ''}' + : f.isEmpty + ? 'At least 15 m over the ground all the way' + : '${f.length} leg${f.length == 1 ? '' : 's'} too close to the ground'); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ButtonTile( + id: 'terrain-check', + label: 'Check terrain', + hint: hint, + button: 'Check', + onTap: _running || m.points.length < 2 ? null : _run, + ), + for (final TerrainIssue t in f ?? const []) IssueRow(issue: t.toIssue(), onTap: () => widget.editor.select(t.leg + 1)), + ]); + } +} + class IssueRow extends StatelessWidget { const IssueRow({super.key, required this.issue, this.onTap}); diff --git a/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart b/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart index 8f6327f..740469c 100644 --- a/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart +++ b/Fly App/lib/ui/fly/overlays/fly_zone_licences.dart @@ -230,7 +230,8 @@ class FlyZoneDatabaseRow extends StatelessWidget { child: Text( db == null ? 'Not reported yet' - : '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}', + : '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}' + '${db.preciseBehind ? '\nPrecise ${db.preciseRemoteVersion} available: it comes with the aircraft firmware' : ''}', style: HudText.monoSmall.copyWith(color: db?.outOfDate == true ? HudColors.caution : Glass.ink), ), ), diff --git a/Fly App/lib/ui/fly/panels/gimbal_sheet.dart b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart index 0633fe6..5cff55b 100644 --- a/Fly App/lib/ui/fly/panels/gimbal_sheet.dart +++ b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart @@ -6,7 +6,9 @@ import '../../../fly/calibration.dart'; import '../../../fly/gimbal_control.dart'; import '../../../fly/hud_features.dart'; import '../../../fly/product_profile.dart'; +import '../../../fly/settings/settings_spec.dart'; import '../../../fly/state/gimbal_state.dart'; +import '../../../fly/state/key_state.dart'; import '../../../fly/state/parse.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; @@ -15,6 +17,87 @@ import '../hud_kit.dart'; import 'camera_menu/camera_rows.dart'; import 'camera_write.dart'; import 'settings/calibration/calibration_hub.dart'; +import 'settings/setting_tiles.dart'; + +/// Roll-axis (and pitch / yaw) motor tuning on gimbals that expose it +/// (`GimbalKey.*_MOTOR_CONTROL_*`, Ronin-class and some Zenmuse). Each row +/// asks `isKeySupported` and hides when the gimbal says no, so on most +/// aircraft the whole section stays empty. +class GimbalMotorRows extends StatefulWidget { + const GimbalMotorRows({super.key, required this.model, required this.dji, required this.onError}); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + + static const List<(String, String)> _axes = <(String, String)>[('ROLL', 'Roll'), ('PITCH', 'Pitch'), ('YAW', 'Yaw')]; + static const List<(String, String)> _values = <(String, String)>[ + ('MOTOR_CONTROL_STIFFNESS', 'stiffness'), + ('MOTOR_CONTROL_STRENGTH', 'strength'), + ('MOTOR_CONTROL_GYRO_FILTERING_FACTOR', 'gyro filtering'), + ('MOTOR_CONTROL_PRE_CONTROL', 'pre-control'), + ]; + + static final List rows = [ + for (final (String axis, String axisLabel) in _axes) + for (final (String key, String label) in _values) + KeyNumberSpec( + id: 'gimbal${axis}_$key', + label: '$axisLabel $label', + key: DjiKey.gimbal('${axis}_$key'), + min: 0, + max: 100, + groundOnly: true, + ), + ]; + + @override + State createState() => _GimbalMotorRowsState(); +} + +class _GimbalMotorRowsState extends State { + late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys); + + @override + void initState() { + super.initState(); + _subs.watch([for (final SettingSpec r in GimbalMotorRows.rows) ...r.watched]); + } + + @override + void dispose() { + _subs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final FlightModel m = widget.model; + return ListenableBuilder( + listenable: m.keys, + builder: (BuildContext context, _) { + final SettingsContext c = SettingsContext(profile: m.profile, keys: m.keys, aircraft: m.aircraft); + // Only rows the gimbal confirmed: unknown support would show a column of dashes. + final List shown = [ + for (final SettingSpec r in GimbalMotorRows.rows) + if (m.keys.supported(r.key!) == true) r, + ]; + if (shown.isEmpty) return const SizedBox.shrink(); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + GimbalSettingsList._section('MOTOR TUNING'), + for (final SettingSpec r in shown) + SettingRowTile( + spec: r, + settings: c, + dji: widget.dji, + onError: widget.onError, + custom: (CustomSpec _) => const SizedBox.shrink(), + ), + ]); + }, + ); + } +} /// Opens the gimbal settings (plan P5) as a right-side glass sheet over the /// live view, like the camera menu. @@ -257,6 +340,7 @@ class GimbalSettingsList extends StatelessWidget { whenDisconnected: false, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _section('ADVANCED'), + _presetRow(p), for (final (GimbalTuning t, AxisRange r) in GimbalTuning.rowsFor(p)) if (_g.reportedSettings[t.id] != null) _tuningRow(t, r), ButtonTile( @@ -268,6 +352,8 @@ class GimbalSettingsList extends StatelessWidget { ), ]), ), + // ── Roll motor (gimbals that expose motor tuning; hidden elsewhere) ── + if (p.connected) GimbalMotorRows(model: model, dji: dji, onError: onError), ], ), ); @@ -317,6 +403,30 @@ class GimbalSettingsList extends StatelessWidget { ); } + /// Fast / Medium / Slow: max speed and smoothness on both axes at once. + Widget _presetRow(ProductProfile p) { + final Map ranges = p.gimbal?.controller ?? const {}; + final Map reported = { + for (final MapEntry e in ranges.entries) + if (_g.reportedSettings[e.key] != null) e.key: e.value, + }; + if (p.connected && GimbalPreset.fast.valuesFor(reported).isEmpty) return const SizedBox.shrink(); + final GimbalPreset? current = GimbalPreset.matching(reported, _g.setting); + return ChoiceTile( + key: const ValueKey('row-gimbalPreset'), + label: 'Movement', + hint: current == null && p.connected ? 'Custom' : null, + options: <(String, String)>[for (final GimbalPreset g in GimbalPreset.values) (g.name, g.label)], + selected: current?.name, + onSelect: (String name) { + final GimbalPreset g = GimbalPreset.values.byName(name); + for (final MapEntry e in g.valuesFor(reported).entries) { + _write('Gimbal movement', e.key, e.value); + } + }, + ); + } + Widget _tuningRow(GimbalTuning t, AxisRange r) { final int? v = asInt(_g.setting(t.id)); final int step = GimbalTuning.stepFor(r); diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart b/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart index dad833c..edba6dc 100644 --- a/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart +++ b/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../../../../fly/calibration.dart'; +import '../../../../../fly/fly_prefs.dart'; +import '../../../../../fly/state/aircraft_state.dart' show CompassSensor; import '../../../hud_kit.dart'; import 'calibration_scaffold.dart'; import 'illustrations.dart'; @@ -108,6 +110,7 @@ class _CompassWizardState extends WizardState with _Pages 1 ? ' Its $count compasses calibrate together.' : ''}', onStart: _start, + detail: CompassInterferenceMeter(sensors: model.aircraft.compassSensors), ), RunPhase.starting => starting(const CompassIllustration(step: CompassStep.horizontal, animate: false)), RunPhase.running => CalibrationScaffold( @@ -120,6 +123,7 @@ class _CompassWizardState extends WizardState with _Pages[stopAction], ), RunPhase.succeeded => succeeded( @@ -151,7 +155,10 @@ class _ImuWizardState extends WizardState with _Pages { @override bool get canAbort => false; - Future _start() => start(dji.startIMUCalibration); + /// One IMU of several (external IMU1 / IMU2 on the M-series), or all when null. + int? _unit; + + Future _start() => start(() => dji.startIMUCalibration(index: _unit)); @override Widget build(BuildContext context) { @@ -163,8 +170,10 @@ class _ImuWizardState extends WizardState with _Pages { heading: 'Calibrate the IMU', body: 'Take the propellers off and put the aircraft on a flat, level, steady surface with the motors off and ' 'the gimbal clamp removed. Do not touch it while it calibrates — about 5 to 10 minutes. Some aircraft ask ' - 'to be turned onto each side; this screen shows which.${count > 1 ? ' All $count IMUs calibrate together.' : ''}', + 'to be turned onto each side; this screen shows which.' + '${count > 1 ? (_unit == null ? ' All $count IMUs calibrate together.' : ' Only IMU ${_unit! + 1} calibrates.') : ''}', onStart: _start, + detail: count > 1 ? _unitChooser(count) : null, ), RunPhase.starting => starting(const ImuIllustration()), RunPhase.running => CalibrationScaffold( @@ -191,6 +200,67 @@ class _ImuWizardState extends WizardState with _Pages { RunPhase.failed => failed(illustration: const IconIllustration('gauge', color: HudColors.danger), onRetry: _start), }; } + + /// All IMUs, or one: GO 4's IMU1 / IMU2 choice on aircraft with external units. + Widget _unitChooser(int count) => Wrap(spacing: 6, runSpacing: 6, children: [ + ChoiceChip( + key: const ValueKey('imu-all'), + label: const Text('All IMUs'), + selected: _unit == null, + onSelected: (_) => setState(() => _unit = null), + ), + for (int i = 0; i < count; i++) + ChoiceChip( + key: ValueKey('imu-$i'), + label: Text('IMU ${i + 1}'), + selected: _unit == i, + onSelected: (_) => setState(() => _unit = i), + ), + ]); +} + +/// The compass's magnetic interference, per compass (GO 4's interference +/// meter): a bar graded by the flight controller's own verdict. Move until +/// every bar is green before calibrating. +class CompassInterferenceMeter extends StatelessWidget { + const CompassInterferenceMeter({super.key, required this.sensors}); + + final List sensors; + + @override + Widget build(BuildContext context) { + if (sensors.isEmpty) return const SizedBox.shrink(); + return Column(key: const ValueKey('compass-interference'), crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('MAGNETIC INTERFERENCE', style: HudText.label), + const SizedBox(height: 4), + for (final CompassSensor s in sensors) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row(children: [ + SizedBox(width: 70, child: Text(sensors.length > 1 ? 'Compass ${s.index + 1}' : 'Compass', style: HudText.monoSmall)), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: switch (s.level) { 0 => 0.25, 1 => 0.6, 2 => 1.0, _ => 0.0 }, + minHeight: 6, + backgroundColor: HudColors.off, + color: switch (s.level) { 0 => HudColors.ready, 1 => HudColors.caution, _ => HudColors.danger }, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 72, + child: Text( + switch (s.level) { 0 => 'Low', 1 => 'Moderate', 2 => 'High', _ => 'No reading' }, + style: HudText.monoSmall.copyWith(color: switch (s.level) { 0 => HudColors.ready, 1 => HudColors.caution, 2 => HudColors.danger, _ => HudText.dim }), + ), + ), + ]), + ), + ]); + } } // ── Remote controller ─────────────────────────────────────────────────────── @@ -372,6 +442,7 @@ class _LensWizardState extends WizardState with _Pages { @override Widget build(BuildContext context) { + _remember(); return switch (run.phase) { RunPhase.idle => intro( illustration: const IconIllustration('focus'), @@ -393,11 +464,30 @@ class _LensWizardState extends WizardState with _Pages { RunPhase.succeeded => succeeded( illustration: const IconIllustration('focus', progress: 1, color: HudColors.ready), heading: 'Lens calibrated', - body: 'Manual focus now follows the lens\'s distance scale.', + body: 'Manual focus now follows the lens\'s distance scale. For a sharp infinity, focus by hand on ' + 'something far away (a horizon, a building over 100 m off) and record it as infinity.', + more: [WizardAction('Set infinity', _infinity, id: 'infinity')], ), RunPhase.failed => failed(illustration: const IconIllustration('focus', color: HudColors.danger), onRetry: _start), }; } + + /// `calibratedInfinityFocus`: the lens's current focus is its infinity. + Future _infinity() async { + final bool ok = await send('Infinity focus', dji.calibrateInfinityFocus); + if (ok && mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(content: Text('Infinity focus recorded'))); + } + } + + /// Once calibrated here, the first-manual-focus prompt stops asking for this camera. + bool _remembered = false; + + void _remember() { + if (_remembered || run.phase != RunPhase.succeeded) return; + _remembered = true; + FlyPrefs.markLensCalibrated(model.profile.camera?.displayName ?? model.model ?? 'camera'); + } } // ── Sensor cleaning ───────────────────────────────────────────────────────── diff --git a/Fly App/lib/ui/fly/panels/settings/link_rows.dart b/Fly App/lib/ui/fly/panels/settings/link_rows.dart new file mode 100644 index 0000000..e4cf426 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/link_rows.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../dji_service.dart'; +import '../../../../flight_model.dart'; +import '../../../../fly/fly_prefs.dart'; +import '../../../../fly/state/link_extras.dart'; +import '../../../../theme.dart'; +import '../../hud_kit.dart'; +import '../camera_menu/camera_rows.dart'; +import 'setting_tiles.dart' show TextSettingDialog; + +/// GO 4's RC → Button Customization (plan P6): what C1 and C2 do. Kept on +/// the phone; the flight screen acts on the remote's presses. +class CustomButtonRows extends StatelessWidget { + const CustomButtonRows({super.key}); + + @override + Widget build(BuildContext context) { + final List<(String, String)> options = <(String, String)>[for (final RcButtonAction a in RcButtonAction.values) (a.name, a.label)]; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + for (final RcButton b in RcButton.values) + ValueListenableBuilder( + valueListenable: b == RcButton.c1 ? FlyPrefs.c1 : FlyPrefs.c2, + builder: (BuildContext context, RcButtonAction a, _) => ChoiceTile( + key: ValueKey('row-rc${b.label}'), + label: '${b.label} button', + options: options, + selected: a.name, + onSelect: (String v) => FlyPrefs.setRcButton(b, RcButtonAction.values.byName(v)), + ), + ), + ]); + } +} + +/// A slave remote looks for masters in range and joins one with its password. +class MasterSearchRows extends StatefulWidget { + const MasterSearchRows({super.key, required this.model, required this.dji, required this.onError}); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + + @override + State createState() => _MasterSearchRowsState(); +} + +class _MasterSearchRowsState extends State { + bool _searching = false; + + @override + void dispose() { + if (_searching) widget.dji.searchMasters(false).catchError((Object _) {}); + super.dispose(); + } + + Future _toggle() async { + final bool on = !_searching; + setState(() => _searching = on); + if (on) widget.model.rcMasters.value = const []; + try { + await widget.dji.searchMasters(on); + } catch (e) { + if (mounted) setState(() => _searching = false); + widget.onError('Search for master', e); + } + } + + Future _join(RcPeer m) async { + final String? password = await showDialog( + context: context, + builder: (BuildContext ctx) => TextSettingDialog(label: 'Password for ${m.name ?? 'master'}', initial: '', secret: true, maxLength: 4), + ); + if (password == null) return; + try { + final String? r = await widget.dji.connectToMaster(id: m.id, name: m.name ?? '', password: password); + if (!mounted) return; + final String msg = switch (r) { + 'ACCEPTED' => 'Joined ${m.name ?? 'the master'}', + 'PASSWORD_ERROR' => 'Wrong password', + 'REJECTED' => 'The master refused', + 'MAXIMUM_CAPACITY' => 'The master has no room for another slave', + 'TIMEOUT' => 'The master did not answer', + _ => 'Could not join ($r)', + }; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(msg))); + } catch (e) { + widget.onError('Join master', e); + } + } + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ButtonTile( + id: 'searchMasters', + label: _searching ? 'Searching for masters…' : 'Find a master remote', + button: _searching ? 'Stop' : 'Search', + onTap: _toggle, + ), + ValueListenableBuilder>( + valueListenable: widget.model.rcMasters, + builder: (BuildContext context, List masters, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + for (final RcPeer m in masters) + ValueTile( + key: ValueKey('master-${m.id}'), + label: m.name ?? 'Remote ${m.id}', + hint: m.signal == null ? null : 'Signal ${m.signal} %', + value: 'Join', + onTap: () => _join(m), + ), + ]), + ), + ]); + } +} + +/// A master remote lists the slaves that joined it. +class SlaveListRow extends StatefulWidget { + const SlaveListRow({super.key, required this.dji, required this.onError}); + + final DjiService dji; + final void Function(String label, Object error) onError; + + @override + State createState() => _SlaveListRowState(); +} + +class _SlaveListRowState extends State { + List? _slaves; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final List> l = await widget.dji.getSlaves(); + if (mounted) setState(() => _slaves = [for (final Map m in l) RcPeer.fromMap(m)]); + } catch (_) { + if (mounted) setState(() => _slaves = const []); + } + } + + @override + Widget build(BuildContext context) { + final List? s = _slaves; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueTile( + key: const ValueKey('row-slaves'), + label: 'Slaves', + value: s == null ? '…' : (s.isEmpty ? 'None' : '${s.length}'), + onTap: _load, + ), + for (final RcPeer p in s ?? const []) + ValueTile(label: p.name ?? 'Remote ${p.id}', value: p.signal == null ? '—' : '${p.signal} %'), + ]); + } +} + +/// The video link's interference as a bar chart (GO 4's transmission +/// spectrum): taller bars are noisier channels; the quietest is marked, the +/// one to pick in Custom mode. Listens only while it is on screen. +class SpectrumChart extends StatefulWidget { + const SpectrumChart({super.key, required this.model, required this.dji, required this.connected}); + + final FlightModel model; + final DjiService dji; + final bool connected; + + @override + State createState() => _SpectrumChartState(); +} + +class _SpectrumChartState extends State { + @override + void initState() { + super.initState(); + if (widget.connected) widget.dji.watchSpectrum(true).catchError((Object _) {}); + } + + @override + void dispose() { + widget.dji.watchSpectrum(false).catchError((Object _) {}); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.model.spectrum, + builder: (BuildContext context, Spectrum? s, _) { + if (s == null || s.bars.isEmpty) { + return ValueTile( + key: const ValueKey('row-spectrum'), + label: 'Interference', + value: widget.connected ? 'Listening…' : '—', + ); + } + final SpectrumBar? q = s.quietest; + return Padding( + key: const ValueKey('row-spectrum'), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: Text('INTERFERENCE', style: HudText.label)), + if (q != null) Text('Quietest: ${q.label}', style: HudText.monoSmall.copyWith(color: HudColors.ready)), + ]), + const SizedBox(height: 6), + SizedBox(height: 70, child: CustomPaint(size: Size.infinite, painter: SpectrumPainter(s))), + ]), + ); + }, + ); + } +} + +class SpectrumPainter extends CustomPainter { + SpectrumPainter(this.spectrum); + + final Spectrum spectrum; + + /// dBm drawn as an empty bar (quiet) and a full one (loud). + static const double floor = -110, ceiling = -60; + + @override + void paint(Canvas canvas, Size size) { + final List bars = spectrum.bars; + if (bars.isEmpty) return; + final double w = size.width / bars.length; + final SpectrumBar? q = spectrum.quietest; + for (int i = 0; i < bars.length; i++) { + final double t = ((bars[i].power - floor) / (ceiling - floor)).clamp(0.05, 1.0); + final Color c = identical(bars[i], q) + ? HudColors.ready + : t > 0.7 + ? HudColors.danger + : t > 0.4 + ? HudColors.caution + : Glass.sat; + final double h = size.height * t; + canvas.drawRect(Rect.fromLTWH(i * w + w * 0.15, size.height - h, w * 0.7, h), Paint()..color = c); + } + } + + @override + bool shouldRepaint(SpectrumPainter old) => old.spectrum != spectrum; +} + +/// GO 4's video cache: record the live feed to the phone while flying, with a +/// size cap and Clear. +class VideoCacheRows extends StatefulWidget { + const VideoCacheRows({super.key, required this.dji, required this.onError}); + + final DjiService dji; + final void Function(String label, Object error) onError; + + static const List limits = [1024, 2048, 4096, 8192, 16384]; + + @override + State createState() => _VideoCacheRowsState(); +} + +class _VideoCacheRowsState extends State { + Map? _info; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + try { + final Map i = await widget.dji.getVideoCacheInfo(); + if (mounted) setState(() => _info = i); + } on MissingPluginException { + // No native side (tests). + } catch (_) {} + } + + static String _size(num? bytes) { + final double b = (bytes ?? 0).toDouble(); + if (b >= 1 << 30) return '${(b / (1 << 30)).toStringAsFixed(1)} GB'; + return '${(b / (1 << 20)).toStringAsFixed(0)} MB'; + } + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueListenableBuilder( + valueListenable: FlyPrefs.videoCache, + builder: (BuildContext context, bool on, _) => SwitchTile( + key: const ValueKey('row-videoCache'), + label: 'Cache the live video', + hint: 'Records the feed to this phone while flying (raw H.264, plays in VLC)', + value: on, + onChanged: FlyPrefs.setVideoCache, + ), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.videoCacheLimitMb, + builder: (BuildContext context, int mb, _) => ChoiceTile( + key: const ValueKey('row-videoCacheLimit'), + label: 'Cache size', + hint: 'The oldest recordings go first', + options: <(String, String)>[for (final int l in VideoCacheRows.limits) ('$l', '${l ~/ 1024} GB')], + selected: '$mb', + onSelect: (String v) => FlyPrefs.setVideoCacheLimitMb(int.parse(v)), + ), + ), + ButtonTile( + id: 'clearVideoCache', + label: 'Clear video cache', + hint: _info == null ? null : '${_info!['files'] ?? 0} recordings · ${_size(_info!['bytes'] as num?)}', + button: 'Clear', + danger: true, + onTap: () async { + try { + await widget.dji.clearVideoCache(); + } catch (e) { + widget.onError('Clear video cache', e); + } + _refresh(); + }, + ), + ]); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart b/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart index cf316b7..cf881b2 100644 --- a/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart +++ b/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart @@ -79,6 +79,13 @@ class SettingRowTile extends StatelessWidget { ), KeyNumberSpec() => _NumberTile(spec: s, keys: _keys, locked: locked, onSet: (int v) => _write(s.key, v)), KeyReadoutSpec() => ValueTile(key: ValueKey('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)), + KeyTextSpec() => ValueTile( + key: ValueKey('row-${s.id}'), + label: s.label, + hint: locked ?? s.hint, + value: s.shown(_keys), + onTap: locked == null ? () => _editText(context, s) : null, + ), KeyActionSpec() => ButtonTile( key: ValueKey('row-${s.id}'), id: s.id, @@ -96,6 +103,28 @@ class SettingRowTile extends StatelessWidget { return tile; } + Future _editText(BuildContext context, KeyTextSpec t) async { + final String? typed = await showDialog( + context: context, + builder: (BuildContext ctx) => TextSettingDialog( + label: t.label, + initial: t.secret ? '' : (t.current(_keys) ?? ''), + secret: t.secret, + maxLength: t.maxLength, + ), + ); + if (typed == null || typed == t.current(_keys)) return; + if (t.send != null) { + try { + await t.send!(dji, typed); + } catch (e) { + onError(t.label, e); + } + return; + } + await _write(t.key, typed); + } + Future _runAction(BuildContext context, KeyActionSpec a) async { if (a.confirmTitle != null) { final SlideResult? ok = await showSlideToConfirm( @@ -210,6 +239,65 @@ class _NumberDialogState extends State<_NumberDialog> { } } +/// Types a string setting. The dialog's own State owns the controller (one +/// disposed right after `await showDialog` crashes the exit animation). +class TextSettingDialog extends StatefulWidget { + const TextSettingDialog({super.key, required this.label, required this.initial, this.secret = false, this.maxLength = 32}); + + final String label; + final String initial; + final bool secret; + final int maxLength; + + @override + State createState() => _TextSettingDialogState(); +} + +class _TextSettingDialogState extends State { + late final TextEditingController _text = TextEditingController(text: widget.initial); + bool _show = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _submit() { + final String v = _text.text.trim(); + Navigator.of(context).pop(v.isEmpty ? null : v); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: const Color(0xFF10203F), + title: Text(widget.label, style: HudText.sans.copyWith(fontSize: 15)), + content: TextField( + key: const ValueKey('text-input'), + controller: _text, + autofocus: true, + obscureText: widget.secret && !_show, + maxLength: widget.maxLength, + style: HudText.value.copyWith(fontSize: 16), + decoration: InputDecoration( + suffixIcon: widget.secret + ? IconButton( + onPressed: () => setState(() => _show = !_show), + icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18), + ) + : null, + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + FilledButton(key: const ValueKey('text-set'), onPressed: _submit, child: const Text('Set')), + ], + ); + } +} + /// GO 4's stick-mode picture: what each stick does in Mode 1 / 2 / 3. class StickModeDiagram extends StatelessWidget { const StickModeDiagram({super.key, required this.mode}); 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 964ed36..2e4ef6d 100644 --- a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart +++ b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart @@ -24,6 +24,7 @@ import '../../overlays/live_stream.dart'; import '../camera_menu/camera_rows.dart'; import '../gimbal_sheet.dart'; import 'calibration/calibration_hub.dart'; +import 'link_rows.dart'; import 'sensing_rows.dart'; import 'setting_tiles.dart'; @@ -41,6 +42,7 @@ Future showFlySettings( String initialTab = SettingsTabs.safety, VoidCallback? onOpenStatus, VoidCallback? onClearRoute, + VoidCallback? onOpenFlightLogs, }) { return showGeneralDialog( context: context, @@ -57,6 +59,7 @@ Future showFlySettings( initialTab: initialTab, onOpenStatus: onOpenStatus, onClearRoute: onClearRoute, + onOpenFlightLogs: onOpenFlightLogs, ), ), transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( @@ -75,6 +78,7 @@ class FlySettingsSheet extends StatefulWidget { this.initialTab = SettingsTabs.safety, this.onOpenStatus, this.onClearRoute, + this.onOpenFlightLogs, }); final FlightModel model; @@ -84,6 +88,9 @@ class FlySettingsSheet extends StatefulWidget { final VoidCallback? onOpenStatus; final VoidCallback? onClearRoute; + /// Opens the flight records (General → Flight logs); the row hides without it. + final VoidCallback? onOpenFlightLogs; + static const double maxWidth = 540; static const double railWidth = 112; @@ -337,6 +344,32 @@ class _FlySettingsSheetState extends State { onError: widget.onError, ), SettingsTabs.airSenseList => AirSenseList(key: const ValueKey('row-airSense'), airSense: _m.airSense, shrinkWrap: true), + SettingsTabs.units => ValueListenableBuilder( + valueListenable: FlyPrefs.units, + builder: (BuildContext context, UnitSystem u, _) => ChoiceTile( + key: const ValueKey('row-units'), + label: 'Units', + hint: 'What the flight screen shows; settings stay in metres', + options: <(String, String)>[for (final UnitSystem s in UnitSystem.values) (s.name, s.label)], + selected: u.name, + onSelect: (String v) => FlyPrefs.setUnits(UnitSystem.values.byName(v)), + ), + ), + SettingsTabs.customButtons => const CustomButtonRows(key: ValueKey('row-customButtons')), + SettingsTabs.masterSearch => + MasterSearchRows(key: const ValueKey('row-masterSearch'), model: _m, dji: widget.dji, onError: widget.onError), + SettingsTabs.slaveList => SlaveListRow(key: const ValueKey('row-slaveList'), dji: widget.dji, onError: widget.onError), + SettingsTabs.spectrum => SpectrumChart(key: const ValueKey('spectrum'), model: _m, dji: widget.dji, connected: c.connected), + SettingsTabs.videoCache => VideoCacheRows(key: const ValueKey('row-videoCacheGroup'), dji: widget.dji, onError: widget.onError), + SettingsTabs.flightLogs => widget.onOpenFlightLogs == null + ? const SizedBox.shrink() + : ValueTile( + key: const ValueKey('row-flightLogs'), + label: 'Flight logs', + hint: 'Every recorded flight, its track and its logbook upload', + value: 'Open', + onTap: widget.onOpenFlightLogs, + ), SettingsTabs.about => _about(), SettingsTabs.developer => _developer(), _ => const SizedBox.shrink(), diff --git a/Fly App/lib/ui/fly/top/status_bar.dart b/Fly App/lib/ui/fly/top/status_bar.dart index 7dfb4a0..d1d3196 100644 --- a/Fly App/lib/ui/fly/top/status_bar.dart +++ b/Fly App/lib/ui/fly/top/status_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../flight_model.dart'; import '../../../fly/flight_status.dart'; import '../../../fly/hud_features.dart'; +import '../../../fly/state/aircraft_state.dart' show RtkStatus; import '../../../fly/state/alert_state.dart'; import '../../../fly/state/link_state.dart'; import '../../../fly/state/vision_state.dart'; @@ -42,6 +43,11 @@ class StatusBar extends StatelessWidget { _StatusChip(model: model, onTap: onStatus), const SizedBox(width: 6), _GpsCluster(model: model), + Requires( + HudFeatures.rtkStatus, + whenDisconnected: false, + child: Padding(padding: const EdgeInsets.only(left: 6), child: _RtkChip(model: model)), + ), const SizedBox(width: 6), _ModeText(model: model), Requires( @@ -130,6 +136,43 @@ class _GpsCluster extends StatelessWidget { } } +/// GO 4's RTK chip: the module's fix (FIX green, FLOAT amber, else dim) and +/// its satellites. Hidden until the module has reported once. +class _RtkChip extends StatelessWidget { + const _RtkChip({required this.model}); + + final FlightModel model; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: model.aircraft, + builder: (BuildContext context, _) { + final RtkStatus? r = model.aircraft.rtk; + if (r == null) return const SizedBox.shrink(); + final Color c = r.fixed + ? HudColors.ready + : r.solution == 'FLOAT' + ? HudColors.caution + : HudColors.off; + return GlassPill( + key: const ValueKey('rtk-chip'), + height: StatusBar.height, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text('RTK', style: HudText.label.copyWith(color: c, fontWeight: FontWeight.w700)), + const SizedBox(width: 4), + Text(r.label, style: HudText.mono.copyWith(color: c, fontWeight: FontWeight.w700)), + if (r.satellites != null && r.satellites! > 0) ...[ + const SizedBox(width: 4), + Text('${r.satellites}', style: HudText.monoSmall.copyWith(color: HudText.dim)), + ], + ]), + ); + }, + ); + } +} + class _ModeText extends StatelessWidget { const _ModeText({required this.model}); diff --git a/Fly App/lib/ui/pv_icons.dart b/Fly App/lib/ui/pv_icons.dart index 97b33e2..10ba436 100644 --- a/Fly App/lib/ui/pv_icons.dart +++ b/Fly App/lib/ui/pv_icons.dart @@ -37,6 +37,7 @@ class PvPaths { 'land': 'M12 4v12m0 0-4-4m4 4 4-4M4 20h16', 'stop': 'M7 7h10v10H7z', 'alert': 'M12 3 2 20h20L12 3zM12 10v4M12 17h.01', + 'info': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v6M12 7h.01', 'chevronsRight': 'M6 6l6 6-6 6M13 6l6 6-6 6', 'rth': 'M12 3a9 9 0 1 0 9 9M12 3v6l4 2M20 4l-3 1 1-3', 'map': 'M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2zM9 4v14M15 6v14', diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index 921b0da..ae2cee0 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -38,9 +38,13 @@ class FakeDjiService extends DjiService { /// The last `uploadWaypointMission` payload. Map? uploaded; + /// Whether the last upload went to the Waypoints 2.0 operator. + bool uploadedV2 = false; + @override - Future uploadWaypointMission(Map mission) { + Future uploadWaypointMission(Map mission, {bool v2 = false}) { uploaded = mission; + uploadedV2 = v2; return _record('uploadWaypointMission(${(mission['waypoints'] as List).length})'); } @@ -50,6 +54,90 @@ class FakeDjiService extends DjiService { Future pauseWaypointMission() => _record('pauseWaypointMission'); @override Future resumeWaypointMission() => _record('resumeWaypointMission'); + @override + Future setWaypointAutoFlightSpeed(double speed) => _record('setWaypointAutoFlightSpeed(${speed.toStringAsFixed(0)})'); + + /// What `getWaypointInterruption` answers. + Map? interruption; + @override + Future?> getWaypointInterruption() async => interruption; + + // ── POI / Follow Me / orientation / QuickShots (plan P7) ─────────────────── + Map? hotpoint; + double? maxAngular; + + @override + Future startHotpoint({ + required double lat, + required double lon, + required double altitude, + required double radius, + required double angularVelocity, + required bool clockwise, + String startPoint = 'NEAREST', + String heading = 'TOWARDS_HOT_POINT', + }) { + hotpoint = {'lat': lat, 'lon': lon, 'altitude': altitude, 'radius': radius, 'angularVelocity': angularVelocity, 'clockwise': clockwise, 'startPoint': startPoint, 'heading': heading}; + return _record('startHotpoint'); + } + + @override + Future pauseHotpoint() => _record('pauseHotpoint'); + @override + Future resumeHotpoint() => _record('resumeHotpoint'); + @override + Future stopHotpoint() => _record('stopHotpoint'); + @override + Future setHotpointRadius(double radius) => _record('setHotpointRadius(${radius.round()})'); + @override + Future setHotpointAngularVelocity(double value) => _record('setHotpointAngularVelocity(${value.toStringAsFixed(1)})'); + @override + Future resetHotpointHeading() => _record('resetHotpointHeading'); + @override + Future getHotpointMaxAngularVelocity(double radius) async => maxAngular; + @override + Future startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) => + _record('startFollowMe(${altitude.round()}, $heading)'); + @override + Future updateFollowMeTarget(double lat, double lon) => _record('updateFollowMeTarget'); + @override + Future stopFollowMe() => _record('stopFollowMe'); + @override + Future setFlightOrientationMode(String mode) => _record('setFlightOrientationMode($mode)'); + @override + Future lockCourseUsingCurrentHeading() => _record('lockCourseUsingCurrentHeading'); + @override + Future startQuickShot(String name, {double? x, double? y, double? w, double? h}) => _record('startQuickShot($name)'); + @override + Future setActiveTrackCircularSpeed(double speed) => _record('setActiveTrackCircularSpeed(${speed.toStringAsFixed(0)})'); + @override + Future setShootPhotoMode(String mode) => _record('setShootPhotoMode($mode)'); + + // ── Remote / link / cache (plan P6) ──────────────────────────────────────── + @override + Future searchMasters(bool on) => _record('searchMasters($on)'); + @override + Future connectToMaster({required int id, required String name, required String password}) async { + await _record('connectToMaster($id, $name)'); + return 'ACCEPTED'; + } + + @override + Future>> getSlaves() async => const >[]; + @override + Future watchSpectrum(bool on) async => calls.add('watchSpectrum($on)'); + @override + Future setWifiCredentials({String? ssid, String? password}) => _record('setWifiCredentials(${ssid ?? '-'}, ${password == null ? '-' : '***'})'); + @override + Future rebootWifi() => _record('rebootWifi'); + @override + Future setVideoCacheRecording(bool on, {int? limitMb}) async => calls.add('setVideoCacheRecording($on)'); + @override + Future> getVideoCacheInfo() async => {'bytes': 0, 'files': 0, 'recording': false}; + @override + Future clearVideoCache() => _record('clearVideoCache'); + @override + Future calibrateInfinityFocus() => _record('calibrateInfinityFocus'); // ── ActiveTrack / TapFly (plan P7b) ──────────────────────────────────────── @override @@ -141,7 +229,7 @@ class FakeDjiService extends DjiService { @override Future stopCompassCalibration() => _record('stopCompassCalibration'); @override - Future startIMUCalibration({int? index}) => _record('startIMUCalibration'); + Future startIMUCalibration({int? index}) => _record(index == null ? 'startIMUCalibration' : 'startIMUCalibration($index)'); @override Future startGravityCenterCalibration() => _record('startGravityCenterCalibration'); @override diff --git a/Fly App/test/fly/p6_extras_test.dart b/Fly App/test/fly/p6_extras_test.dart new file mode 100644 index 0000000..67776c2 --- /dev/null +++ b/Fly App/test/fly/p6_extras_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/dji_service.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/gimbal_control.dart'; +import 'package:pilotvault_fly/fly/hud_features.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/settings/settings_spec.dart'; +import 'package:pilotvault_fly/fly/settings/settings_tabs.dart'; +import 'package:pilotvault_fly/fly/state/aircraft_state.dart'; +import 'package:pilotvault_fly/fly/state/fly_zone_state.dart'; +import 'package:pilotvault_fly/fly/state/key_state.dart'; +import 'package:pilotvault_fly/fly/state/link_extras.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/fly/units.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/link_rows.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +FlightModel _connected(Map profile) => FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile); + +void main() { + group('Status bar and safety extras (P2 / P3)', () { + test('RTK and compass interference ride in the telemetry tick', () { + final FlightModel m = _connected(phantom4); + TelemetryReducer(m).apply(telemetryTick() + ..['rtk'] = {'solution': 'FIXED_POINT', 'inUse': true, 'satellites': 24} + ..['compassSensors'] = [ + {'index': 0, 'state': 'NORMAL_MODULUS', 'value': 1500}, + {'index': 1, 'state': 'SERIOUS_MODULUS', 'value': 2600}, + ]); + expect(m.aircraft.rtk!.label, 'FIX'); + expect(m.aircraft.rtk!.fixed, isTrue); + expect(m.aircraft.compassSensors.map((CompassSensor c) => c.level), [0, 2]); + TelemetryReducer(m).apply(telemetryTick()); + expect(m.aircraft.rtk, isNull, reason: 'no module, no chip'); + }); + + test('Precision Takeoff follows the flight controller once it answered', () { + ProductProfile with_(bool? supported) => ProductProfile.fromBridge({ + ...phantom4, + 'flightController': {...(phantom4['flightController'] as Map), 'precisionTakeOffSupported': supported}, + }); + expect(HudFeatures.precisionTakeoff.enabledFor(with_(null)), ProductProfile.fromBridge(phantom4).hasVps); + expect(HudFeatures.precisionTakeoff.enabledFor(with_(false)), isFalse); + expect(HudFeatures.precisionTakeoff.enabledFor(with_(true)), isTrue); + }); + }); + + group('Gimbal presets (P5)', () { + test('Fast / Medium / Slow write fractions of each range and are recognised back', () { + const Map ranges = { + 'pitchMaxSpeed': AxisRange(0, 100), + 'pitchSmoothing': AxisRange(0, 30), + }; + expect(GimbalPreset.fast.valuesFor(ranges), {'pitchMaxSpeed': 80, 'pitchSmoothing': 6}); + final Map slow = GimbalPreset.slow.valuesFor(ranges); + expect(GimbalPreset.matching(ranges, (String id) => slow[id]), GimbalPreset.slow); + expect(GimbalPreset.matching(ranges, (String id) => id == 'pitchMaxSpeed' ? 50 : 1), isNull, reason: 'a custom mix'); + }); + }); + + group('Settings extras (P6)', () { + test('units', () { + expect(Units.length(100, UnitSystem.imperial), '328 ft'); + expect(Units.speed(10, UnitSystem.imperial), '22.4 mph'); + expect(Units.distance(1500, UnitSystem.metric), '1.50 km'); + expect(Units.distance(2000, UnitSystem.imperial), '1.24 mi'); + }); + + test('spectrum: OcuSync bands and Lightbridge channels, the quietest marked', () { + final Spectrum os = Spectrum.fromEvent({ + 'link': 'ocusync', + 'bands': [ + {'from': 2400.0, 'to': 2410.0, 'rssi': -90}, + {'from': 2410.0, 'to': 2420.0, 'rssi': -100}, + ], + }); + expect(os.bars.map((SpectrumBar b) => b.label), ['2405', '2415']); + expect(os.quietest!.label, '2415'); + final Spectrum lb = Spectrum.fromEvent({ + 'link': 'lightbridge', + 'channels': [{'channel': 13, 'power': -70}], + }); + expect(lb.bars.single.label, '13'); + }); + + test('battery warning records read as what went wrong', () { + expect(formatWarningRecord({'error': false}), 'None'); + expect(formatWarningRecord({'overHeated': true, 'lowVoltageCell': 3}), 'overheated, low cell 3'); + expect( + formatWarningHistory([ + {'error': false}, + {'underVoltage': true}, + ]), + '1 of 2: under-voltage', + ); + }); + + test('text rows: secrets never show; master / slave rows follow the role', () { + const KeyTextSpec pw = KeyTextSpec(id: 'pw', label: 'Password', key: DjiKey.wifi('PASSWORD'), secret: true); + final KeyState k = KeyState()..apply(pw.key.id, 'hunter22'); + expect(pw.shown(k), '••••••'); + final ProductProfile p = ProductProfile.fromBridge(inspire2X5s); + final SettingsTab rc = SettingsTabs.all(p).firstWhere((SettingsTab t) => t.id == SettingsTabs.rc); + final SettingSpec password = rc.rows.firstWhere((SettingSpec r) => r.id == 'rcLinkPassword'); + final KeyState s = KeyState(); + final FlightModel m = _connected(inspire2X5s); + SettingsContext ctx() => SettingsContext(profile: p, keys: s, aircraft: m.aircraft); + expect(password.visible(ctx()), isFalse, reason: 'single-operator remote'); + s.apply(SettingsKeys.rcRole.id, 'MASTER'); + expect(password.visible(ctx()), p.hasMasterSlaveRc); + }); + + test('a C1 press reaches the model as a new value each time', () { + final FlightModel m = _connected(phantom4); + final TelemetryReducer r = TelemetryReducer(m); + r.apply({'type': 'rcButton', 'button': 'C1'}); + final RcButtonPress first = m.rcButton.value!; + r.apply({'type': 'rcButton', 'button': 'C1'}); + expect(m.rcButton.value!.button, RcButton.c1); + expect(m.rcButton.value, isNot(first)); + }); + + testWidgets('C1 / C2 choices are saved on the phone', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + await FlyPrefs.load(); + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CustomButtonRows())))); + await tester.tap(find.byKey(const ValueKey('row-rcC1'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-mapSwap'))); + await tester.pump(); + expect(FlyPrefs.c1.value, RcButtonAction.mapSwap); + }); + }); + + group('Calibration extras (P6b)', () { + testWidgets('the interference meter grades every compass', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: CompassInterferenceMeter(sensors: [CompassSensor(0, 'NORMAL_MODULUS', 1500), CompassSensor(1, 'WEAK_MODULUS', 2100)]), + ), + )); + expect(find.text('Low'), findsOneWidget); + expect(find.text('Moderate'), findsOneWidget); + expect(find.text('Compass 2'), findsOneWidget); + }); + }); + + group('GEO database (P8)', () { + test('a newer precise database on the server marks it out of date, across re-reads', () { + final FlyZoneState z = FlyZoneState(); + z.applyDatabase({'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'}); + expect(z.database!.outOfDate, isFalse); + z.applyRemoteVersion('1.1'); + expect(z.database!.preciseBehind, isTrue); + z.applyDatabase({'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'}); + expect(z.database!.outOfDate, isTrue, reason: 'the remote version is kept'); + }); + }); +} diff --git a/Fly App/test/fly/p7_modes_test.dart b/Fly App/test/fly/p7_modes_test.dart new file mode 100644 index 0000000..ac19880 --- /dev/null +++ b/Fly App/test/fly/p7_modes_test.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart' show LatLng; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/action_rail_state.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart'; +import 'package:pilotvault_fly/fly/modes/orbit_modes.dart'; +import 'package:pilotvault_fly/fly/modes/terrain.dart'; +import 'package:pilotvault_fly/fly/modes/vision_modes.dart'; +import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/safety_gate.dart'; +import 'package:pilotvault_fly/fly/state/mission_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; +import 'package:pilotvault_fly/ui/fly/flight_command_flow.dart'; +import 'package:pilotvault_fly/ui/fly/modes/mode_panels.dart'; +import 'package:pilotvault_fly/ui/fly/modes/mode_picker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +FlightModel _model(Map tick, {Map? profile}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + TelemetryReducer(m).apply(tick); + return m; +} + +Map _grounded() => telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false; + +List _blockers(List c) => [for (final GateCheck g in c) if (g.blocks) g.message]; + +class _Ground implements ElevationSource { + _Ground(this.at); + + /// Ground elevation for a point. + final double Function(LatLng p) at; + + @override + Future> elevations(List points) async => [for (final LatLng p in points) at(p)]; +} + +void main() { + group('Point of Interest', () { + test('spec: °/s from the tangential speed, the speed capped at the aircraft\'s limit', () { + const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568), radius: 30, speed: 3); + expect(p.angularVelocity, closeTo(5.73, 0.01)); + expect(PoiSpec.speedFor(p.angularVelocity, 30), closeTo(3, 1e-9)); + expect(p.toBridge(maxAngular: 4)['angularVelocity'], 4); + expect(p.issues(maxAngular: 4).where(((bool, String) i) => !i.$1).map(((bool, String) i) => i.$2), contains(contains('at most'))); + expect(const PoiSpec().issues().first.$1, isTrue, reason: 'no centre blocks'); + expect(p.lapSeconds, 63); + }); + + test('gate: flying in P mode with GPS; refused on the ground or during another mode', () { + const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568)); + final FlightModel air = _model(telemetryTick()); + expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), isEmpty); + expect(_blockers(SafetyGate(model: _model(_grounded()), dji: FakeDjiService()).checkPoi(p)), contains('Take off before starting Point of Interest.')); + air.mission.apply({'type': 'mission', 'operator': 'tapFly', 'state': 'EXECUTING'}); + expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), contains('Stop the intelligent flight mode that is running first.')); + }); + + test('start, adjust and Stop go to the hotpoint operator', () async { + final FlightModel m = _model(telemetryTick()); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: m, dji: dji); + await g.startPoi(const PoiSpec(center: LatLng(55.676, 12.568), radius: 40, speed: 4, clockwise: false, heading: PoiHeading.forward)); + expect(dji.hotpoint!['radius'], 40); + expect(dji.hotpoint!['clockwise'], isFalse); + expect(dji.hotpoint!['heading'], 'ALONG_CIRCLE_LOOKING_FORWARDS'); + + m.mission.apply({'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING', 'radius': 40.0}); + expect(m.mission.state, 'hotpoint'); + expect(RailState.activeMode(m.aircraft, m.mission), ActiveMode.hotpoint); + await g.adjustPoi(speed: 2, clockwise: false, currentRadius: 40); + expect(dji.calls.last, 'setHotpointAngularVelocity(-2.9)', reason: 'anticlockwise is negative'); + await g.run(FlightCommand.stopMode); + expect(dji.calls.last, 'stopHotpoint'); + }); + + test('a finish with an error is reported once', () { + final MissionState ms = MissionState(); + ms.apply({'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING'}); + ms.apply({'type': 'mission', 'operator': 'hotpoint', 'state': 'READY_TO_EXECUTE', 'event': 'finish', 'error': 'GPS lost'}); + expect(ms.modeResult, 'GPS lost'); + expect(ms.modeFinishes, 1); + expect(ms.hotpoint.active, isFalse); + }); + }); + + group('Follow Me', () { + test('needs the phone\'s fix; starts at the set height and streams the phone', () async { + final FlightModel m = _model(telemetryTick()); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: m, dji: dji); + expect(_blockers(g.checkFollowMe(const FollowMeSpec())), contains(contains('no location fix'))); + m + ..phoneLatitude = 55.6762 + ..phoneLongitude = 12.5684; + expect(_blockers(g.checkFollowMe(const FollowMeSpec())), isEmpty); + await g.startFollowMe(const FollowMeSpec(altitude: 25, heading: FollowMeHeading.remote)); + expect(dji.calls.last, 'startFollowMe(25, CONTROLLED_BY_REMOTE_CONTROLLER)'); + await g.updateFollowMe(); + expect(dji.calls.last, isNot('updateFollowMeTarget'), reason: 'not following yet'); + m.mission.apply({'type': 'mission', 'operator': 'followMe', 'state': 'EXECUTING', 'distance': 12.0}); + await g.updateFollowMe(); + expect(dji.calls.last, 'updateFollowMeTarget'); + expect(m.mission.followMe.distance, 12); + await g.run(FlightCommand.stopMode); + expect(dji.calls.last, 'stopFollowMe'); + }); + }); + + group('Orientation, Tripod, Hyperlapse', () { + test('Home Lock needs a home point 10 m away; Course Lock locks the heading', () async { + final FlightModel near = _model(telemetryTick()..['homeDistance'] = 4.0); + final FakeDjiService dji = FakeDjiService(); + expect(_blockers(SafetyGate(model: near, dji: dji).checkOrientation(OrientationMode.homeLock)), contains(contains('10 m'))); + final FlightModel m = _model(telemetryTick()); + final SafetyGate g = SafetyGate(model: m, dji: dji); + await g.setOrientation(OrientationMode.courseLock); + expect(dji.calls.last, 'setFlightOrientationMode(COURSE_LOCK)'); + await expectLater(g.lockCourse(), throwsA(isA()), reason: 'the FC still reports normal heading'); + TelemetryReducer(m).apply(telemetryTick()..['orientationMode'] = 'COURSE_LOCK'); + await g.lockCourse(); + expect(dji.calls.last, 'lockCourseUsingCurrentHeading'); + expect(OrientationMode.fromSdk('HOME_LOCK'), OrientationMode.homeLock); + }); + + test('Tripod and Cinematic are key writes behind the gate', () async { + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji); + await g.setSlowMode(cinematic: false, on: true); + expect(dji.calls.last, 'setKey(TRIPOD_MODE_ENABLED, true)'); + await g.setSlowMode(cinematic: true, on: false); + expect(dji.calls.last, 'setKey(CINEMATIC_MODE_ENABLED, false)'); + }); + + test('Free hyperlapse: frames from the video length, then the camera\'s interval mode', () async { + const HyperlapseSpec h = HyperlapseSpec(intervalSeconds: 3, videoSeconds: 10); + expect(h.frames, 250); + expect(h.shootingSeconds, 750); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji); + await g.startHyperlapse(h); + expect(dji.calls, ['setCameraMode(photo)', 'setShootPhotoMode(INTERVAL)', 'setPhotoTimeInterval(250, 3)', 'startShootPhoto']); + // Over 254 frames the camera shoots "until stopped" and the gate stops it. + const HyperlapseSpec long = HyperlapseSpec(intervalSeconds: 2, videoSeconds: 20); + expect(long.cameraCount, 255); + dji.calls.clear(); + await g.startHyperlapse(long); + expect(dji.calls, contains('setPhotoTimeInterval(255, 2)')); + await g.stopHyperlapse(); + expect(dji.calls.last, 'stopShootPhoto'); + }); + }); + + group('QuickShots and ActiveTrack extras', () { + test('shots offered per product; the gate flies one on a boxed subject', () async { + expect(QuickShot.offered(omni: false, zoom: false), hasLength(4)); + expect(QuickShot.offered(omni: true, zoom: true), contains(QuickShot.dollyZoom)); + expect(TrackMode.offered(omni: false), isNot(contains(TrackMode.spotlightPro))); + expect(TrackMode.offered(omni: true), containsAll([TrackMode.spotlightPro, TrackMode.circle])); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji); + expect(g.checkQuickShot(QuickShot.rocket).where((GateCheck c) => !c.blocks), isNotEmpty, reason: 'it always warns about the path'); + await g.startQuickShot(const Rect.fromLTWH(0.4, 0.3, 0.2, 0.3), QuickShot.helix); + expect(dji.calls.last, 'startQuickShot(HELIX)'); + await g.setTrackCircleSpeed(-4); + expect(dji.calls.last, 'setActiveTrackCircularSpeed(-4)'); + }); + + test('QuickShot progress comes from the ActiveTrack operator', () { + final MissionState ms = MissionState(); + ms.apply({ + 'type': 'mission', + 'operator': 'activeTrack', + 'state': 'PERFORMING_QUICK_SHOT', + 'tracking': {'percent': 40}, + }); + expect(ms.state, 'quickshot'); + expect(ms.activeTrack.percent, 40); + }); + }); + + group('Waypoints extras', () { + const WaypointMissionSpec route = WaypointMissionSpec(points: [ + WaypointSpec(at: LatLng(55.6760, 12.5680)), + WaypointSpec(at: LatLng(55.6770, 12.5680)), + WaypointSpec(at: LatLng(55.6780, 12.5680)), + WaypointSpec(at: LatLng(55.6790, 12.5680)), + ]); + + test('resume: an interruption on this route resumes from its waypoint', () async { + expect(route.from(2).points, hasLength(2)); + expect(route.matchesInterruption(2, const LatLng(55.6775, 12.5680)), isTrue); + expect(route.matchesInterruption(2, const LatLng(55.70, 12.60)), isFalse); + final FakeDjiService dji = FakeDjiService()..interruption = {'index': 2, 'lat': 55.6775, 'lon': 12.5680}; + final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji); + expect(await g.interruptionFor(route), 2); + dji.interruption = {'index': 3, 'lat': 55.6785, 'lon': 12.5680}; + expect(await g.interruptionFor(route), isNull, reason: 'one waypoint left is not a route'); + }); + + test('cruise speed in flight only while the mission flies', () async { + final FlightModel m = _model(telemetryTick()); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: m, dji: dji); + await expectLater(g.setWaypointCruiseSpeed(6), throwsA(isA())); + m.mission.apply({'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'}); + await g.setWaypointCruiseSpeed(20); + expect(dji.calls.last, 'setWaypointAutoFlightSpeed(15)'); + }); + + test('a V2-only aircraft uploads to Waypoints 2.0 and is warned about actions', () async { + final Map m300 = {...mavic2Zoom, 'model': 'MATRICE_300_RTK'}; + final FlightModel m = _model(telemetryTick(), profile: m300); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: m, dji: dji); + expect(g.usesWaypointsV2, isTrue); + final WaypointMissionSpec shooting = route.copyWith(points: [ + for (final WaypointSpec p in route.points) p.copyWith(action: WaypointCameraAction.photo), + ]); + expect(g.checkWaypointStart(shooting).map((GateCheck c) => c.message), contains(contains('Waypoints 2.0'))); + await g.startWaypointMission(route); + expect(dji.uploadedV2, isTrue); + }); + + test('terrain: a hill between waypoints is found, flat ground is not', () async { + const TerrainCheck flat = TerrainCheck(_FlatGround()); + expect(await flat.run(route, route.points.first.at), isEmpty); + // A 60 m hill around the second leg; the route flies 50 m above take-off. + final TerrainCheck hill = TerrainCheck(_Ground((LatLng p) => p.latitude > 55.6772 && p.latitude < 55.6778 ? 60 : 0)); + final List found = await hill.run(route, route.points.first.at); + expect(found.single.leg, 1); + expect(found.single.clearance, lessThan(0)); + expect(found.single.toIssue().blocks, isTrue); + expect(hill.samples(route).length, lessThanOrEqualTo(100)); + }); + }); + + group('Catalogue and tutorials', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlyPrefs.seenTutorials.value = {}; + }); + + test('every mode is flyable and has a tutorial', () { + for (final ModeInfo i in ModeCatalog.all) { + expect(i.built, isTrue, reason: i.label); + expect(i.tutorial, isNotEmpty, reason: i.label); + } + }); + + testWidgets('a tutorial shows once, page by page', (WidgetTester tester) async { + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (BuildContext c) { + ctx = c; + return const SizedBox(); + }))); + final ModeInfo poi = ModeCatalog.of(IntelligentMode.pointOfInterest); + final Future shown = ensureModeTutorial(ctx, poi); + await tester.pumpAndSettle(); + expect(find.text(poi.tutorial.first), findsOneWidget); + for (int i = 0; i < poi.tutorial.length; i++) { + await tester.tap(find.byKey(const ValueKey('tutorial-next'))); + await tester.pumpAndSettle(); + } + await shown; + expect(FlyPrefs.seenTutorials.value, contains('pointOfInterest')); + ensureModeTutorial(ctx, poi); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('tutorial-step')), findsNothing, reason: 'seen once'); + }); + }); + + group('Mode panels', () { + Future<(FlightModel, FakeDjiService)> pump(WidgetTester tester, Widget Function(FlightModel m, SafetyGate g, FlightCommandFlow f) build) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FlightModel m = _model(telemetryTick()); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: m, dji: dji); + final FlightCommandFlow f = FlightCommandFlow(gate: g, onError: (_, __) {}, reviewBeforeTakeOff: (_) async => true); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: Align(alignment: Alignment.topLeft, child: build(m, g, f))))); + return (m, dji); + } + + testWidgets('POI: centre on the aircraft, GO slides to start', (WidgetTester tester) async { + final ModeSetup setup = ModeSetup(); + final (FlightModel m, FakeDjiService dji) = await pump( + tester, + (FlightModel m, SafetyGate g, FlightCommandFlow f) => + PoiPanel(model: m, setup: setup, gate: g, flow: f, dji: FakeDjiService(), onExit: () {}, onError: (_, __) {}), + ); + expect(find.text('TAP THE MAP ON THE SUBJECT'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('poi-aircraft'))); + await tester.pump(); + expect(setup.poi.center, LatLng(m.aircraft.latitude!, m.aircraft.longitude!)); + await tester.tap(find.byKey(const ValueKey('poi-radius-plus'))); + await tester.pump(); + expect(setup.poi.radius, 35); + await tester.tap(find.byKey(const ValueKey('mode-poi-go'))); + await tester.pumpAndSettle(); + expect(find.byType(SlideConfirmDialog), findsOneWidget); + expect(dji.calls, isEmpty); + }); + + testWidgets('Hyperlapse: picking the interval updates the frame count', (WidgetTester tester) async { + final ModeSetup setup = ModeSetup(); + await pump(tester, (FlightModel m, SafetyGate g, FlightCommandFlow f) => HyperlapsePanel(model: m, setup: setup, gate: g, onExit: () {}, onError: (_, __) {})); + await tester.tap(find.byKey(const ValueKey('hl-length-20'))); + await tester.pump(); + expect(find.textContaining('500 FRAMES'), findsOneWidget); + }); + }); +} + +class _FlatGround implements ElevationSource { + const _FlatGround(); + + @override + Future> elevations(List points) async => List.filled(points.length, 12); +} diff --git a/Fly App/test/fly/safety_test.dart b/Fly App/test/fly/safety_test.dart index b5e0286..efb6ff8 100644 --- a/Fly App/test/fly/safety_test.dart +++ b/Fly App/test/fly/safety_test.dart @@ -71,7 +71,9 @@ void main() { expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint); expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint); expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.tapFly); - expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.other); + expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.hotpoint); + expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_FOLLOW_ME').mode, ActiveMode.followMe); + expect(rail(telemetryTick()..['flightModeEnum'] = 'DRAW').mode, ActiveMode.other); expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse); expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground'); }); @@ -146,7 +148,8 @@ void main() { expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty); expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty); expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty); - expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode).single, contains('pause button')); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button')); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode), isEmpty, reason: 'POI stops from the app now'); expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode), isEmpty); }); diff --git a/Fly App/test/fly/settings_test.dart b/Fly App/test/fly/settings_test.dart index e8737c9..ce84395 100644 --- a/Fly App/test/fly/settings_test.dart +++ b/Fly App/test/fly/settings_test.dart @@ -112,8 +112,16 @@ void main() { for (final SettingSpec r in SettingsTabs.all(ProductProfile.fromBridge(p)).firstWhere((SettingsTab t) => t.id == 'battery').rows) if (r is GroupSpec) r.label, ]; - expect(groups(phantom4), ['WARNINGS', 'BATTERY']); - expect(groups(inspire2X5s), ['WARNINGS', 'BATTERY 1', 'BATTERY 2']); + // The pair and cell-voltage groups are always listed and gated per profile. + expect(groups(phantom4), ['WARNINGS', 'BATTERY', 'BATTERY PAIR', 'CELL VOLTAGE']); + expect(groups(inspire2X5s), ['WARNINGS', 'BATTERY 1', 'BATTERY 2', 'BATTERY PAIR', 'CELL VOLTAGE']); + final SettingsTab p4 = SettingsTabs.all(lb).firstWhere((SettingsTab t) => t.id == 'battery'); + final SettingsTab i2 = SettingsTabs.all(ProductProfile.fromBridge(inspire2X5s)).firstWhere((SettingsTab t) => t.id == 'battery'); + final SettingsContext i2c = SettingsContext( + profile: ProductProfile.fromBridge(inspire2X5s), keys: KeyState(), aircraft: connectedModel(inspire2X5s).aircraft); + expect(p4.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(lbc), isFalse, reason: 'one pack, nothing to pair'); + expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(i2c), isTrue); + expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-CELL VOLTAGE').visible(i2c), isFalse, reason: 'smart batteries'); final KeyReadoutSpec cells = spec(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s)); expect(cells.key.index, 1); }); @@ -287,6 +295,9 @@ void main() { await tester.tap(find.byKey(const ValueKey('opt-MANUAL'))); await tester.pump(); expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)')); + // The spectrum chart above pushes the channel row down the lazy list. + await tester.scrollUntilVisible(find.byKey(const ValueKey('row-osChannel')), 80, + scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-transmission')), matching: find.byType(Scrollable))); expect(find.byKey(const ValueKey('row-osChannel')), findsOneWidget); await tester.pump(cameraSettle); }); diff --git a/Fly App/test/fly/vision_modes_test.dart b/Fly App/test/fly/vision_modes_test.dart index 5f9477a..21601ad 100644 --- a/Fly App/test/fly/vision_modes_test.dart +++ b/Fly App/test/fly/vision_modes_test.dart @@ -288,6 +288,8 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); FlyPrefs.acceptedModeTerms.value = {'activeTrack', 'tapFly'}; + // The first-use tutorials have their own test (p7_modes_test.dart). + FlyPrefs.seenTutorials.value = {for (final IntelligentMode m in IntelligentMode.values) m.name}; }); Future pump(WidgetTester tester, FlightModel m) async { diff --git a/Fly App/test/fly/waypoint_test.dart b/Fly App/test/fly/waypoint_test.dart index 55f2e1a..264f154 100644 --- a/Fly App/test/fly/waypoint_test.dart +++ b/Fly App/test/fly/waypoint_test.dart @@ -682,6 +682,8 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); FlyPrefs.acceptedModeTerms.value = {}; + // The first-use tutorials have their own test (p7_modes_test.dart). + FlyPrefs.seenTutorials.value = {for (final IntelligentMode m in IntelligentMode.values) m.name}; }); Future pump(WidgetTester tester, FlightModel m, {WaypointEditor? editor}) async {