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:
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 {
|
||||
|
||||
+47
-3
@@ -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")
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 '—';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user