Give the Fly App every remaining GO 4 flight mode and the missing settings

Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
1 parent 91ca150a35
commit 636da1ac2c
57 files changed
+5363 -87

No files matched your search

@@ -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)) }
@@ -115,6 +115,10 @@ class DjiSdkBridge(
AirSenseBridge(ctx),
FlyZoneBridge(ctx),
LiveStreamBridge(ctx),
RemoteControllerBridge(ctx),
VideoCacheBridge(ctx),
HotpointBridge(ctx),
FollowMeBridge(ctx),
)
init {
@@ -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<Boolean>("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<String>("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<String, Any?> {
@@ -132,6 +132,7 @@ class FlyZoneBridge(private val ctx: BridgeCtx) : SubBridge {
"getFlyZoneDatabase" -> {
val m = manager() ?: return unavailable(result)
val out = LinkedHashMap<String, Any?>()
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<String> {
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<String, Any?>? {
if (n == null) return null
val base = mapOf("type" to "flySafe", "kind" to n.type?.name)
@@ -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<String, Any?>("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<String>("heading") ?: "TOWARD_FOLLOW_POSITION")
} catch (_: IllegalArgumentException) {
FollowMeHeading.TOWARD_FOLLOW_POSITION
}
val mission = FollowMeMission(
heading,
call.argument<Double>("lat") ?: 0.0,
call.argument<Double>("lon") ?: 0.0,
(call.argument<Number>("altitude") ?: 10).toFloat(),
)
o.startMission(mission, ctx.completion(result))
}
"updateFollowMeTarget" -> withOp(result) {
val lat = call.argument<Double>("lat")
val lon = call.argument<Double>("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)
}
}
@@ -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<String, Any?>("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<Double>("lat") ?: 0.0, call.argument<Double>("lon") ?: 0.0),
(call.argument<Number>("altitude") ?: 30).toDouble(),
(call.argument<Number>("radius") ?: 20).toDouble(),
(call.argument<Number>("angularVelocity") ?: 10).toFloat(),
call.argument<Boolean>("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<Number>("radius") ?: 20).toFloat(), ctx.completion(result))
}
"setHotpointAngularVelocity" -> withOp(result) {
it.setAngularVelocity((call.argument<Number>("value") ?: 10).toFloat(), ctx.completion(result))
}
"resetHotpointHeading" -> withOp(result) { it.resetHeading(ctx.completion(result)) }
"getHotpointMaxAngularVelocity" -> HotpointMissionOperator.getMaxAngularVelocityForRadius(
(call.argument<Number>("radius") ?: 20).toDouble(),
object : CommonCallbacks.CompletionCallbackWith<Float> {
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 <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
val raw = call.argument<String>(name) ?: return fallback
return try {
java.lang.Enum.valueOf(T::class.java, raw)
} catch (_: IllegalArgumentException) {
fallback
}
}
}
@@ -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()
}
@@ -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<Boolean>("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<Number>("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<WaypointMissionInterruption> {
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<Number>("speed") ?: 5).toFloat(), ctx.completion(result))
}
"getActiveTrackCircularSpeed" -> track(result) { op ->
op.getCircularSpeed(object : CommonCallbacks.CompletionCallbackWith<Float> {
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<String>("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<Number>("x") ?: 0.4).toFloat()
val y = (call.argument<Number>("y") ?: 0.35).toFloat()
val w = (call.argument<Number>("w") ?: 0.2).toFloat()
val h = (call.argument<Number>("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 }
}
@@ -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 ->
@@ -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<String, Boolean>()
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<Boolean>("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<Information>) {
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<Array<Information>> {
override fun onSuccess(list: Array<Information>?) {
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<Int>("id") ?: 0,
call.argument<String>("name") ?: "",
call.argument<String>("password") ?: "",
)
r.connectToMaster(creds, object : CommonCallbacks.CompletionCallbackWith<ConnectToMasterResult> {
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<Boolean>("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<String>("ssid")
val password = call.argument<String>("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<String, Any?> =
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<FrequencyInterference>? ->
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<ChannelInterference>? ->
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<WifiChannelInterference>? ->
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) {
}
}
}
@@ -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/<yyyyMMdd_HHmmss>.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<Boolean>("on") == true
call.argument<Int>("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()
}
}
}
@@ -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<String, Any?>? = null,
execution: Map<String, Any?>? = null,
event: String? = null,
error: DJIWaypointV2Error? = null,
) {
val m = linkedMapOf<String, Any?>("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<DJIWaypointV2Error> { 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<List<Map<String, Any?>>>("waypoints") ?: emptyList()
if (raw.size < 2) {
ctx.fail(result, "A route needs at least 2 waypoints")
return
}
val curved = call.argument<String>("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<String>("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<Number>("autoFlightSpeed") ?: 8).toFloat())
.setMaxFlightSpeed((call.argument<Number>("maxFlightSpeed") ?: 15).toFloat())
.setFinishedAction(finished)
.setGotoFirstWaypointMode(
if (call.argument<String>("gotoFirstWaypointMode") == "POINT_TO_POINT") WaypointV2MissionTypes.MissionGotoWaypointMode.POINT_TO_POINT
else WaypointV2MissionTypes.MissionGotoWaypointMode.SAFELY,
)
.setExitMissionOnRCSignalLostEnabled(call.argument<Boolean>("exitMissionOnRCSignalLost") ?: true)
.setRepeatTimes(call.argument<Number>("repeatTimes")?.toInt() ?: 1)
.addwaypoints(points)
call.argument<Map<String, Any?>>("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")
}
+126 -8
View File
@@ -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<void> uploadWaypointMission(Map<String, Object?> mission) =>
_methods.invokeMethod<void>('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<void> uploadWaypointMission(Map<String, Object?> mission, {bool v2 = false}) =>
_methods.invokeMethod<void>('uploadWaypointMission', <String, Object?>{...mission, if (v2) 'v2': true});
Future<void> cancelWaypointUpload() => _methods.invokeMethod<void>('cancelWaypointUpload');
/// The operator's `WaypointMissionState` name; also re-published as an event.
@@ -405,11 +409,125 @@ class DjiService {
Future<void> startFlyZoneDatabaseUpgrade() => _methods.invokeMethod<void>('startFlyZoneDatabaseUpgrade');
Future<void> stopFlyZoneDatabaseUpgrade() => _methods.invokeMethod<void>('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<void> startQuickShot(String name) =>
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{'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<void> startQuickShot(String name, {double? x, double? y, double? w, double? h}) =>
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{
'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<void> setActiveTrackCircularSpeed(double speed) =>
_methods.invokeMethod<void>('setActiveTrackCircularSpeed', <String, dynamic>{'speed': speed});
Future<double?> getActiveTrackCircularSpeed() => _methods.invokeMethod<double>('getActiveTrackCircularSpeed');
/// Where the last waypoint mission was cut short: `{missionId, index, lat,
/// lon, altitude}`, or null when it was not.
Future<Map<String, dynamic>?> getWaypointInterruption() async {
final dynamic m = await _methods.invokeMethod('getWaypointInterruption');
return m == null ? null : Map<String, dynamic>.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<void> 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<void>('startHotpoint', <String, dynamic>{
'lat': lat,
'lon': lon,
'altitude': altitude,
'radius': radius,
'angularVelocity': angularVelocity,
'clockwise': clockwise,
'startPoint': startPoint,
'heading': heading,
});
Future<void> pauseHotpoint() => _methods.invokeMethod<void>('pauseHotpoint');
Future<void> resumeHotpoint() => _methods.invokeMethod<void>('resumeHotpoint');
Future<void> stopHotpoint() => _methods.invokeMethod<void>('stopHotpoint');
Future<void> setHotpointRadius(double radius) =>
_methods.invokeMethod<void>('setHotpointRadius', <String, dynamic>{'radius': radius});
/// °/s; the sign is the direction (positive clockwise).
Future<void> setHotpointAngularVelocity(double value) =>
_methods.invokeMethod<void>('setHotpointAngularVelocity', <String, dynamic>{'value': value});
Future<void> resetHotpointHeading() => _methods.invokeMethod<void>('resetHotpointHeading');
/// The fastest the aircraft circles at [radius] m, °/s.
Future<double?> getHotpointMaxAngularVelocity(double radius) =>
_methods.invokeMethod<double>('getHotpointMaxAngularVelocity', <String, dynamic>{'radius': radius});
// ── Follow Me (plan P7, `FollowMeBridge.kt`) ────────────────────────────────
/// heading: TOWARD_FOLLOW_POSITION | CONTROLLED_BY_REMOTE_CONTROLLER.
Future<void> startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) =>
_methods.invokeMethod<void>('startFollowMe', <String, dynamic>{'lat': lat, 'lon': lon, 'altitude': altitude, 'heading': heading});
Future<void> updateFollowMeTarget(double lat, double lon) =>
_methods.invokeMethod<void>('updateFollowMeTarget', <String, dynamic>{'lat': lat, 'lon': lon});
Future<void> stopFollowMe() => _methods.invokeMethod<void>('stopFollowMe');
// ── Orientation modes (plan P7) ─────────────────────────────────────────────
/// mode: AIRCRAFT_HEADING | COURSE_LOCK | HOME_LOCK.
Future<void> setFlightOrientationMode(String mode) =>
_methods.invokeMethod<void>('setFlightOrientationMode', <String, dynamic>{'mode': mode});
/// Course Lock's "forward" becomes the aircraft's current heading.
Future<void> lockCourseUsingCurrentHeading() => _methods.invokeMethod<void>('lockCourseUsingCurrentHeading');
// ── Remote controller extras (plan P6, `RemoteControllerBridge.kt`) ─────────
/// Looks for master remotes in range; they arrive as `rcMasters` events.
Future<void> searchMasters(bool on) => _methods.invokeMethod<void>('searchMasters', <String, dynamic>{'on': on});
/// Joins a master remote; answers `ConnectToMasterResult` (ACCEPTED, PASSWORD_ERROR, …).
Future<String?> connectToMaster({required int id, required String name, required String password}) =>
_methods.invokeMethod<String>('connectToMaster', <String, dynamic>{'id': id, 'name': name, 'password': password});
/// The slaves linked to this (master) remote: `[{id, name, signal}]`.
Future<List<Map<String, dynamic>>> getSlaves() async {
final List<Object?>? l = await _methods.invokeMethod<List<Object?>>('getSlaves');
return <Map<String, dynamic>>[for (final Object? s in l ?? const <Object?>[]) if (s is Map) Map<String, dynamic>.from(s)];
}
/// The video link's interference while on, as `spectrum` events.
Future<void> watchSpectrum(bool on) => _methods.invokeMethod<void>('watchSpectrum', <String, dynamic>{'on': on});
/// The Wi-Fi link's network name and/or password; [rebootWifi] applies them.
Future<void> setWifiCredentials({String? ssid, String? password}) => _methods.invokeMethod<void>('setWifiCredentials', <String, dynamic>{
if (ssid != null) 'ssid': ssid,
if (password != null) 'password': password,
});
Future<void> rebootWifi() => _methods.invokeMethod<void>('rebootWifi');
// ── Video cache (plan P6, `VideoCacheBridge.kt`) ────────────────────────────
Future<void> setVideoCacheRecording(bool on, {int? limitMb}) =>
_methods.invokeMethod<void>('setVideoCacheRecording', <String, dynamic>{'on': on, if (limitMb != null) 'limitMb': limitMb});
/// `{bytes, files, recording}`.
Future<Map<String, dynamic>> getVideoCacheInfo() async {
final dynamic m = await _methods.invokeMethod('getVideoCacheInfo');
return Map<String, dynamic>.from(m as Map);
}
Future<void> clearVideoCache() => _methods.invokeMethod<void>('clearVideoCache');
/// Records the lens's current focus as infinity (focus on a far subject first).
Future<void> calibrateInfinityFocus() => _methods.invokeMethod<void>('calibrateInfinityFocus');
// ── Media (MediaManager) ────────────────────────────────────────────────────
Future<void> refreshMediaList() => _methods.invokeMethod<void>('refreshMediaList');
+13
View File
@@ -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<Size?> videoSize = ValueNotifier<Size?>(null);
/// The remote's latest C1 / C2 press (plan P6); the flight screen acts on it.
final ValueNotifier<RcButtonPress?> rcButton = ValueNotifier<RcButtonPress?>(null);
/// Master remotes in range while the dual-operator search runs.
final ValueNotifier<List<RcPeer>> rcMasters = ValueNotifier<List<RcPeer>>(const <RcPeer>[]);
/// The video link's interference while the spectrum chart watches it.
final ValueNotifier<Spectrum?> spectrum = ValueNotifier<Spectrum?>(null);
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
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();
}
}
+8 -2
View File
@@ -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<String> _waypointModes = <String>{'GPS_WAYPOINT'};
static const Set<String> _trackModes = <String>{'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'};
static const Set<String> _otherModes = <String>{'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'DRAW', 'TERRAIN_FOLLOW'};
static const Set<String> _otherModes = <String>{'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;
}
+73
View File
@@ -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<bool> liveStreamAudio = ValueNotifier<bool>(false);
/// Metric or imperial readouts on the HUD and in settings (plan P6).
static final ValueNotifier<UnitSystem> units = ValueNotifier<UnitSystem>(UnitSystem.metric);
/// What the remote's C1 / C2 buttons do (plan P6); GO 4's defaults.
static final ValueNotifier<RcButtonAction> c1 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalRecenter);
static final ValueNotifier<RcButtonAction> c2 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalToggleDown);
/// Record the live feed to the phone while flying (GO 4's video cache, plan P6).
static final ValueNotifier<bool> videoCache = ValueNotifier<bool>(false);
/// The cache's size cap; the oldest recordings go first.
static final ValueNotifier<int> videoCacheLimitMb = ValueNotifier<int>(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<Set<String>> lensCalibrated = ValueNotifier<Set<String>>(<String>{});
/// Intelligent modes whose tutorial the pilot has seen (plan P7), by name.
static final ValueNotifier<Set<String>> seenTutorials = ValueNotifier<Set<String>>(<String>{});
static SharedPreferences? _prefs;
static Future<void> 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 <String>[]).toSet();
seenTutorials.value = (_prefs!.getStringList(_tutorialsKey) ?? const <String>[]).toSet();
} catch (_) {
// Storage unavailable: run on the defaults.
}
@@ -166,6 +205,40 @@ abstract final class FlyPrefs {
await _prefs?.setBool(_liveAudioKey, on);
}
static Future<void> setUnits(UnitSystem u) async {
units.value = u;
await _prefs?.setString(_unitsKey, u.name);
}
static Future<void> 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<void> setVideoCache(bool on) async {
videoCache.value = on;
await _prefs?.setBool(_videoCacheKey, on);
}
static Future<void> setVideoCacheLimitMb(int mb) async {
videoCacheLimitMb.value = mb;
await _prefs?.setInt(_videoCacheLimitKey, mb);
}
static Future<void> markLensCalibrated(String camera) async {
if (lensCalibrated.value.contains(camera)) return;
lensCalibrated.value = <String>{...lensCalibrated.value, camera};
await _prefs?.setStringList(_lensCalibratedKey, lensCalibrated.value.toList()..sort());
}
static Future<void> markTutorialSeen(String mode) async {
if (seenTutorials.value.contains(mode)) return;
seenTutorials.value = <String>{...seenTutorials.value, mode};
await _prefs?.setStringList(_tutorialsKey, seenTutorials.value.toList()..sort());
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
+43
View File
@@ -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<String> speedIds = <String>['pitchMaxSpeed', 'yawMaxSpeed'];
static const List<String> smoothingIds = <String>['pitchSmoothing', 'yawSmoothing'];
/// The values this preset writes, for the tuning ids [ranges] covers.
Map<String, int> valuesFor(Map<String, AxisRange> ranges) => <String, int>{
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<String, AxisRange> ranges, Object? Function(String id) setting) {
for (final GimbalPreset p in values) {
final Map<String, int> want = p.valuesFor(ranges);
if (want.isEmpty) return null;
final bool all = want.entries.every((MapEntry<String, int> 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).
+9 -3
View File
@@ -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,
+99 -9
View File
@@ -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 <String>[],
this.built = false,
});
@@ -33,6 +34,10 @@ class ModeInfo {
/// The first-use disclaimer: what the pilot stays responsible for.
final List<String> 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<String> 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<String> _waypointTutorial = <String>[
'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<ModeInfo> all = <ModeInfo>[
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: <String>[
..._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: <String>[
..._common,
'The aircraft circles at a fixed height and does not brake for obstacles on the circle: check the whole circle is clear.',
],
tutorial: <String>[
'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: <String>[
..._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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
..._common,
'A QuickShot flies a set path around the subject, up to 120 m away and high: keep that space clear.',
],
tutorial: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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.',
],
),
];
+283
View File
@@ -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<String, Object?> toBridge({double? maxAngular}) {
double w = angularVelocity;
if (maxAngular != null && maxAngular > 0) w = math.min(w, maxAngular);
return <String, Object?>{
'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<QuickShot> offered({required bool omni, required bool zoom}) => <QuickShot>[
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<int> intervals = <int>[2, 3, 5, 10];
static const List<int> durations = <int>[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();
}
}
+139
View File
@@ -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<List<double>> elevations(List<LatLng> 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<WaypointSpec> 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<List<TerrainIssue>> run(WaypointMissionSpec spec, LatLng home) async {
final List<(int, LatLng, double)> s = samples(spec);
if (s.isEmpty) return const <TerrainIssue>[];
final List<double> ground = await source.elevations(<LatLng>[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<int, TerrainIssue> worst = <int, TerrainIssue>{};
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<TerrainIssue> 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<List<double>> elevations(List<LatLng> points) async {
final List<double> out = <double>[];
// 100 coordinates per request is the API's limit.
for (int i = 0; i < points.length; i += 100) {
final List<LatLng> chunk = points.sublist(i, (i + 100).clamp(0, points.length));
final Uri uri = Uri.https('api.open-meteo.com', '/v1/elevation', <String, String>{
'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(<double>[for (final Object? e in list) (e as num).toDouble()]);
} finally {
client.close();
}
}
return out;
}
}
+31 -1
View File
@@ -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<TrackMode> offered({required bool omni}) =>
<TrackMode>[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;
@@ -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.
+5
View File
@@ -226,9 +226,11 @@ class FlightControllerCaps {
this.accessLockerSupported,
this.onboardSdkAvailable,
this.simulatorAvailable,
this.precisionTakeOffSupported,
});
factory FlightControllerCaps.fromMap(Map<String, dynamic> 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 {
+35
View File
@@ -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,
};
}
+327 -1
View File
@@ -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<void> setWaypointCruiseSpeed(double speed) async {
if (!model.mission.waypoint.active) {
throw const MissionRefused(<GateCheck>[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<int?> interruptionFor(WaypointMissionSpec spec) async {
if (!model.connected || spec.points.length < 2) return null;
try {
final Map<String, dynamic>? 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<void> 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<void> setTrackCircleSpeed(double speed) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[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<GateCheck> checkQuickShot(QuickShot shot) {
final List<GateCheck> checks = <GateCheck>[];
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<void> startQuickShot(Rect rect, QuickShot shot) async {
final List<GateCheck> 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<GateCheck> 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<GateCheck> checkPoi(PoiSpec spec, {double? maxAngular}) {
final List<GateCheck> checks = <GateCheck>[];
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<void> startPoi(PoiSpec spec, {double? maxAngular}) async {
final List<GateCheck> checks = checkPoi(spec, maxAngular: maxAngular);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Map<String, Object?> 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<void> pausePoi() async {
if (!model.mission.hotpoint.active || model.mission.hotpoint.paused) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not circling.')]);
}
await dji.pauseHotpoint();
}
Future<void> resumePoi() async {
final List<GateCheck> checks = <GateCheck>[
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<void> adjustPoi({double? radius, double? speed, required bool clockwise, required double currentRadius}) async {
if (!model.mission.hotpoint.active) throw const MissionRefused(<GateCheck>[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<GateCheck> checkFollowMe(FollowMeSpec spec, {double? phoneAccuracy}) {
final List<GateCheck> checks = <GateCheck>[];
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<void> startFollowMe(FollowMeSpec spec) async {
final List<GateCheck> 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<void> 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<GateCheck> checkOrientation(OrientationMode mode) {
final List<GateCheck> checks = <GateCheck>[];
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<void> setOrientation(OrientationMode mode) async {
final List<GateCheck> 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<void> lockCourse() async {
if (_a.orientationMode != 'COURSE_LOCK') throw const MissionRefused(<GateCheck>[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<GateCheck> checkSlowMode({required bool cinematic, required bool on}) => <GateCheck>[
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<void> setSlowMode({required bool cinematic, required bool on}) async {
final List<GateCheck> 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<GateCheck> checkHyperlapse(HyperlapseSpec spec) {
final List<GateCheck> checks = <GateCheck>[];
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<void> startHyperlapse(HyperlapseSpec spec) async {
final List<GateCheck> 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<void> 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()
@@ -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<void> 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<String, dynamic>? m = asMap(v);
if (m == null) return formatValue(v);
final List<String> found = <String>[
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<String> bad = <String>[
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 '—';
+295 -1
View File
@@ -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<SettingsTab> all(ProductProfile p) => <SettingsTab>[
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 <DjiKey>[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<SettingSpec> _transmission = <SettingSpec>[
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 <int>[1, 2]) ...<SettingSpec>[
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<SettingSpec> _general = <SettingSpec>[
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: <DjiKey>[SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]),
const GroupSpec('DEVELOPER'),
const CustomSpec(id: developer),
+77
View File
@@ -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<String, dynamic> 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<CompassSensor> compassSensors = const <CompassSensor>[];
/// 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 = <CompassSensor>[
for (final Map<String, dynamic> c in asMapList(t['compassSensors'])) CompassSensor(asInt(c['index']) ?? 0, asString(c['state']), asInt(c['value'])),
];
final Map<String, dynamic>? 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 <CompassSensor>[];
rtk = null;
updatedAt = null;
notifyListeners();
}
+27 -3
View File
@@ -322,7 +322,15 @@ class FlyZoneState extends ChangeNotifier {
}
void applyDatabase(Map<String, dynamic> 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<String, dynamic> 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',
+84
View File
@@ -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<String, dynamic> 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<SpectrumBar> bars;
factory Spectrum.fromEvent(Map<String, dynamic> e) {
final List<SpectrumBar> bars = <SpectrumBar>[
for (final Map<String, dynamic> 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<String, dynamic> 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;
}
}
+112 -7
View File
@@ -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 <SensedSubject>[],
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<SensedSubject> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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();
}
}
+15
View File
@@ -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 = <RcPeer>[for (final Map<String, dynamic> 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());
+48
View File
@@ -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';
}
+7 -1
View File
@@ -379,7 +379,13 @@ class _HomePageState extends State<HomePage> {
Navigator.of(context).push(MaterialPageRoute<void>(
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<void>(
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
)),
),
));
}
+14 -7
View File
@@ -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(<Listenable>[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: <Widget>[
_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)),
]),
);
},
+246 -8
View File
@@ -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<FlyScreen> createState() => _FlyScreenState();
}
@@ -140,6 +153,23 @@ class _FlyScreenState extends State<FlyScreen> {
/// 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<FlyScreen> {
_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<void> _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: <String, Object?>{'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<void> _offerLensCalibration(String camera) async {
final bool? go = await showDialog<bool>(
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: <Widget>[
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')),
FilledButton(key: const ValueKey<String>('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<FlyScreen> {
..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<FlyScreen> {
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<FlyScreen> {
_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<void> _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<void> _exitVision(BuildContext context) async {
@@ -292,6 +459,36 @@ class _FlyScreenState extends State<FlyScreen> {
if (mounted) setState(() => _mode = null);
}
/// Leaving POI or Follow Me while it flies stops it first, like the vision modes.
Future<void> _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: <GateCheck>[..._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<FlyScreen> {
_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<FlyScreen> {
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<FlyScreen> {
Positioned.fromRect(key: const ValueKey<String>('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<FlyScreen> {
);
}
/// 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: <Widget>[
// ── Camera column (right) ─────────────────────────────────────────────
@@ -592,6 +825,7 @@ class _FlyScreenState extends State<FlyScreen> {
onError: _error,
onOpenStatus: () => _openStatus(context),
onClearRoute: _trail.clear,
onOpenFlightLogs: widget.onOpenFlightLogs,
),
),
),
@@ -688,6 +922,7 @@ class _FlyScreenState extends State<FlyScreen> {
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<FlyScreen> {
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<FlyScreen> {
editor: _waypoints,
limits: _limits,
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
resumeFrom: _resumeFrom,
onResume: () => _resume(context),
),
),
Requires(
+697
View File
@@ -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<void> confirmAndStart(
BuildContext context, {
required String title,
required String icon,
required String description,
required List<GateCheck> checks,
required Future<void> 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<Widget> 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<String>('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: <Widget>[
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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) ...<Widget>[
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<String>('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: <Widget>[
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<int> 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<String>('$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: <Widget>[
Text(label, style: HudText.label),
const SizedBox(width: 4),
b('−', -1, canDown),
SizedBox(width: 58, child: Text(value, key: ValueKey<String>(id), textAlign: TextAlign.center, style: HudText.mono)),
b('+', 1, canUp),
]);
}
}
Future<void> _guard(String label, Future<void> 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<void> _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(<Listenable>[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: <Widget>[
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
if (!active)
HudChip(
key: const ValueKey<String>('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) ...<Widget>[
const SizedBox(height: 4),
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
HudChip(key: const ValueKey<String>('poi-cw'), label: 'Clockwise', selected: p.clockwise, onTap: () => set(p.copyWith(clockwise: true))),
HudChip(key: const ValueKey<String>('poi-ccw'), label: 'Anticlockwise', selected: !p.clockwise, onTap: () => set(p.copyWith(clockwise: false))),
for (final PoiHeading h in PoiHeading.values)
HudChip(key: ValueKey<String>('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: <Widget>[
Text('JOIN', style: HudText.label),
for (final PoiStart s in PoiStart.values)
HudChip(key: ValueKey<String>('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 ...<Widget>[
const SizedBox(height: 4),
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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<Widget> poiMapLayers(ModeSetup setup, MissionState mission) => <Widget>[
ListenableBuilder(
listenable: Listenable.merge(<Listenable>[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<Object>(polylines: <Polyline<Object>>[
Polyline<Object>(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>[
Marker(
point: c,
width: 22,
height: 22,
child: Container(
key: const ValueKey<String>('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<LatLng> _circle(LatLng c, double r) {
const double metresPerDegree = 111320;
final double cosLat = math.cos(c.latitude * math.pi / 180);
return <LatLng>[
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(<Listenable>[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: <Widget>[
if (!active)
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
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<String>('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<void> _set(BuildContext context, OrientationMode m) async {
final List<GateCheck> 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: <Widget>[
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
for (final OrientationMode m in OrientationMode.values)
HudChip(key: ValueKey<String>('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<SettingSpec> tuning = <SettingSpec>[
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<SlowModePanel> createState() => _SlowModePanelState();
}
class _SlowModePanelState extends State<SlowModePanel> {
late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys);
@override
void initState() {
super.initState();
if (widget.model.connected) {
_subs.watch(<DjiKey>[SlowModePanel.tripod, SlowModePanel.cinematic, SlowModePanel.brake, SlowModePanel.brakeRange, SlowModePanel.yaw, SlowModePanel.yawRange]);
}
}
@override
void dispose() {
_subs.dispose();
super.dispose();
}
Future<void> _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<void> send() async {
k.setPending(<String, Object?>{key.id: on});
try {
await widget.gate.setSlowMode(cinematic: cinematic, on: on);
} finally {
k.dropPending(<String>[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: <Widget>[
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
if (k.supported(SlowModePanel.tripod) != false)
HudChip(
key: const ValueKey<String>('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<String>('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: <Widget>[
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(<Listenable>[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: <Widget>[
if (!shooting)
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
Text('EVERY', style: HudText.label),
for (final int s in HyperlapseSpec.intervals)
HudChip(key: ValueKey<String>('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<String>('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,
),
),
]),
],
);
},
);
}
}
+81 -2
View File
@@ -24,8 +24,87 @@ Future<IntelligentMode?> 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<void> 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<void>(
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<ModeTutorialDialog> createState() => _ModeTutorialDialogState();
}
class _ModeTutorialDialogState extends State<ModeTutorialDialog> {
int _page = 0;
@override
Widget build(BuildContext context) {
final List<String> 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: <Widget>[
Row(children: <Widget>[
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<String>('tutorial-step'), style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w400, height: 1.4)),
const SizedBox(height: 14),
Row(children: <Widget>[
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<String>('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.
@@ -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<Widget> chips = tapFly
final List<Widget> chips = quickShots
? <Widget>[
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<String>('quickshot-${s.name}'),
label: s.label,
selected: session.quickShot == s,
// A shot cannot change once it flies.
onTap: active ? null : () => session.setQuickShot(s),
),
]
: tapFly
? <Widget>[
for (final TapFlyDirection d in gate.tapFlyDirections())
HudChip(
@@ -110,7 +142,7 @@ class VisionModePanel extends StatelessWidget {
),
]
: <Widget>[
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<String>('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<String>(tapFly ? 'tapfly-panel' : 'track-panel'),
key: ValueKey<String>(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: <Widget>[
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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<int> 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<String>('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'),
key: ValueKey<String>('$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: <Widget>[
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),
]);
}
}
@@ -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) ...<Widget>[
const SizedBox(width: 8),
GestureDetector(
key: const ValueKey<String>('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<void> _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<String>('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: <Widget>[
b('−', -1, _speed > WaypointMissionSpec.minSpeed),
SizedBox(width: 50, child: Text('${_speed.toStringAsFixed(0)} m/s', key: const ValueKey<String>('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});
@@ -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) ...<Widget>[
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<TerrainCheckRow> createState() => _TerrainCheckRowState();
}
class _TerrainCheckRowState extends State<TerrainCheckRow> {
bool _running = false;
List<TerrainIssue>? _found;
String? _error;
/// The route the result belongs to; editing it makes the result stale.
WaypointMissionSpec? _checked;
Future<void> _run() async {
final WaypointMissionSpec m = widget.editor.mission;
setState(() {
_running = true;
_error = null;
});
try {
final List<TerrainIssue> 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<TerrainIssue>? 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: <Widget>[
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 <TerrainIssue>[]) IssueRow(issue: t.toIssue(), onTap: () => widget.editor.select(t.leg + 1)),
]);
}
}
class IssueRow extends StatelessWidget {
const IssueRow({super.key, required this.issue, this.onTap});
@@ -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),
),
),
+110
View File
@@ -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<SettingSpec> rows = <SettingSpec>[
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<GimbalMotorRows> createState() => _GimbalMotorRowsState();
}
class _GimbalMotorRowsState extends State<GimbalMotorRows> {
late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys);
@override
void initState() {
super.initState();
_subs.watch(<DjiKey>[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<SettingSpec> shown = <SettingSpec>[
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: <Widget>[
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: <Widget>[
_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<String, AxisRange> ranges = p.gimbal?.controller ?? const <String, AxisRange>{};
final Map<String, AxisRange> reported = <String, AxisRange>{
for (final MapEntry<String, AxisRange> 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<String>('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<String, int> 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);
@@ -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<CompassWizard> with _Pages<Compass
'in your hand. Take the aircraft off any metal surface. You will turn it a full circle level, '
'then a full circle nose down.${count > 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<CompassWizard> with _Pages<Compass
? 'Turn slowly on the spot about 360° until the next step appears.'
: 'Point the nose at the ground and turn about 360° again.',
progress: step == CompassStep.horizontal ? 0.25 : 0.75,
detail: CompassInterferenceMeter(sensors: model.aircraft.compassSensors),
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
@@ -151,7 +155,10 @@ class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
@override
bool get canAbort => false;
Future<void> _start() => start(dji.startIMUCalibration);
/// One IMU of several (external IMU1 / IMU2 on the M-series), or all when null.
int? _unit;
Future<void> _start() => start(() => dji.startIMUCalibration(index: _unit));
@override
Widget build(BuildContext context) {
@@ -163,8 +170,10 @@ class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
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<ImuWizard> with _Pages<ImuWizard> {
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: <Widget>[
ChoiceChip(
key: const ValueKey<String>('imu-all'),
label: const Text('All IMUs'),
selected: _unit == null,
onSelected: (_) => setState(() => _unit = null),
),
for (int i = 0; i < count; i++)
ChoiceChip(
key: ValueKey<String>('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<CompassSensor> sensors;
@override
Widget build(BuildContext context) {
if (sensors.isEmpty) return const SizedBox.shrink();
return Column(key: const ValueKey<String>('compass-interference'), crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
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: <Widget>[
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<LensWizard> with _Pages<LensWizard> {
@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<LensWizard> with _Pages<LensWizard> {
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>[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<void> _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 ─────────────────────────────────────────────────────────
@@ -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: <Widget>[
for (final RcButton b in RcButton.values)
ValueListenableBuilder<RcButtonAction>(
valueListenable: b == RcButton.c1 ? FlyPrefs.c1 : FlyPrefs.c2,
builder: (BuildContext context, RcButtonAction a, _) => ChoiceTile(
key: ValueKey<String>('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<MasterSearchRows> createState() => _MasterSearchRowsState();
}
class _MasterSearchRowsState extends State<MasterSearchRows> {
bool _searching = false;
@override
void dispose() {
if (_searching) widget.dji.searchMasters(false).catchError((Object _) {});
super.dispose();
}
Future<void> _toggle() async {
final bool on = !_searching;
setState(() => _searching = on);
if (on) widget.model.rcMasters.value = const <RcPeer>[];
try {
await widget.dji.searchMasters(on);
} catch (e) {
if (mounted) setState(() => _searching = false);
widget.onError('Search for master', e);
}
}
Future<void> _join(RcPeer m) async {
final String? password = await showDialog<String>(
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: <Widget>[
ButtonTile(
id: 'searchMasters',
label: _searching ? 'Searching for masters…' : 'Find a master remote',
button: _searching ? 'Stop' : 'Search',
onTap: _toggle,
),
ValueListenableBuilder<List<RcPeer>>(
valueListenable: widget.model.rcMasters,
builder: (BuildContext context, List<RcPeer> masters, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
for (final RcPeer m in masters)
ValueTile(
key: ValueKey<String>('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<SlaveListRow> createState() => _SlaveListRowState();
}
class _SlaveListRowState extends State<SlaveListRow> {
List<RcPeer>? _slaves;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final List<Map<String, dynamic>> l = await widget.dji.getSlaves();
if (mounted) setState(() => _slaves = <RcPeer>[for (final Map<String, dynamic> m in l) RcPeer.fromMap(m)]);
} catch (_) {
if (mounted) setState(() => _slaves = const <RcPeer>[]);
}
}
@override
Widget build(BuildContext context) {
final List<RcPeer>? s = _slaves;
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ValueTile(
key: const ValueKey<String>('row-slaves'),
label: 'Slaves',
value: s == null ? '…' : (s.isEmpty ? 'None' : '${s.length}'),
onTap: _load,
),
for (final RcPeer p in s ?? const <RcPeer>[])
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<SpectrumChart> createState() => _SpectrumChartState();
}
class _SpectrumChartState extends State<SpectrumChart> {
@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<Spectrum?>(
valueListenable: widget.model.spectrum,
builder: (BuildContext context, Spectrum? s, _) {
if (s == null || s.bars.isEmpty) {
return ValueTile(
key: const ValueKey<String>('row-spectrum'),
label: 'Interference',
value: widget.connected ? 'Listening…' : '—',
);
}
final SpectrumBar? q = s.quietest;
return Padding(
key: const ValueKey<String>('row-spectrum'),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Row(children: <Widget>[
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<SpectrumBar> 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<int> limits = <int>[1024, 2048, 4096, 8192, 16384];
@override
State<VideoCacheRows> createState() => _VideoCacheRowsState();
}
class _VideoCacheRowsState extends State<VideoCacheRows> {
Map<String, dynamic>? _info;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
try {
final Map<String, dynamic> 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: <Widget>[
ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.videoCache,
builder: (BuildContext context, bool on, _) => SwitchTile(
key: const ValueKey<String>('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<int>(
valueListenable: FlyPrefs.videoCacheLimitMb,
builder: (BuildContext context, int mb, _) => ChoiceTile(
key: const ValueKey<String>('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();
},
),
]);
}
}
@@ -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<String>('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)),
KeyTextSpec() => ValueTile(
key: ValueKey<String>('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<String>('row-${s.id}'),
id: s.id,
@@ -96,6 +103,28 @@ class SettingRowTile extends StatelessWidget {
return tile;
}
Future<void> _editText(BuildContext context, KeyTextSpec t) async {
final String? typed = await showDialog<String>(
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<void> _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<TextSettingDialog> createState() => _TextSettingDialogState();
}
class _TextSettingDialogState extends State<TextSettingDialog> {
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<String>('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: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(key: const ValueKey<String>('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});
@@ -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<void> showFlySettings(
String initialTab = SettingsTabs.safety,
VoidCallback? onOpenStatus,
VoidCallback? onClearRoute,
VoidCallback? onOpenFlightLogs,
}) {
return showGeneralDialog<void>(
context: context,
@@ -57,6 +59,7 @@ Future<void> showFlySettings(
initialTab: initialTab,
onOpenStatus: onOpenStatus,
onClearRoute: onClearRoute,
onOpenFlightLogs: onOpenFlightLogs,
),
),
transitionBuilder: (BuildContext context, Animation<double> 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<FlySettingsSheet> {
onError: widget.onError,
),
SettingsTabs.airSenseList => AirSenseList(key: const ValueKey<String>('row-airSense'), airSense: _m.airSense, shrinkWrap: true),
SettingsTabs.units => ValueListenableBuilder<UnitSystem>(
valueListenable: FlyPrefs.units,
builder: (BuildContext context, UnitSystem u, _) => ChoiceTile(
key: const ValueKey<String>('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<String>('row-customButtons')),
SettingsTabs.masterSearch =>
MasterSearchRows(key: const ValueKey<String>('row-masterSearch'), model: _m, dji: widget.dji, onError: widget.onError),
SettingsTabs.slaveList => SlaveListRow(key: const ValueKey<String>('row-slaveList'), dji: widget.dji, onError: widget.onError),
SettingsTabs.spectrum => SpectrumChart(key: const ValueKey<String>('spectrum'), model: _m, dji: widget.dji, connected: c.connected),
SettingsTabs.videoCache => VideoCacheRows(key: const ValueKey<String>('row-videoCacheGroup'), dji: widget.dji, onError: widget.onError),
SettingsTabs.flightLogs => widget.onOpenFlightLogs == null
? const SizedBox.shrink()
: ValueTile(
key: const ValueKey<String>('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(),
+43
View File
@@ -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<String>('rtk-chip'),
height: StatusBar.height,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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) ...<Widget>[
const SizedBox(width: 4),
Text('${r.satellites}', style: HudText.monoSmall.copyWith(color: HudText.dim)),
],
]),
);
},
);
}
}
class _ModeText extends StatelessWidget {
const _ModeText({required this.model});
+1
View File
@@ -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',
+90 -2
View File
@@ -38,9 +38,13 @@ class FakeDjiService extends DjiService {
/// The last `uploadWaypointMission` payload.
Map<String, Object?>? uploaded;
/// Whether the last upload went to the Waypoints 2.0 operator.
bool uploadedV2 = false;
@override
Future<void> uploadWaypointMission(Map<String, Object?> mission) {
Future<void> uploadWaypointMission(Map<String, Object?> mission, {bool v2 = false}) {
uploaded = mission;
uploadedV2 = v2;
return _record('uploadWaypointMission(${(mission['waypoints'] as List<Object?>).length})');
}
@@ -50,6 +54,90 @@ class FakeDjiService extends DjiService {
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
@override
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
@override
Future<void> setWaypointAutoFlightSpeed(double speed) => _record('setWaypointAutoFlightSpeed(${speed.toStringAsFixed(0)})');
/// What `getWaypointInterruption` answers.
Map<String, dynamic>? interruption;
@override
Future<Map<String, dynamic>?> getWaypointInterruption() async => interruption;
// ── POI / Follow Me / orientation / QuickShots (plan P7) ───────────────────
Map<String, Object?>? hotpoint;
double? maxAngular;
@override
Future<void> 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 = <String, Object?>{'lat': lat, 'lon': lon, 'altitude': altitude, 'radius': radius, 'angularVelocity': angularVelocity, 'clockwise': clockwise, 'startPoint': startPoint, 'heading': heading};
return _record('startHotpoint');
}
@override
Future<void> pauseHotpoint() => _record('pauseHotpoint');
@override
Future<void> resumeHotpoint() => _record('resumeHotpoint');
@override
Future<void> stopHotpoint() => _record('stopHotpoint');
@override
Future<void> setHotpointRadius(double radius) => _record('setHotpointRadius(${radius.round()})');
@override
Future<void> setHotpointAngularVelocity(double value) => _record('setHotpointAngularVelocity(${value.toStringAsFixed(1)})');
@override
Future<void> resetHotpointHeading() => _record('resetHotpointHeading');
@override
Future<double?> getHotpointMaxAngularVelocity(double radius) async => maxAngular;
@override
Future<void> startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) =>
_record('startFollowMe(${altitude.round()}, $heading)');
@override
Future<void> updateFollowMeTarget(double lat, double lon) => _record('updateFollowMeTarget');
@override
Future<void> stopFollowMe() => _record('stopFollowMe');
@override
Future<void> setFlightOrientationMode(String mode) => _record('setFlightOrientationMode($mode)');
@override
Future<void> lockCourseUsingCurrentHeading() => _record('lockCourseUsingCurrentHeading');
@override
Future<void> startQuickShot(String name, {double? x, double? y, double? w, double? h}) => _record('startQuickShot($name)');
@override
Future<void> setActiveTrackCircularSpeed(double speed) => _record('setActiveTrackCircularSpeed(${speed.toStringAsFixed(0)})');
@override
Future<void> setShootPhotoMode(String mode) => _record('setShootPhotoMode($mode)');
// ── Remote / link / cache (plan P6) ────────────────────────────────────────
@override
Future<void> searchMasters(bool on) => _record('searchMasters($on)');
@override
Future<String?> connectToMaster({required int id, required String name, required String password}) async {
await _record('connectToMaster($id, $name)');
return 'ACCEPTED';
}
@override
Future<List<Map<String, dynamic>>> getSlaves() async => const <Map<String, dynamic>>[];
@override
Future<void> watchSpectrum(bool on) async => calls.add('watchSpectrum($on)');
@override
Future<void> setWifiCredentials({String? ssid, String? password}) => _record('setWifiCredentials(${ssid ?? '-'}, ${password == null ? '-' : '***'})');
@override
Future<void> rebootWifi() => _record('rebootWifi');
@override
Future<void> setVideoCacheRecording(bool on, {int? limitMb}) async => calls.add('setVideoCacheRecording($on)');
@override
Future<Map<String, dynamic>> getVideoCacheInfo() async => <String, dynamic>{'bytes': 0, 'files': 0, 'recording': false};
@override
Future<void> clearVideoCache() => _record('clearVideoCache');
@override
Future<void> calibrateInfinityFocus() => _record('calibrateInfinityFocus');
// ── ActiveTrack / TapFly (plan P7b) ────────────────────────────────────────
@override
@@ -141,7 +229,7 @@ class FakeDjiService extends DjiService {
@override
Future<void> stopCompassCalibration() => _record('stopCompassCalibration');
@override
Future<void> startIMUCalibration({int? index}) => _record('startIMUCalibration');
Future<void> startIMUCalibration({int? index}) => _record(index == null ? 'startIMUCalibration' : 'startIMUCalibration($index)');
@override
Future<void> startGravityCenterCalibration() => _record('startGravityCenterCalibration');
@override
+167
View File
@@ -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<String, dynamic> 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'] = <String, dynamic>{'solution': 'FIXED_POINT', 'inUse': true, 'satellites': 24}
..['compassSensors'] = <Object?>[
<String, dynamic>{'index': 0, 'state': 'NORMAL_MODULUS', 'value': 1500},
<String, dynamic>{'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), <int?>[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(<String, dynamic>{
...phantom4,
'flightController': <String, dynamic>{...(phantom4['flightController'] as Map<String, dynamic>), '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<String, AxisRange> ranges = <String, AxisRange>{
'pitchMaxSpeed': AxisRange(0, 100),
'pitchSmoothing': AxisRange(0, 30),
};
expect(GimbalPreset.fast.valuesFor(ranges), <String, int>{'pitchMaxSpeed': 80, 'pitchSmoothing': 6});
final Map<String, int> 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(<String, dynamic>{
'link': 'ocusync',
'bands': <Object?>[
<String, dynamic>{'from': 2400.0, 'to': 2410.0, 'rssi': -90},
<String, dynamic>{'from': 2410.0, 'to': 2420.0, 'rssi': -100},
],
});
expect(os.bars.map((SpectrumBar b) => b.label), <String>['2405', '2415']);
expect(os.quietest!.label, '2415');
final Spectrum lb = Spectrum.fromEvent(<String, dynamic>{
'link': 'lightbridge',
'channels': <Object?>[<String, dynamic>{'channel': 13, 'power': -70}],
});
expect(lb.bars.single.label, '13');
});
test('battery warning records read as what went wrong', () {
expect(formatWarningRecord(<String, dynamic>{'error': false}), 'None');
expect(formatWarningRecord(<String, dynamic>{'overHeated': true, 'lowVoltageCell': 3}), 'overheated, low cell 3');
expect(
formatWarningHistory(<Object?>[
<String, dynamic>{'error': false},
<String, dynamic>{'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(<String, dynamic>{'type': 'rcButton', 'button': 'C1'});
final RcButtonPress first = m.rcButton.value!;
r.apply(<String, dynamic>{'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(<String, Object>{});
await FlyPrefs.load();
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CustomButtonRows()))));
await tester.tap(find.byKey(const ValueKey<String>('row-rcC1')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('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>[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(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
expect(z.database!.outOfDate, isFalse);
z.applyRemoteVersion('1.1');
expect(z.database!.preciseBehind, isTrue);
z.applyDatabase(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
expect(z.database!.outOfDate, isTrue, reason: 'the remote version is kept');
});
});
}
+333
View File
@@ -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<String, dynamic> tick, {Map<String, dynamic>? profile}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
TelemetryReducer(m).apply(tick);
return m;
}
Map<String, dynamic> _grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false;
List<String> _blockers(List<GateCheck> c) => <String>[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<List<double>> elevations(List<LatLng> points) async => <double>[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(<String, dynamic>{'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(<String, dynamic>{'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(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING'});
ms.apply(<String, dynamic>{'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(<String, dynamic>{'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<MissionRefused>()), 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, <String>['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>[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(<String, dynamic>{
'type': 'mission',
'operator': 'activeTrack',
'state': 'PERFORMING_QUICK_SHOT',
'tracking': <String, dynamic>{'percent': 40},
});
expect(ms.state, 'quickshot');
expect(ms.activeTrack.percent, 40);
});
});
group('Waypoints extras', () {
const WaypointMissionSpec route = WaypointMissionSpec(points: <WaypointSpec>[
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 = <String, dynamic>{'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 = <String, dynamic>{'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<MissionRefused>()));
m.mission.apply(<String, dynamic>{'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<String, dynamic> m300 = <String, dynamic>{...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: <WaypointSpec>[
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<TerrainIssue> 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(<String, Object>{});
FlyPrefs.seenTutorials.value = <String>{};
});
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<void> 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<String>('tutorial-next')));
await tester.pumpAndSettle();
}
await shown;
expect(FlyPrefs.seenTutorials.value, contains('pointOfInterest'));
ensureModeTutorial(ctx, poi);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('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<String>('poi-aircraft')));
await tester.pump();
expect(setup.poi.center, LatLng(m.aircraft.latitude!, m.aircraft.longitude!));
await tester.tap(find.byKey(const ValueKey<String>('poi-radius-plus')));
await tester.pump();
expect(setup.poi.radius, 35);
await tester.tap(find.byKey(const ValueKey<String>('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<String>('hl-length-20')));
await tester.pump();
expect(find.textContaining('500 FRAMES'), findsOneWidget);
});
});
}
class _FlatGround implements ElevationSource {
const _FlatGround();
@override
Future<List<double>> elevations(List<LatLng> points) async => List<double>.filled(points.length, 12);
}
+5 -2
View File
@@ -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);
});
+13 -2
View File
@@ -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), <String>['WARNINGS', 'BATTERY']);
expect(groups(inspire2X5s), <String>['WARNINGS', 'BATTERY 1', 'BATTERY 2']);
// The pair and cell-voltage groups are always listed and gated per profile.
expect(groups(phantom4), <String>['WARNINGS', 'BATTERY', 'BATTERY PAIR', 'CELL VOLTAGE']);
expect(groups(inspire2X5s), <String>['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<KeyReadoutSpec>(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s));
expect(cells.key.index, 1);
});
@@ -287,6 +295,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey<String>('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<String>('row-osChannel')), 80,
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-transmission')), matching: find.byType(Scrollable)));
expect(find.byKey(const ValueKey<String>('row-osChannel')), findsOneWidget);
await tester.pump(cameraSettle);
});
+2
View File
@@ -288,6 +288,8 @@ void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{'activeTrack', 'tapFly'};
// The first-use tutorials have their own test (p7_modes_test.dart).
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m) async {
+2
View File
@@ -682,6 +682,8 @@ void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{};
// The first-use tutorials have their own test (p7_modes_test.dart).
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m, {WaypointEditor? editor}) async {