diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt new file mode 100644 index 0000000..0e60170 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CalibrationBridge.kt @@ -0,0 +1,294 @@ +package com.pilotvault.flyapp + +import dji.common.camera.DJICameraCalibrateResult +import dji.common.camera.DJICameraCalibrateState +import dji.common.error.DJIError +import dji.common.flightcontroller.GravityCenterState +import dji.common.flightcontroller.imu.IMUState +import dji.common.remotecontroller.CalibrationState +import dji.keysdk.CameraKey +import dji.keysdk.DJIKey +import dji.keysdk.FlightControllerKey +import dji.keysdk.KeyManager +import dji.keysdk.RemoteControllerKey +import dji.keysdk.callback.SetCallback +import dji.sdk.base.BaseProduct +import dji.sdk.camera.Camera +import dji.sdk.flightcontroller.FlightController +import dji.sdk.products.Aircraft +import dji.sdk.remotecontroller.RemoteController +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * The calibration and maintenance wizards' native half (plan P6b). + * + * State goes out as the `calibration` fast group of the [TelemetryHub]: + * + * ``` + * calibration: { + * support: {compassCount, imuCount, gravityCenter, propeller, lens, sensorCleaning, rc}, + * imu: {units: [{index, state, progress, gyroscope, accelerometer, connected}], + * multiSide, orientationState, toCalibrate: [NOSE_DOWN …], calibrated: [...]}, + * gravityCenter: {state, error}, + * lens: {state, result}, + * sensorCleaning: SensorCleaningState name, + * rc: {state, segments, axes: {A: …, …}}, + * rcHardware: {lx, ly, rx, ry, leftDial, rightDial} // only while watched + * } + * ``` + * + * The compass walks its own `compassCalibrationState` in the flight-controller + * keys ([FlightControllerBridge]); the gimbal its `gimbal` group ([GimbalBridge]). + * + * Methods: `startCompassCalibration`, `stopCompassCalibration`, + * `startIMUCalibration {index?}`, `startGravityCenterCalibration`, + * `stopGravityCenterCalibration`, `startPropellerCalibration`, + * `stopPropellerCalibration`, `startLensCalibration`, `stopLensCalibration`, + * `initSensorCleaning`, `startSensorCleaning`, `exitSensorCleaning`, + * `setRcCalibrationStep {step: MIDDLE | LIMITS | QUIT}` and + * `watchRcHardware {on}` (the sticks and dials, for the RC wizard's live view). + * + * The RC calibration key sequence (MIDDLE → LIMITS → QUIT) is what DJI's UX SDK + * drives; which remotes accept it is still to be confirmed on hardware (R2). + */ +class CalibrationBridge(private val ctx: BridgeCtx) : SubBridge { + + /** The `calibration` group as last built; re-published whole on every change. */ + private val group = LinkedHashMap() + + /** IMU units by index — the callback reports one unit at a time. */ + private val imuUnits = sortedMapOf>() + + private val watch = KeyWatch(ctx) + private val rcWatch = KeyWatch(ctx) + private var rcHardwareOn = false + + private fun fc(): FlightController? = ctx.aircraft()?.flightController + private fun camera(): Camera? = ctx.product()?.camera + private fun rc(): RemoteController? = ctx.aircraft()?.remoteController + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "startCompassCalibration" -> withFc(result) { + val compass = it.compass + if (compass == null) ctx.fail(result, "No compass on this aircraft") + else compass.startCalibration(ctx.completion(result)) + } + "stopCompassCalibration" -> withFc(result) { + val compass = it.compass + if (compass == null) ctx.fail(result, "No compass on this aircraft") + else compass.stopCalibration(ctx.completion(result)) + } + "startIMUCalibration" -> withFc(result) { + val index = call.argument("index") + if (index == null) it.startIMUCalibration(ctx.completion(result)) + else it.startIMUCalibration(index, ctx.completion(result)) + } + "startGravityCenterCalibration" -> withFc(result) { it.startGravityCenterCalibration(ctx.completion(result)) } + "stopGravityCenterCalibration" -> withFc(result) { it.stopGravityCenterCalibration(ctx.completion(result)) } + "startPropellerCalibration" -> withFc(result) { + if (!it.isPropellerCalibrationSupported) ctx.fail(result, "Propeller calibration is not supported on this aircraft") + else it.startPropellerCalibration(ctx.completion(result)) + } + "stopPropellerCalibration" -> withFc(result) { it.stopPropellerCalibration(ctx.completion(result)) } + "startLensCalibration" -> withCamera(result) { it.startCalibration(ctx.completion(result)) } + "stopLensCalibration" -> withCamera(result) { it.stopCalibration(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)) } + "setRcCalibrationStep" -> { + val step = CalibrationState.values().firstOrNull { it.name == call.argument("step") } + val km = KeyManager.getInstance() + if (step == null) { + ctx.fail(result, "Unknown RC calibration step") + } else if (km == null || rc() == null) { + ctx.fail(result, "No remote controller connected") + } else { + km.setValue(RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE), step, object : SetCallback { + override fun onSuccess() { + ctx.mainHandler.post { result.success(null) } + } + + override fun onFailure(error: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) } + } + }) + } + } + "watchRcHardware" -> { + val on = call.argument("on") ?: false + ctx.mainHandler.post { + setRcHardware(on) + result.success(null) + } + } + else -> return false + } + return true + } + + private inline fun withFc(result: MethodChannel.Result, block: (FlightController) -> Unit) { + val f = fc() + if (f == null) ctx.fail(result, "No aircraft connected") else block(f) + } + + private inline fun withCamera(result: MethodChannel.Result, block: (Camera) -> Unit) { + val c = camera() + if (c == null) ctx.fail(result, "No camera connected") else block(c) + } + + override fun bind(product: BaseProduct?) { + if (product !is Aircraft) return + watch.clear() + val fc = product.flightController + safe { + fc?.setIMUStateCallback { s -> ctx.mainHandler.post { onImu(s) } } + } + safe { + fc?.setGravityCenterStateCallback { s: GravityCenterState -> + val v = mapOf("state" to s.calibrationState?.name, "error" to s.calibrationError?.description) + ctx.mainHandler.post { put("gravityCenter", v) } + } + } + safe { + product.camera?.setCalibrationCallback(object : Camera.CalibrationCallback { + override fun OnStateUpdate(state: DJICameraCalibrateState?) { + ctx.mainHandler.post { patch("lens", mapOf("state" to state?.name)) } + } + + override fun OnResultUpdate(r: DJICameraCalibrateResult?) { + ctx.mainHandler.post { patch("lens", mapOf("result" to r?.name)) } + } + }) + } + watch.watch(key { CameraKey.create(CameraKey.SENSOR_CLEANING_STATE) }) { v -> put("sensorCleaning", KeyValues.toDart(v)) } + watch.watch(key { CameraKey.create(CameraKey.IS_SENSOR_CLEANING_SUPPORTED) }) { v -> patch("support", mapOf("sensorCleaning" to (v as? Boolean))) } + watch.watch(key { RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE) }) { v -> patch("rc", mapOf("state" to KeyValues.toDart(v))) } + + readSupport() + // Counts and key support are often unknown until the handshake finishes. + ctx.mainHandler.postDelayed({ if (ctx.aircraft() === product) readSupport() }, 3000) + } + + /** What the wizard hub offers; a missing fact is unknown, not unsupported. */ + private fun readSupport() { + val a = ctx.aircraft() ?: return + val km = KeyManager.getInstance() + val fc = a.flightController + fun supported(k: DJIKey?): Boolean? = if (km == null || k == null) null else safe { km.isKeySupported(k) } + patch( + "support", + mapOf( + "compassCount" to safe { fc?.compassCount }, + "imuCount" to safe { fc?.imuCount }, + "gravityCenter" to supported(key { FlightControllerKey.create(FlightControllerKey.START_GRAVITY_CENTER_CALIBRATION) }), + "propeller" to safe { fc?.isPropellerCalibrationSupported }, + "lens" to safe { a.camera?.isInterchangeableLensSupported }, + "rc" to (if (a.remoteController == null) false else supported(key { RemoteControllerKey.create(RemoteControllerKey.CALIBRATION_STATE) })), + ), + ) + } + + private fun onImu(s: IMUState) { + val units = mutableListOf(s) + s.subIMUState?.filterNotNull()?.let { units += it } + for (u in units) { + imuUnits[u.index] = mapOf( + "index" to u.index, + "state" to u.calibrationState?.name, + "progress" to u.calibrationProgress, + "gyroscope" to u.gyroscopeState?.name, + "accelerometer" to u.accelerometerState?.name, + "connected" to u.isConnected, + ) + } + val hint = s.multipleOrientationCalibrationHint + put( + "imu", + mapOf( + "units" to imuUnits.values.toList(), + "multiSide" to s.isMultiSideCalibrationType, + "orientationState" to hint?.state?.name, + "toCalibrate" to hint?.orientationsToCalibrate?.map { it.name }?.sorted(), + "calibrated" to hint?.orientationsCalibrated?.map { it.name }?.sorted(), + ), + ) + } + + /** The RC wizard's live sticks and dials, plus the per-axis calibration keys. */ + private fun setRcHardware(on: Boolean) { + if (on == rcHardwareOn) return + rcHardwareOn = on + val remote = rc() + if (!on) { + safe { remote?.setHardwareStateCallback(null) } + rcWatch.clear() + group.remove("rcHardware") + publish() + return + } + safe { + remote?.setHardwareStateCallback { h -> + val v = mapOf( + "lx" to h.leftStick?.horizontalPosition, + "ly" to h.leftStick?.verticalPosition, + "rx" to h.rightStick?.horizontalPosition, + "ry" to h.rightStick?.verticalPosition, + "leftDial" to h.leftDial, + "rightDial" to h.rightDial?.value, + ) + ctx.mainHandler.post { if (rcHardwareOn) put("rcHardware", v) } + } + } + rcWatch.watch(key { RemoteControllerKey.create(RemoteControllerKey.REMOTE_CONTROLLER_CALIBRATION_NUMBER_OF_SEGMENT) }) { v -> + patch("rc", mapOf("segments" to KeyValues.toDart(v))) + } + for (axis in "ABCDEFGH") { + val name = "REMOTE_CONTROLLER_CALIBRATION_${axis}_AXIS_STATUS" + val param = safe { RemoteControllerKey::class.java.getField(name).get(null) as? String } ?: continue + rcWatch.watch(key { RemoteControllerKey.create(param) }) { v -> + @Suppress("UNCHECKED_CAST") + val rcGroup = group["rc"] as? Map + @Suppress("UNCHECKED_CAST") + val axes = (rcGroup?.get("axes") as? Map).orEmpty() + patch("rc", mapOf("axes" to axes + (axis.toString() to KeyValues.toDart(v)))) + } + } + } + + // Main thread only. + private fun put(k: String, v: Any?) { + if (group[k] == v && group.containsKey(k)) return + group[k] = v + publish() + } + + private fun patch(k: String, values: Map) { + @Suppress("UNCHECKED_CAST") + val current = (group[k] as? Map).orEmpty() + put(k, current + values) + } + + private fun publish() = ctx.hub.putFast(mapOf("calibration" to LinkedHashMap(group))) + + override fun reset() { + watch.clear() + rcWatch.clear() + rcHardwareOn = false + imuUnits.clear() + group.clear() + } + + private inline fun key(make: () -> DJIKey?): DJIKey? = try { + make() + } catch (_: Throwable) { + null + } + + private inline fun safe(block: () -> T): T? = try { + block() + } catch (_: Throwable) { + null + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt index 3237062..f6f9fd9 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt @@ -23,7 +23,7 @@ import io.flutter.plugin.common.MethodChannel * helpers ([FlightControllerBridge], [BatteryBridge], [AirLinkBridge], * [FlightAssistantBridge], [DiagnosticsBridge], [CameraBridge], [GimbalBridge], * [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge], - * [KeyBridge]). + * [KeyBridge], [CalibrationBridge]). * - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event * is a map carrying a `type` key. Telemetry from every subsystem is batched by * the shared [TelemetryHub] into one `telemetry` event per tick. @@ -110,6 +110,7 @@ class DjiSdkBridge( AccountBridge(ctx), ProductProfileBridge(ctx), KeyBridge(ctx), + CalibrationBridge(ctx), ) init { diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt index bb6b7e5..994fc2e 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt @@ -76,18 +76,6 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { it.setHomeLocation(LocationCoordinate2D(lat, lon), ctx.completion(result)) } } - // Calibration starts (Aircraft Status sheet); the guided wizards are P6b. - "startCompassCalibration" -> guard(controller, result) { - val compass = it.compass - if (compass == null) ctx.fail(result, "No compass on this aircraft") - else compass.startCalibration(ctx.completion(result)) - } - "stopCompassCalibration" -> guard(controller, result) { - val compass = it.compass - if (compass == null) ctx.fail(result, "No compass on this aircraft") - else compass.stopCalibration(ctx.completion(result)) - } - "startIMUCalibration" -> guard(controller, result) { it.startIMUCalibration(ctx.completion(result)) } "setMaxFlightHeight" -> guard(controller, result) { it.setMaxFlightHeight((call.argument("value") ?: 120), ctx.completion(result)) } diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 7fdf987..adccdca 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -71,7 +71,33 @@ class DjiService { /// HORIZONTAL → VERTICAL → SUCCESSFUL / FAILED as the pilot rotates the aircraft. Future startCompassCalibration() => _methods.invokeMethod('startCompassCalibration'); Future stopCompassCalibration() => _methods.invokeMethod('stopCompassCalibration'); - Future startIMUCalibration() => _methods.invokeMethod('startIMUCalibration'); + + // ── Calibration & maintenance (plan P6b, `CalibrationBridge.kt`) ──────────── + // Progress arrives in the `calibration` telemetry group (`CalibrationState`). + + /// Every IMU, or only [index]. There is no stop: it runs to the end. + Future startIMUCalibration({int? index}) => + _methods.invokeMethod('startIMUCalibration', {if (index != null) 'index': index}); + Future startGravityCenterCalibration() => _methods.invokeMethod('startGravityCenterCalibration'); + Future stopGravityCenterCalibration() => _methods.invokeMethod('stopGravityCenterCalibration'); + Future startPropellerCalibration() => _methods.invokeMethod('startPropellerCalibration'); + Future stopPropellerCalibration() => _methods.invokeMethod('stopPropellerCalibration'); + + /// Manual-focus lens calibration (interchangeable-lens cameras). + Future startLensCalibration() => _methods.invokeMethod('startLensCalibration'); + Future stopLensCalibration() => _methods.invokeMethod('stopLensCalibration'); + + /// Sensor dedusting: enter the mode, clean (lens off), leave it. + Future initSensorCleaning() => _methods.invokeMethod('initSensorCleaning'); + Future startSensorCleaning() => _methods.invokeMethod('startSensorCleaning'); + Future exitSensorCleaning() => _methods.invokeMethod('exitSensorCleaning'); + + /// Remote-controller calibration step: `MIDDLE` → `LIMITS` → `QUIT`. + Future setRcCalibrationStep(String step) => + _methods.invokeMethod('setRcCalibrationStep', {'step': step}); + + /// Streams the sticks and dials into `calibration.rcHardware` while on. + Future watchRcHardware(bool on) => _methods.invokeMethod('watchRcHardware', {'on': on}); /// Starts the flight controller's built-in simulator at [latitude]/[longitude] /// (developer only; propellers off). `telemetry.simulatorActive` reflects it. diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 5e75c10..c887c03 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -4,6 +4,7 @@ import 'fly/product_profile.dart'; import 'fly/state/aircraft_state.dart'; import 'fly/state/alert_state.dart'; import 'fly/state/battery_state.dart'; +import 'fly/state/calibration_state.dart'; import 'fly/state/camera_state.dart'; import 'fly/state/gimbal_state.dart'; import 'fly/state/key_state.dart'; @@ -128,8 +129,11 @@ class FlightModel extends ChangeNotifier { /// KeyManager values the settings sheet listens to (plan P6). final KeyState keys = KeyState(); + /// The calibration wizards' progress (plan P6b). + final CalibrationState calibration = CalibrationState(); + late final List _subs = [ - aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, // + aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, // ]; /// Wipes the aircraft-bound sub-models on disconnect. @@ -142,6 +146,7 @@ class FlightModel extends ChangeNotifier { vision.clear(); alerts.clear(); keys.clear(); + calibration.clear(); }); // ── SDK / registration / connection ──────────────────────────────────────── diff --git a/Fly App/lib/fly/calibration.dart b/Fly App/lib/fly/calibration.dart new file mode 100644 index 0000000..95b302f --- /dev/null +++ b/Fly App/lib/fly/calibration.dart @@ -0,0 +1,360 @@ +import '../flight_model.dart'; +import 'hud_features.dart'; +import 'product_profile.dart'; +import 'state/calibration_state.dart'; + +/// The calibration and maintenance wizards (plan P6b). +enum CalibrationKind { + compass, + imu, + rc, + gimbal, + + /// Roll / pitch / yaw trim against a horizon line over the live video. + gimbalAdjust, + lens, + sensorCleaning, + gravityCenter, + propeller, + + /// No SDK call: GO 4 also only says "use DJI Assistant 2". + visionSensors, +} + +/// Titles, gating and availability per wizard. +abstract final class CalibrationKinds { + static String title(CalibrationKind k) => switch (k) { + CalibrationKind.compass => 'Compass calibration', + CalibrationKind.imu => 'IMU calibration', + CalibrationKind.rc => 'Remote controller calibration', + CalibrationKind.gimbal => 'Gimbal auto calibration', + CalibrationKind.gimbalAdjust => 'Adjust gimbal', + CalibrationKind.lens => 'Lens calibration', + CalibrationKind.sensorCleaning => 'Sensor cleaning', + CalibrationKind.gravityCenter => 'Center of gravity calibration', + CalibrationKind.propeller => 'Propeller calibration', + CalibrationKind.visionSensors => 'Vision sensor calibration', + }; + + static String icon(CalibrationKind k) => switch (k) { + CalibrationKind.compass => 'compass', + CalibrationKind.imu => 'gauge', + CalibrationKind.rc => 'rc', + CalibrationKind.gimbal || CalibrationKind.gimbalAdjust => 'gimbal', + CalibrationKind.lens => 'focus', + CalibrationKind.sensorCleaning => 'aperture', + CalibrationKind.gravityCenter || CalibrationKind.propeller => 'drone', + CalibrationKind.visionSensors => 'obstacle', + }; + + static HudFeature feature(CalibrationKind k) => switch (k) { + CalibrationKind.compass => HudFeatures.calibrationCompass, + CalibrationKind.imu => HudFeatures.calibrationImu, + CalibrationKind.rc => HudFeatures.calibrationRc, + CalibrationKind.gimbal => HudFeatures.gimbalCalibration, + CalibrationKind.gimbalAdjust => HudFeatures.gimbalAdjust, + CalibrationKind.lens => HudFeatures.calibrationLens, + CalibrationKind.sensorCleaning => HudFeatures.sensorCleaning, + CalibrationKind.gravityCenter => HudFeatures.calibrationGravityCenter, + CalibrationKind.propeller => HudFeatures.calibrationPropeller, + CalibrationKind.visionSensors => HudFeatures.visionSensorGuide, + }; + + /// The wizards the hub lists: the profile's feature, then whatever the + /// aircraft has since said it does not support. Unknown counts as offered. + static List available(ProductProfile p, CalibrationState c) => [ + for (final CalibrationKind k in CalibrationKind.values) + if ((!p.connected || feature(k).enabledFor(p)) && _runtime(k, c) != false) k, + ]; + + static bool? _runtime(CalibrationKind k, CalibrationState c) => switch (k) { + CalibrationKind.compass => c.compassCount == null ? null : c.compassCount! > 0, + CalibrationKind.gravityCenter => c.gravityCenterSupported, + CalibrationKind.propeller => c.propellerSupported, + CalibrationKind.lens => c.lensSupported, + CalibrationKind.sensorCleaning => c.sensorCleaningSupported, + CalibrationKind.rc => c.rcSupported, + _ => null, + }; +} + +enum RunPhase { idle, starting, running, succeeded, failed } + +/// One wizard's progress, folded from the live model. A wizard calls [begin] +/// when its start command is sent and [observe] on every model change; the +/// result only counts once the aircraft was seen working since [begin], so a +/// stale SUCCESSFUL from an earlier run never ends a new one. +abstract class CalibrationRun { + RunPhase phase = RunPhase.idle; + String? failure; + bool _seenActive = false; + + bool get active => phase == RunPhase.starting || phase == RunPhase.running; + bool get finished => phase == RunPhase.succeeded || phase == RunPhase.failed; + + void begin() { + phase = RunPhase.starting; + failure = null; + _seenActive = false; + } + + /// The start command itself was refused. + void startFailed(String reason) { + phase = RunPhase.failed; + failure = reason; + } + + /// For calibrations the SDK reports nothing about: the start was accepted, + /// so it is running until the pilot says it ended ([complete]). + void accepted() { + if (phase != RunPhase.starting) return; + phase = RunPhase.running; + _seenActive = true; + } + + void complete() { + if (active) phase = RunPhase.succeeded; + } + + void reset() { + phase = RunPhase.idle; + failure = null; + _seenActive = false; + } + + void observe(FlightModel m); + + /// Common folding: [working] while the aircraft reports progress, then + /// [ok] / [failed] once it has worked. + void fold({required bool working, bool ok = false, bool failed = false, String? reason}) { + if (!active) return; + if (working) { + _seenActive = true; + phase = RunPhase.running; + return; + } + if (!_seenActive) return; + if (failed) { + phase = RunPhase.failed; + failure = reason; + } else if (ok) { + phase = RunPhase.succeeded; + } + } +} + +// ── Compass ───────────────────────────────────────────────────────────────── + +enum CompassStep { horizontal, vertical } + +class CompassRun extends CalibrationRun { + CompassStep? step; + + @override + void observe(FlightModel m) { + final String? s = m.aircraft.compassCalibrationState; + step = switch (s) { + 'HORIZONTAL' => CompassStep.horizontal, + 'VERTICAL' => CompassStep.vertical, + _ => step, + }; + final bool working = s == 'HORIZONTAL' || s == 'VERTICAL' || (m.aircraft.compassCalibrating == true && s != 'SUCCESSFUL' && s != 'FAILED'); + // After SUCCESSFUL the state drops back to NOT_CALIBRATING; an end without + // a verdict counts by the compass's own error flag. + final bool ended = !working && (s == 'SUCCESSFUL' || s == 'FAILED' || s == 'NOT_CALIBRATING'); + final bool failed = s == 'FAILED' || (s == 'NOT_CALIBRATING' && m.aircraft.compassHasError == true); + fold( + working: working, + ok: ended && !failed, + failed: ended && failed, + reason: 'Move away from metal, cars and buildings, then try again.', + ); + } +} + +// ── IMU ───────────────────────────────────────────────────────────────────── + +/// GO 4's order for the multi-side sequence. +abstract final class ImuOrientations { + static const List order = ['BOTTOM_DOWN', 'TOP_DOWN', 'NOSE_DOWN', 'TAIL_DOWN', 'LEFT_DOWN', 'RIGHT_DOWN']; + + static String label(String o) => switch (o) { + 'BOTTOM_DOWN' => 'Flat, upright', + 'TOP_DOWN' => 'Upside down', + 'NOSE_DOWN' => 'Nose down', + 'TAIL_DOWN' => 'Tail down', + 'LEFT_DOWN' => 'Left side down', + 'RIGHT_DOWN' => 'Right side down', + _ => o, + }; +} + +class ImuRun extends CalibrationRun { + /// 0..1 across the units that report progress. + double? progress; + + /// The side to put down now, in a multi-side calibration. + String? orientation; + + /// The multi-side sequence: every side and whether it is done. + List<(String, bool)> sides = const <(String, bool)>[]; + + @override + void observe(FlightModel m) { + final CalibrationState c = m.calibration; + final List units = [for (final ImuUnit u in c.imuUnits) if (u.connected != false) u]; + final List p = [for (final ImuUnit u in units) if (u.progress != null) u.progress!]; + progress = p.isEmpty ? null : p.reduce((int a, int b) => a + b) / p.length / 100; + + final Set done = c.imuCalibrated.toSet(); + final Set wanted = {...c.imuToCalibrate, ...done}; + sides = <(String, bool)>[ + for (final String o in ImuOrientations.order) + if (wanted.contains(o)) (o, done.contains(o)), + ]; + orientation = c.imuMultiSide == true + ? ImuOrientations.order.where((String o) => wanted.contains(o) && !done.contains(o)).firstOrNull + : null; + + final bool working = units.any((ImuUnit u) => u.state == 'CALIBRATING') || c.imuOrientationState == 'CALIBRATING'; + final bool failed = units.any((ImuUnit u) => u.state == 'FAILED'); + final bool ok = units.isNotEmpty && units.every((ImuUnit u) => u.state == 'SUCCESSFUL'); + fold(working: working && !failed, ok: ok, failed: failed, reason: 'Keep the aircraft still on a level surface and try again.'); + } +} + +// ── Remote controller ─────────────────────────────────────────────────────── + +enum RcStep { middle, limits } + +/// The remote's calibration as GO 4 / the UX SDK run it: record the centre +/// (MIDDLE), sweep every stick and dial (LIMITS), then QUIT to save. +class RcRun extends CalibrationRun { + RcStep? step; + bool _quitting = false; + + /// The pilot asked to finish; the next NORMAL / QUIT ends the run. + void finishing() => _quitting = true; + + @override + void begin() { + super.begin(); + step = null; + _quitting = false; + } + + @override + void observe(FlightModel m) { + final String? s = m.calibration.rcState; + if (s == 'MIDDLE') step = RcStep.middle; + if (s == 'LIMITS') step = RcStep.limits; + fold( + working: (s == 'MIDDLE' || s == 'LIMITS') && !_quitting, + ok: _quitting, + failed: s == 'TIMEOUT' || s == 'DISCONNECT', + reason: s == 'TIMEOUT' ? 'The remote timed out. Start again.' : 'The remote disconnected.', + ); + } +} + +// ── Gimbal auto calibration ───────────────────────────────────────────────── + +class GimbalRun extends CalibrationRun { + int? progress; + + @override + void observe(FlightModel m) { + progress = m.gimbal.calibrationProgress; + fold( + working: m.gimbal.calibrating, + ok: m.gimbal.lastCalibrationOk == true, + failed: m.gimbal.lastCalibrationOk == false, + reason: 'Make sure nothing blocks the gimbal and the aircraft is level, then try again.', + ); + } +} + +// ── Lens ──────────────────────────────────────────────────────────────────── + +class LensRun extends CalibrationRun { + @override + void observe(FlightModel m) { + final String? r = m.calibration.lensResult; + fold( + working: m.calibration.lensState == 'CALIBRATING' && r != 'COMPLETED' && r != 'FAIL_NORMAL' && r != 'DATA_ERROR', + ok: r == 'COMPLETED', + failed: r == 'FAIL_NORMAL' || r == 'DATA_ERROR', + reason: r == 'DATA_ERROR' ? 'The lens returned bad data. Check it is seated correctly.' : 'Calibration failed. Try again.', + ); + } +} + +// ── Center of gravity ─────────────────────────────────────────────────────── + +class GravityCenterRun extends CalibrationRun { + @override + void observe(FlightModel m) { + final String? s = m.calibration.gravityCenterState; + fold( + working: s == 'CALCULATING', + ok: s == 'SUCCESSFUL', + failed: s == 'FAILED', + reason: m.calibration.gravityCenterError ?? 'Hover steadily in calm air and try again.', + ); + } +} + +// ── Sensor cleaning ───────────────────────────────────────────────────────── + +/// What the pilot does at each sensor-cleaning state. +enum CleaningStep { off, entering, removeLens, ready, cleaning, finishing, remountLens } + +abstract final class SensorCleaning { + static CleaningStep step(String? s) => switch (s) { + 'INITIATING' => CleaningStep.entering, + 'WAITING_FOR_LENS_REMOVAL' => CleaningStep.removeLens, + 'READY' => CleaningStep.ready, + 'CLEANING' => CleaningStep.cleaning, + 'FINISHING' => CleaningStep.finishing, + 'WAITING_FOR_LENS_REMOUNT' => CleaningStep.remountLens, + _ => CleaningStep.off, + }; + + static String instruction(CleaningStep s) => switch (s) { + CleaningStep.off => 'The camera vibrates its sensor to shake dust off. You will remove the lens and refit it afterwards.', + CleaningStep.entering => 'Preparing the camera…', + CleaningStep.removeLens => 'Remove the lens and hold the camera with the sensor facing down.', + CleaningStep.ready => 'Keep the sensor facing down and start cleaning.', + CleaningStep.cleaning => 'Cleaning… keep the sensor facing down.', + CleaningStep.finishing => 'Finishing…', + CleaningStep.remountLens => 'Refit the lens, then finish.', + }; +} + +/// Sensor cleaning runs while the camera is in its cleaning mode, and ends +/// well once the pilot has left the mode ([exiting]) and the camera is idle. +class SensorCleaningRun extends CalibrationRun { + CleaningStep step = CleaningStep.off; + bool _exiting = false; + + void exiting() => _exiting = true; + + @override + void begin() { + super.begin(); + _exiting = false; + } + + @override + void observe(FlightModel m) { + step = SensorCleaning.step(m.calibration.sensorCleaning); + fold(working: step != CleaningStep.off && !_exiting, ok: _exiting && step == CleaningStep.off); + } +} + +/// Propeller calibration: the SDK has a start and a stop but reports no +/// progress, so the run lasts until the pilot ends it. +class PropellerRun extends CalibrationRun { + @override + void observe(FlightModel m) {} +} diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index aea3896..f99e450 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -112,6 +112,27 @@ abstract final class HudFeatures { static final HudFeature linkWifi = HudFeature( 'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi); + // ── Calibration & maintenance wizards (plan P6b) ────────────────────────── + // Runtime support (`CalibrationState`) can still drop a wizard the profile allows. + static final HudFeature calibrationCompass = HudFeature( + 'calibration.compass', 'Compass calibration', 'Calibration', (ProductProfile p) => p.isAircraft); + static final HudFeature calibrationImu = HudFeature( + 'calibration.imu', 'IMU calibration', 'Calibration', (ProductProfile p) => p.isAircraft); + static final HudFeature calibrationRc = HudFeature( + 'calibration.rc', 'Remote controller calibration', 'Calibration', (ProductProfile p) => p.remoteController != null); + static final HudFeature gimbalAdjust = HudFeature( + 'calibration.gimbalAdjust', 'Adjust gimbal (horizon)', 'Calibration', (ProductProfile p) => p.hasGimbal); + static final HudFeature calibrationLens = HudFeature('calibration.lens', 'Lens calibration', 'Calibration', + (ProductProfile p) => p.camera?.interchangeableLensSupported == true); + static final HudFeature sensorCleaning = HudFeature('calibration.sensorCleaning', 'Sensor cleaning', 'Calibration', + (ProductProfile p) => p.camera?.interchangeableLensSupported == true); + static final HudFeature calibrationGravityCenter = HudFeature( + 'calibration.gravityCenter', 'Center of gravity calibration', 'Calibration', (ProductProfile p) => p.isAircraft); + static final HudFeature calibrationPropeller = HudFeature('calibration.propeller', 'Propeller calibration', 'Calibration', + (ProductProfile p) => p.flightController?.propellerCalibrationSupported == true); + static final HudFeature visionSensorGuide = HudFeature( + 'calibration.vision', 'Vision sensor calibration (guide)', 'Calibration', (ProductProfile p) => p.hasVps); + // ── Intelligent flight modes ────────────────────────────────────────────── static final List modes = [ for (final IntelligentMode m in IntelligentMode.values) @@ -155,6 +176,15 @@ abstract final class HudFeatures { linkOcuSync, linkLightbridge, linkWifi, + calibrationCompass, + calibrationImu, + calibrationRc, + gimbalAdjust, + calibrationLens, + sensorCleaning, + calibrationGravityCenter, + calibrationPropeller, + visionSensorGuide, ...modes, ]; diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index 3d00b93..b869c2a 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../dji_service.dart'; import '../flight_model.dart'; import 'action_rail_state.dart'; +import 'calibration.dart'; import 'flight_status.dart'; import 'hud_geometry.dart'; import 'state/aircraft_state.dart'; @@ -65,6 +66,16 @@ class SafetyGateRefused implements Exception { String toString() => verdict.blockers.map((GateCheck c) => c.message).join(' '); } +/// Thrown by [SafetyGate.runMaintenance] when a calibration may not start. +class MaintenanceRefused implements Exception { + const MaintenanceRefused(this.checks); + + final List checks; + + @override + String toString() => checks.where((GateCheck c) => c.blocks).map((GateCheck c) => c.message).join(' '); +} + /// The one place every aircraft-moving command passes through (plan §1.5, P3). /// /// [check] grades the preconditions — connected, flight state, GPS, battery, @@ -196,6 +207,61 @@ class SafetyGate { } } + // ── Calibration & maintenance (plan P6b) ──────────────────────────────────── + + /// Below this charge an IMU calibration (5–10 minutes) is discouraged. + static const int imuMinBattery = 30; + + /// Preconditions for starting [kind]. Everything except the center-of-gravity + /// calibration (done in a hover) and the gimbal trim needs the aircraft on + /// the ground with the motors off. + List checkMaintenance(CalibrationKind kind) { + final List checks = []; + if (kind == CalibrationKind.visionSensors) return checks; + if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.')); + if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.')); + switch (kind) { + case CalibrationKind.gimbalAdjust: + case CalibrationKind.visionSensors: + break; + case CalibrationKind.gravityCenter: + if (_a.isFlying != true) checks.add(const GateCheck.block('Take off and hover first: the aircraft measures its balance in the air.')); + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(const GateCheck.block('GPS too weak to hold a steady hover.')); + if (_a.windWarning != null && _a.windWarning != 'LEVEL_0') checks.add(const GateCheck.warn('Strong wind: the result may be off.')); + default: + if (_a.isFlying == true) { + checks.add(const GateCheck.block('Land first.')); + } else if (_a.motorsOn == true) { + checks.add(const GateCheck.block('Stop the motors first.')); + } + } + switch (kind) { + case CalibrationKind.imu: + final int? percent = model.battery.percent; + if (percent != null && percent < imuMinBattery) checks.add(GateCheck.warn('Battery at $percent%: calibration takes up to 10 minutes.')); + if (_a.imuPreheating == true) checks.add(const GateCheck.warn('The IMU is warming up; calibration waits for it.')); + case CalibrationKind.compass: + if (_a.compassCalibrating == true) checks.add(const GateCheck.warn('A compass calibration is already running; starting restarts it.')); + case CalibrationKind.gimbal: + if (model.gimbal.calibrating) checks.add(const GateCheck.block('The gimbal is already calibrating.')); + case CalibrationKind.propeller: + checks.add(const GateCheck.warn('The motors spin during calibration. Keep clear of the propellers.')); + case CalibrationKind.sensorCleaning: + case CalibrationKind.lens: + if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.')); + default: + break; + } + return checks; + } + + /// Re-checks [kind] and runs [start] — the wizard's start command. + Future runMaintenance(CalibrationKind kind, Future Function() start) async { + final List checks = checkMaintenance(kind); + if (checks.any((GateCheck c) => c.blocks)) throw MaintenanceRefused(checks); + await start(); + } + /// True when the pilot stands far enough from the recorded home point that /// RTH should ask whether to come back to them instead (GO 4's RTH target). bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance; diff --git a/Fly App/lib/fly/state/calibration_state.dart b/Fly App/lib/fly/state/calibration_state.dart new file mode 100644 index 0000000..c838fe1 --- /dev/null +++ b/Fly App/lib/fly/state/calibration_state.dart @@ -0,0 +1,138 @@ +import 'package:flutter/foundation.dart'; + +import 'parse.dart'; + +/// One IMU unit's calibration report. +@immutable +class ImuUnit { + const ImuUnit({required this.index, this.state, this.progress, this.gyroscope, this.accelerometer, this.connected}); + + factory ImuUnit.fromMap(Map m) => ImuUnit( + index: asInt(m['index']) ?? 0, + state: asString(m['state']), + progress: asInt(m['progress']), + gyroscope: asString(m['gyroscope']), + accelerometer: asString(m['accelerometer']), + connected: asBool(m['connected']), + ); + + final int index; + + /// `NONE` / `CALIBRATING` / `SUCCESSFUL` / `FAILED` / `UNKNOWN`. + final String? state; + final int? progress; // 0..100 + /// `SensorState` names: `NORMAL_BIAS`, `WARMING_UP`, `IN_MOTION`, `LARGE_BIAS` … + final String? gyroscope; + final String? accelerometer; + final bool? connected; +} + +/// The RC wizard's live sticks (−660…660) and dials. +@immutable +class RcHardware { + const RcHardware({this.lx, this.ly, this.rx, this.ry, this.leftDial, this.rightDial}); + + factory RcHardware.fromMap(Map m) => RcHardware( + lx: asInt(m['lx']), + ly: asInt(m['ly']), + rx: asInt(m['rx']), + ry: asInt(m['ry']), + leftDial: asInt(m['leftDial']), + rightDial: asInt(m['rightDial']), + ); + + final int? lx, ly, rx, ry, leftDial, rightDial; +} + +/// The calibration and maintenance wizards' state (plan P6b), from the +/// `calibration` group of the telemetry tick (`CalibrationBridge.kt`). The +/// compass reports through [AircraftState] and the gimbal through +/// [GimbalState]; everything else is here. +class CalibrationState extends ChangeNotifier { + // ── What the aircraft offers; null = not known yet ───────────────────────── + int? compassCount; + int? imuCount; + bool? gravityCenterSupported; + bool? propellerSupported; + bool? lensSupported; + bool? sensorCleaningSupported; + bool? rcSupported; + + // ── IMU ──────────────────────────────────────────────────────────────────── + List imuUnits = const []; + + /// The aircraft wants to be turned onto several sides. + bool? imuMultiSide; + + /// `CALIBRATING` / `COMPLETED` / `UNKNOWN` for the multi-side sequence. + String? imuOrientationState; + + /// `CalibrationOrientation` names: `BOTTOM_DOWN`, `NOSE_DOWN` … + List imuToCalibrate = const []; + List imuCalibrated = const []; + + // ── Gravity centre / lens / sensor cleaning ─────────────────────────────── + /// `NONE` / `CALCULATING` / `SUCCESSFUL` / `FAILED`. + String? gravityCenterState; + String? gravityCenterError; + + /// `NOT_CALIBRATE` / `CALIBRATING` / `CALIBRATED` / `WAITING_FOR_CALIBRATE`. + String? lensState; + + /// `IDLE` / `COMPLETED` / `FAIL_NORMAL` / `DATA_ERROR`. + String? lensResult; + + /// `SensorCleaningState` name: `IDLE`, `INITIATING`, `WAITING_FOR_LENS_REMOVAL` … + String? sensorCleaning; + + // ── Remote controller ───────────────────────────────────────────────────── + /// `NORMAL` / `MIDDLE` / `LIMITS` / `QUIT` / `TIMEOUT` / `DISCONNECT`. + String? rcState; + int? rcSegments; + + /// Per-axis calibration status (`A`…`H`) as the remote reports it. + Map rcAxes = const {}; + + /// Only while the RC wizard watches the hardware. + RcHardware? rcHardware; + + void apply(Map? g) { + final Map support = asMap(g?['support']) ?? const {}; + compassCount = asInt(support['compassCount']); + imuCount = asInt(support['imuCount']); + gravityCenterSupported = asBool(support['gravityCenter']); + propellerSupported = asBool(support['propeller']); + lensSupported = asBool(support['lens']); + sensorCleaningSupported = asBool(support['sensorCleaning']); + rcSupported = asBool(support['rc']); + + final Map? imu = asMap(g?['imu']); + imuUnits = [for (final Map u in asMapList(imu?['units'])) ImuUnit.fromMap(u)]; + imuMultiSide = asBool(imu?['multiSide']); + imuOrientationState = asString(imu?['orientationState']); + imuToCalibrate = _strings(imu?['toCalibrate']); + imuCalibrated = _strings(imu?['calibrated']); + + final Map? gc = asMap(g?['gravityCenter']); + gravityCenterState = asString(gc?['state']); + gravityCenterError = asString(gc?['error']); + + final Map? lens = asMap(g?['lens']); + lensState = asString(lens?['state']); + lensResult = asString(lens?['result']); + + sensorCleaning = asString(g?['sensorCleaning']); + + final Map? rc = asMap(g?['rc']); + rcState = asString(rc?['state']); + rcSegments = asInt(rc?['segments']); + rcAxes = asMap(rc?['axes']) ?? const {}; + final Map? hw = asMap(g?['rcHardware']); + rcHardware = hw == null ? null : RcHardware.fromMap(hw); + notifyListeners(); + } + + void clear() => apply(null); + + static List _strings(Object? v) => v is List ? [for (final Object? e in v) if (e is String) e] : const []; +} diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index 045fd87..98db415 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -8,7 +8,7 @@ import 'state/parse.dart'; /// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight /// controller's flat keys plus one nested group per subsystem (`battery`, /// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`, -/// `gimbalSettings`, `vision`). It arrives at +/// `gimbalSettings`, `vision`, `calibration`). It arrives at /// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz /// refreshes, so each group is compared with the last one seen and its /// sub-model only notified when it actually moved. A group missing from the @@ -20,7 +20,7 @@ class TelemetryReducer { final Map _lastGroups = {}; - static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision']; + static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration']; /// Applies [event] if it is one of the telemetry types; returns whether it was. bool apply(Map event) { @@ -89,5 +89,6 @@ class TelemetryReducer { } if (changed.contains('gimbalSettings')) model.gimbal.applySettings(asMap(t['gimbalSettings'])); if (changed.contains('vision')) model.vision.apply(asMap(t['vision'])); + if (changed.contains('calibration')) model.calibration.apply(asMap(t['calibration'])); } } diff --git a/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart index 9d4b6a7..25a968f 100644 --- a/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart +++ b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart @@ -4,11 +4,13 @@ import 'package:flutter/services.dart'; import '../../../dji_service.dart'; import '../../../flight_model.dart'; import '../../../fly/aircraft_checklist.dart'; +import '../../../fly/calibration.dart'; import '../../../fly/state/parse.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; import '../dialogs/slide_to_confirm.dart'; import '../hud_kit.dart'; +import 'settings/calibration/calibration_hub.dart'; /// Opens the Aircraft Status sheet over the right of the flight screen. /// @@ -94,50 +96,38 @@ class _AircraftStatusSheetState extends State { } Future _act(ChecklistAction action) async { - final ({SlideConfirmSpec? spec, String label, Future Function() run}) a = switch (action) { - ChecklistAction.calibrateCompass => ( - label: 'Compass calibration', - run: widget.dji.startCompassCalibration, - spec: const SlideConfirmSpec( - icon: 'compass', - title: 'Calibrate compass', - description: 'Stand away from metal, cars and power lines. Hold the aircraft level and turn it a full circle, ' - 'then hold it nose-down and turn again. The Compass row shows each step.', - sliderLabel: 'Slide to start', - ), - ), - ChecklistAction.stopCompassCalibration => (label: 'Stop calibration', run: widget.dji.stopCompassCalibration, spec: null), - ChecklistAction.calibrateImu => ( - label: 'IMU calibration', - run: widget.dji.startIMUCalibration, - spec: const SlideConfirmSpec( - icon: 'gauge', - title: 'Calibrate IMU', - description: 'Put the aircraft on a flat, level surface with the motors off and do not touch it. ' - 'Calibration takes 5–10 minutes; some aircraft ask to be turned onto each side.', - sliderLabel: 'Slide to start', - ), - ), - ChecklistAction.formatSd => ( - label: 'Format SD', - run: widget.dji.formatStorage, - spec: const SlideConfirmSpec( + switch (action) { + // Calibrations are guided wizards (plan P6b). + case ChecklistAction.calibrateCompass: + return _wizard(CalibrationKind.compass); + case ChecklistAction.calibrateImu: + return _wizard(CalibrationKind.imu); + case ChecklistAction.stopCompassCalibration: + return _send('Stop calibration', widget.dji.stopCompassCalibration); + case ChecklistAction.formatSd: + final SlideResult? ok = await showSlideToConfirm( + context, + const SlideConfirmSpec( icon: 'sdcard', title: 'Format SD card', description: 'Every photo and video on the card is erased. This cannot be undone.', sliderLabel: 'Slide to erase', color: HudColors.danger, ), - ), - }; - if (a.spec != null) { - final SlideResult? ok = await showSlideToConfirm(context, a.spec!); - if (ok == null) return; + ); + if (ok == null) return; + return _send('Format SD', widget.dji.formatStorage); } + } + + Future _wizard(CalibrationKind kind) => + openCalibration(context, kind, model: widget.model, dji: widget.dji, onError: widget.onError); + + Future _send(String label, Future Function() run) async { try { - await a.run(); + await run(); } catch (e) { - widget.onError(a.label, e); + widget.onError(label, e); } } diff --git a/Fly App/lib/ui/fly/panels/gimbal_sheet.dart b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart index b08c9ab..0633fe6 100644 --- a/Fly App/lib/ui/fly/panels/gimbal_sheet.dart +++ b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../dji_service.dart'; import '../../../flight_model.dart'; +import '../../../fly/calibration.dart'; import '../../../fly/gimbal_control.dart'; import '../../../fly/hud_features.dart'; import '../../../fly/product_profile.dart'; @@ -13,6 +14,7 @@ import '../dialogs/slide_to_confirm.dart'; import '../hud_kit.dart'; import 'camera_menu/camera_rows.dart'; import 'camera_write.dart'; +import 'settings/calibration/calibration_hub.dart'; /// Opens the gimbal settings (plan P5) as a right-side glass sheet over the /// live view, like the camera menu. @@ -128,20 +130,8 @@ class GimbalSettingsList extends StatelessWidget { } } - Future _calibrate(BuildContext context) async { - final SlideResult? ok = await showSlideToConfirm( - context, - const SlideConfirmSpec( - icon: 'gimbal', - title: 'Auto calibrate gimbal', - description: 'Put the aircraft on a flat, level surface with the motors off and the gimbal free to move. ' - 'Do not touch it until calibration finishes (about 30 seconds).', - sliderLabel: 'Slide to start', - ), - ); - if (ok == null) return; - await _run('Gimbal calibration', dji.startGimbalCalibration); - } + Future _open(BuildContext context, CalibrationKind kind) => + openCalibration(context, kind, model: model, dji: dji, onError: onError); Future _resetAdvanced(BuildContext context) async { final SlideResult? ok = await showSlideToConfirm( @@ -241,6 +231,16 @@ class GimbalSettingsList extends StatelessWidget { canUp: true, onStep: (int dir) => _run('Roll adjust', () => dji.fineTuneGimbal('roll', dir * GimbalActions.fineTuneStep)), ), + Requires( + HudFeatures.gimbalAdjust, + child: ButtonTile( + id: 'adjustGimbal', + label: 'Adjust gimbal', + hint: 'Roll, pitch and yaw against a horizon line on the video', + button: 'Adjust', + onTap: () => _open(context, CalibrationKind.gimbalAdjust), + ), + ), ]), ), // ── Calibration ─────────────────────────────────────────────────── @@ -313,7 +313,7 @@ class GimbalSettingsList extends StatelessWidget { label: 'Auto calibrate gimbal', button: 'Calibrate', hint: hint, - onTap: blocked == null ? () => _calibrate(context) : null, + onTap: blocked == null ? () => _open(context, CalibrationKind.gimbal) : null, ); } diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/calibration_hub.dart b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_hub.dart new file mode 100644 index 0000000..de90c7d --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_hub.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; + +import '../../../../../dji_service.dart'; +import '../../../../../flight_model.dart'; +import '../../../../../fly/calibration.dart'; +import '../../../../../fly/gimbal_control.dart'; +import '../../../../../fly/hud_features.dart'; +import '../../../../../fly/safety_gate.dart'; +import '../../../../../theme.dart'; +import '../../../../pv_icons.dart'; +import '../../../hud_kit.dart'; +import '../../camera_menu/camera_rows.dart'; +import 'illustrations.dart'; +import 'wizards.dart'; + +/// Opens the [kind] wizard (plan P6b). Every wizard is a full-screen page that +/// leaves the live view, except the gimbal adjust step, which needs the video: +/// it closes any sheet or hub above the flight screen and lays a horizon line +/// over it instead. +Future openCalibration( + BuildContext context, + CalibrationKind kind, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, +}) async { + final NavigatorState nav = Navigator.of(context); + if (kind == CalibrationKind.gimbalAdjust) { + nav.popUntil((Route r) => r is! PopupRoute && r.settings.name != CalibrationHubPage.routeName); + await nav.push(PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + settings: const RouteSettings(name: GimbalAdjustOverlay.routeName), + pageBuilder: (BuildContext context, _, __) => GimbalAdjustOverlay(model: model, dji: dji, onError: onError), + transitionsBuilder: (BuildContext context, Animation a, _, Widget child) => FadeTransition(opacity: a, child: child), + )); + return; + } + void adjust() { + openCalibration(nav.context, CalibrationKind.gimbalAdjust, model: model, dji: dji, onError: onError); + } + + final Widget page = switch (kind) { + CalibrationKind.compass => CompassWizard(model: model, dji: dji, onError: onError), + CalibrationKind.imu => ImuWizard(model: model, dji: dji, onError: onError), + CalibrationKind.rc => RcWizard(model: model, dji: dji, onError: onError), + CalibrationKind.gimbal => GimbalCalibrationWizard(model: model, dji: dji, onError: onError, onAdjust: adjust), + CalibrationKind.lens => LensWizard(model: model, dji: dji, onError: onError), + CalibrationKind.sensorCleaning => SensorCleaningWizard(model: model, dji: dji, onError: onError), + CalibrationKind.gravityCenter => GravityCenterWizard(model: model, dji: dji, onError: onError), + CalibrationKind.propeller => PropellerWizard(model: model, dji: dji, onError: onError), + CalibrationKind.visionSensors => const VisionSensorGuide(), + CalibrationKind.gimbalAdjust => throw StateError('handled above'), + }; + await nav.push(MaterialPageRoute(builder: (_) => page)); +} + +/// Opens the list of every wizard this aircraft offers. +Future showCalibrationHub( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, +}) => + Navigator.of(context).push(MaterialPageRoute( + settings: const RouteSettings(name: CalibrationHubPage.routeName), + builder: (_) => CalibrationHubPage(model: model, dji: dji, onError: onError), + )); + +/// Settings → Safety → "Calibration & maintenance": every wizard the +/// connected aircraft offers, grouped like GO 4, with a one-line status each. +class CalibrationHubPage extends StatelessWidget { + const CalibrationHubPage({super.key, required this.model, required this.dji, required this.onError}); + + static const String routeName = 'calibrationHub'; + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + + static const List<(String, List)> _groups = <(String, List)>[ + ('AIRCRAFT', [CalibrationKind.compass, CalibrationKind.imu, CalibrationKind.gravityCenter, CalibrationKind.propeller, CalibrationKind.visionSensors]), + ('REMOTE CONTROLLER', [CalibrationKind.rc]), + ('GIMBAL & CAMERA', [CalibrationKind.gimbal, CalibrationKind.gimbalAdjust, CalibrationKind.lens, CalibrationKind.sensorCleaning]), + ]; + + String? _status(CalibrationKind k) => switch (k) { + CalibrationKind.compass => model.aircraft.compassHasError == true + ? 'Error — calibrate' + : (model.aircraft.compassCalibrating == true ? 'Calibrating…' : null), + CalibrationKind.imu => model.aircraft.imuPreheating == true ? 'Warming up' : null, + CalibrationKind.gimbal => model.gimbal.calibrating ? 'Calibrating… ${model.gimbal.calibrationProgress ?? 0}%' : null, + CalibrationKind.gravityCenter => 'In a hover, after changing the payload', + CalibrationKind.visionSensors => 'Needs DJI Assistant 2 on a computer', + _ => null, + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF070D1A), + body: SafeArea( + child: ProductProfileScope( + profile: model.profile, + child: ListenableBuilder( + listenable: Listenable.merge([model.calibration, model.aircraft, model.gimbal]), + builder: (BuildContext context, _) { + final Set offered = CalibrationKinds.available(model.profile, model.calibration).toSet(); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Container( + height: 44, + padding: const EdgeInsets.only(left: 4, right: 16), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))), + child: Row(children: [ + IconButton( + key: const ValueKey('hub-back'), + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('chevronLeft', size: 18, color: Glass.ink), + ), + Expanded( + child: Text('Calibration & maintenance', style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700)), + ), + if (!model.connected) Text('NOT CONNECTED', style: HudText.label.copyWith(color: HudColors.caution)), + ]), + ), + Expanded( + child: ListView(padding: const EdgeInsets.only(bottom: 16), children: [ + for (final (String title, List kinds) in _groups) + if (kinds.any(offered.contains)) ...[ + Padding(padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), child: Text(title, style: HudText.label)), + for (final CalibrationKind k in kinds) + if (offered.contains(k)) + _HubRow( + kind: k, + status: _status(k), + onTap: () => openCalibration(context, k, model: model, dji: dji, onError: onError), + ), + ], + ]), + ), + ]); + }, + ), + ), + ), + ); + } +} + +class _HubRow extends StatelessWidget { + const _HubRow({required this.kind, required this.onTap, this.status}); + + final CalibrationKind kind; + final String? status; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + key: ValueKey('hub-${kind.name}'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row(children: [ + PVIcon(CalibrationKinds.icon(kind), size: 18, color: Glass.ink), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(CalibrationKinds.title(kind), style: HudText.sans.copyWith(fontSize: 13)), + if (status != null) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text(status!, style: HudText.sans.copyWith(fontSize: 11, fontWeight: FontWeight.w500, color: HudText.dim)), + ), + ]), + ), + const PVIcon('chevronRight', size: 16, color: HudText.dim), + ]), + ), + ); + } +} + +/// The gimbal adjust step: a horizon reference over the live video and +/// fine-tune steppers for roll (and pitch / yaw where the gimbal moves on +/// them), in a small glass panel so most of the picture stays visible. +class GimbalAdjustOverlay extends StatelessWidget { + const GimbalAdjustOverlay({super.key, required this.model, required this.dji, required this.onError}); + + static const String routeName = 'gimbalAdjust'; + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + + Future _tune(String axis, int dir) async { + try { + await dji.fineTuneGimbal(axis, dir * GimbalActions.fineTuneStep); + } catch (e) { + onError('Adjust gimbal $axis', e); + } + } + + static String _deg(double? v) => v == null ? '—' : '${v > 0 ? '+' : ''}${v.toStringAsFixed(1)}°'; + + @override + Widget build(BuildContext context) { + final bool pitch = model.profile.gimbal?.pitchAdjustable ?? true; + final bool yaw = model.profile.gimbal?.yawAdjustable ?? false; + return Material( + type: MaterialType.transparency, + child: Stack(fit: StackFit.expand, children: [ + const IgnorePointer(child: CustomPaint(painter: HorizonPainter())), + Positioned( + left: 16, + top: 12, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8)), + child: Text('Line the horizon in the video up with the dashed line', style: HudText.sans), + ), + ), + ), + Positioned( + right: 12, + top: 12, + bottom: 12, + width: 300, + child: SafeArea( + left: false, + child: Container( + decoration: BoxDecoration(color: const Color(0xE60B1426), borderRadius: BorderRadius.circular(12)), + child: ListenableBuilder( + listenable: Listenable.merge([model.gimbal, model.aircraft]), + builder: (BuildContext context, _) { + final List checks = SafetyGate(model: model, dji: dji).checkMaintenance(CalibrationKind.gimbalAdjust); + final bool ok = checks.every((GateCheck c) => !c.blocks); + return ListView(padding: const EdgeInsets.symmetric(vertical: 6), children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), + child: Row(children: [ + Expanded(child: Text('Adjust gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + TextButton( + key: const ValueKey('adjust-done'), + onPressed: () => Navigator.of(context).pop(), + child: Text('Done', style: HudText.sans.copyWith(color: Glass.accent, fontSize: 13)), + ), + ]), + ), + for (final GateCheck c in checks) + Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 4), + child: Text(c.message, style: HudText.sans.copyWith(color: c.blocks ? HudColors.danger : HudColors.caution)), + ), + StepperTile( + id: 'adjustRoll', + label: 'Roll', + hint: 'Tilt of the horizon', + shown: _deg(model.gimbal.rollFineTune), + canDown: ok, + canUp: ok, + onStep: (int dir) => _tune('roll', dir), + ), + if (pitch) + StepperTile( + id: 'adjustPitch', + label: 'Pitch', + hint: 'Level point when centred', + shown: _deg(model.gimbal.pitchFineTune), + canDown: ok, + canUp: ok, + onStep: (int dir) => _tune('pitch', dir), + ), + if (yaw) + StepperTile( + id: 'adjustYaw', + label: 'Yaw', + hint: 'Straight ahead when centred', + shown: _deg(model.gimbal.yawFineTune), + canDown: ok, + canUp: ok, + onStep: (int dir) => _tune('yaw', dir), + ), + ]); + }, + ), + ), + ), + ), + ]), + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart new file mode 100644 index 0000000..a2a2b31 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../../dji_service.dart'; +import '../../../../../flight_model.dart'; +import '../../../../../fly/calibration.dart'; +import '../../../../../fly/safety_gate.dart'; +import '../../../../../theme.dart'; +import '../../../../pv_icons.dart'; +import '../../../hud_kit.dart'; + +/// A wizard page: the shared inputs of every calibration flow. +abstract class CalibrationWizard extends StatefulWidget { + const CalibrationWizard({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; +} + +/// The plumbing every wizard shares: it folds the live model into [run], +/// sends its start command through the [SafetyGate], and on the way out +/// ([close], or the system back) [abort]s whatever is half done, so a +/// cancelled calibration leaves the aircraft in its normal state. +abstract class WizardState extends State { + CalibrationKind get kind; + CalibrationRun get run; + + late final SafetyGate gate = SafetyGate(model: widget.model, dji: widget.dji); + + FlightModel get model => widget.model; + DjiService get dji => widget.dji; + + /// Leaving mid-run asks first unless this is false (IMU: nothing to stop). + bool get canAbort => true; + + @override + void initState() { + super.initState(); + model.addListener(_onModel); + } + + @override + void dispose() { + model.removeListener(_onModel); + super.dispose(); + } + + void _onModel() { + if (!mounted) return; + setState(() => run.observe(model)); + } + + /// The gate's verdict right now, for the intro page. + List get checks => gate.checkMaintenance(kind); + bool get blocked => checks.any((GateCheck c) => c.blocks); + + /// Sends [send] through the gate; a refusal or SDK error fails the run. + Future start(Future Function() send) async { + HapticFeedback.selectionClick(); + setState(run.begin); + try { + await gate.runMaintenance(kind, send); + if (mounted) setState(() => run.observe(model)); + } catch (e) { + if (mounted) setState(() => run.startFailed(errorText(e))); + } + } + + /// Runs a step command that is not the start; errors go to the snackbar. + Future send(String label, Future Function() call) async { + try { + await call(); + return true; + } catch (e) { + widget.onError(label, e); + return false; + } + } + + /// Puts the aircraft back to normal. Called on the way out while [run] is active. + Future abort() async {} + + Future close() async { + if (run.active && canAbort) { + final bool? stop = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + backgroundColor: const Color(0xFF0B1426), + title: Text('Stop ${CalibrationKinds.title(kind).toLowerCase()}?', style: HudText.sans.copyWith(fontSize: 15)), + content: Text('The aircraft goes back to its previous calibration.', style: HudText.sans.copyWith(color: HudText.dim)), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Keep going')), + TextButton( + key: const ValueKey('wizard-confirm-stop'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Stop', style: TextStyle(color: HudColors.danger)), + ), + ], + ), + ); + if (stop != true) return; + } + if (run.active) await abort(); + if (mounted) Navigator.of(context).pop(); + } + + static String errorText(Object e) => switch (e) { + PlatformException(:final String? message, :final String code) => message ?? code, + _ => e.toString(), + }; +} + +/// One button along the bottom of a wizard. +class WizardAction { + const WizardAction(this.label, this.onTap, {this.primary = false, this.danger = false, required this.id}); + + final String id; + final String label; + final VoidCallback? onTap; + final bool primary; + final bool danger; +} + +/// The full-screen frame of every wizard: the illustration on the left, the +/// step, instructions, gate checks, progress and buttons on the right (stacked +/// in portrait). Leaves the live view on purpose — GO 4 does the same. +class CalibrationScaffold extends StatelessWidget { + const CalibrationScaffold({ + super.key, + required this.title, + required this.onClose, + required this.illustration, + required this.heading, + required this.body, + this.stepLabel, + this.checks = const [], + this.detail, + this.progress, + this.actions = const [], + this.tone, + }); + + final String title; + final VoidCallback onClose; + final Widget illustration; + final String? stepLabel; + final String heading; + final String body; + final List checks; + final Widget? detail; + + /// 0..1; null shows no bar, NaN an indeterminate one. + final double? progress; + final List actions; + + /// Heading colour: success / failure. + final Color? tone; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, _) { + if (!didPop) onClose(); + }, + child: Scaffold( + backgroundColor: const Color(0xFF070D1A), + body: SafeArea( + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final bool wide = c.maxWidth > c.maxHeight; + final Widget art = Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration(color: const Color(0xFF0B1426), borderRadius: BorderRadius.circular(14)), + child: Center(child: illustration), + ); + final Widget text = _text(context); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _titleBar(), + Expanded( + child: wide + ? Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Expanded(flex: 5, child: art), + Expanded(flex: 6, child: text), + ]) + : Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SizedBox(height: c.maxHeight * 0.38, child: art), + Expanded(child: text), + ]), + ), + ]); + }), + ), + ), + ); + } + + Widget _titleBar() => Container( + height: 44, + padding: const EdgeInsets.only(left: 16, right: 4), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))), + child: Row(children: [ + Expanded(child: Text(title, style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700))), + IconButton( + key: const ValueKey('wizard-close'), + onPressed: onClose, + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ); + + Widget _text(BuildContext context) { + final double? p = progress; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 20, 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Expanded( + child: ListView(padding: EdgeInsets.zero, children: [ + if (stepLabel != null) Text(stepLabel!, key: const ValueKey('wizard-step'), style: HudText.label), + if (stepLabel != null) const SizedBox(height: 6), + Text( + heading, + key: const ValueKey('wizard-heading'), + style: HudText.sans.copyWith(fontSize: 18, fontWeight: FontWeight.w700, color: tone ?? Glass.ink, height: 1.2), + ), + const SizedBox(height: 8), + Text(body, style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w500, color: HudText.dim, height: 1.4)), + for (final GateCheck c in [...checks.where((GateCheck c) => c.blocks), ...checks.where((GateCheck c) => !c.blocks)]) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + PVIcon('alert', size: 14, color: c.blocks ? HudColors.danger : HudColors.caution), + const SizedBox(width: 6), + Expanded( + child: Text(c.message, + style: HudText.sans.copyWith(fontSize: 12, color: c.blocks ? HudColors.danger : HudColors.caution, height: 1.3)), + ), + ]), + ), + if (detail != null) Padding(padding: const EdgeInsets.only(top: 12), child: detail), + ]), + ), + if (p != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + key: const ValueKey('wizard-progress'), + value: p.isNaN ? null : p.clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: Glass.hairline, + color: Glass.accent, + ), + ), + ), + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + for (final WizardAction a in actions) + Padding(padding: const EdgeInsets.only(left: 10), child: _button(a)), + ]), + ), + ]), + ); + } + + static Widget _button(WizardAction a) { + final Color color = a.danger ? HudColors.danger : Glass.accent; + final Text label = Text(a.label, style: const TextStyle(fontFamily: PV.fontSans, fontWeight: FontWeight.w700)); + return a.primary + ? FilledButton( + key: ValueKey('wizard-${a.id}'), + onPressed: a.onTap, + style: FilledButton.styleFrom( + backgroundColor: color, + disabledBackgroundColor: Glass.hairline, + disabledForegroundColor: HudText.dim, + minimumSize: const Size(120, 42), + ), + child: label, + ) + : OutlinedButton( + key: ValueKey('wizard-${a.id}'), + onPressed: a.onTap, + style: OutlinedButton.styleFrom(foregroundColor: Glass.ink, side: BorderSide(color: color), minimumSize: const Size(100, 42)), + child: label, + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/illustrations.dart b/Fly App/lib/ui/fly/panels/settings/calibration/illustrations.dart new file mode 100644 index 0000000..86a803f --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/calibration/illustrations.dart @@ -0,0 +1,416 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../../fly/calibration.dart'; +import '../../../../../fly/state/calibration_state.dart'; +import '../../../../../theme.dart'; +import '../../../../pv_icons.dart'; +import '../../../hud_kit.dart'; + +const Color _line = Glass.ink; +const Color _soft = Color(0x66EAF0FA); + +/// A big icon in a ring — the illustration for wizards without a drawing, +/// with the ring filling as [progress] (0..1) grows. +class IconIllustration extends StatelessWidget { + const IconIllustration(this.icon, {super.key, this.progress, this.color = Glass.accent}); + + final String icon; + final double? progress; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 150, + child: Stack(alignment: Alignment.center, children: [ + SizedBox.expand( + child: CircularProgressIndicator( + value: progress ?? 0, + strokeWidth: 4, + backgroundColor: Glass.hairline, + color: color, + ), + ), + PVIcon(icon, size: 64, stroke: 1.4, color: Glass.ink), + ]), + ); + } +} + +/// The compass dance: level spin, then nose-down spin, with a turning arrow. +class CompassIllustration extends StatefulWidget { + const CompassIllustration({super.key, required this.step, this.animate = true}); + + final CompassStep step; + final bool animate; + + @override + State createState() => _CompassIllustrationState(); +} + +class _CompassIllustrationState extends State with SingleTickerProviderStateMixin { + late final AnimationController _spin = AnimationController(vsync: this, duration: const Duration(seconds: 4)); + + @override + void initState() { + super.initState(); + if (widget.animate) _spin.repeat(); + } + + @override + void dispose() { + _spin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + SizedBox( + width: 220, + height: 180, + child: AnimatedBuilder( + animation: _spin, + builder: (BuildContext context, _) => CustomPaint(painter: _CompassPainter(widget.step, _spin.value)), + ), + ), + const SizedBox(height: 8), + Text(widget.step == CompassStep.horizontal ? 'Level · turn 360°' : 'Nose down · turn 360°', style: HudText.label), + ]); + } +} + +class _CompassPainter extends CustomPainter { + _CompassPainter(this.step, this.t); + + final CompassStep step; + final double t; + + @override + void paint(Canvas canvas, Size size) { + final Offset c = size.center(Offset.zero); + final Paint ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Glass.accent; + final Rect oval = Rect.fromCenter(center: c + const Offset(0, 40), width: 190, height: 50); + canvas.drawArc(oval, 0, math.pi * 1.7, false, ring); + // The arrowhead travels round the ellipse. + final double a = t * 2 * math.pi; + final Offset tip = Offset(oval.center.dx + oval.width / 2 * math.cos(a), oval.center.dy + oval.height / 2 * math.sin(a)); + canvas.drawCircle(tip, 5, Paint()..color = Glass.accent); + + canvas.save(); + canvas.translate(c.dx, c.dy); + if (step == CompassStep.horizontal) { + // Seen from slightly above: squash vertically, spin about the vertical axis. + canvas.scale(1, 0.45); + canvas.rotate(a); + AircraftGlyph.top(canvas, 60); + } else { + // Nose down: the body stands on end, its width breathing as it spins. + canvas.scale(math.cos(a).abs().clamp(0.25, 1.0), 1); + canvas.rotate(math.pi / 2); + AircraftGlyph.side(canvas, 64); + } + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _CompassPainter old) => old.t != t || old.step != step; +} + +/// The aircraft on the side the IMU wants down, over a ground line. +class ImuIllustration extends StatelessWidget { + const ImuIllustration({super.key, this.orientation}); + + /// A `CalibrationOrientation` name; null draws it flat. + final String? orientation; + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: 220, height: 170, child: CustomPaint(painter: _ImuPainter(orientation ?? 'BOTTOM_DOWN'))), + const SizedBox(height: 8), + Text(ImuOrientations.label(orientation ?? 'BOTTOM_DOWN').toUpperCase(), style: HudText.label), + ]); + } +} + +class _ImuPainter extends CustomPainter { + _ImuPainter(this.orientation); + + final String orientation; + + @override + void paint(Canvas canvas, Size size) { + final double ground = size.height - 18; + canvas.drawLine(Offset(20, ground), Offset(size.width - 20, ground), Paint() + ..color = _soft + ..strokeWidth = 2); + const double w = 70; + // How far the drawing reaches below its centre on each side, so it rests on the line. + final double reach = switch (orientation) { + 'NOSE_DOWN' || 'TAIL_DOWN' => w * 0.66, + 'LEFT_DOWN' || 'RIGHT_DOWN' => w * 0.64, + 'TOP_DOWN' => w * 0.18, + _ => w * 0.28, + }; + canvas.save(); + canvas.translate(size.width / 2, ground - reach - 1); + final bool front = orientation == 'LEFT_DOWN' || orientation == 'RIGHT_DOWN'; + canvas.rotate(switch (orientation) { + 'TOP_DOWN' => math.pi, + 'NOSE_DOWN' => math.pi / 2, + 'TAIL_DOWN' => -math.pi / 2, + 'LEFT_DOWN' => -math.pi / 2, + 'RIGHT_DOWN' => math.pi / 2, + _ => 0, + }); + front ? AircraftGlyph.front(canvas, w) : AircraftGlyph.side(canvas, w); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _ImuPainter old) => old.orientation != orientation; +} + +/// Simple quadcopter line drawings, centred on the origin, [w] wide. +abstract final class AircraftGlyph { + static Paint get _stroke => Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..strokeCap = StrokeCap.round + ..color = _line; + + /// From above, nose up: X arms, four rotors, a nose dot. + static void top(Canvas c, double w) { + final Paint p = _stroke; + final double r = w * 0.5; + for (final double a in [math.pi / 4, 3 * math.pi / 4, 5 * math.pi / 4, 7 * math.pi / 4]) { + final Offset e = Offset(math.cos(a) * r, math.sin(a) * r); + c.drawLine(Offset.zero, e, p); + c.drawCircle(e, w * 0.2, p); + } + c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)), + Paint()..color = const Color(0xFF0B1426)); + c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)), p); + c.drawCircle(Offset(0, -w * 0.16), 3.5, Paint()..color = HudColors.danger); + } + + /// From the left side, nose to the right (+x): body, arms, rotors, legs. + static void side(Canvas c, double w) { + final Paint p = _stroke; + c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.6, height: w * 0.22), const Radius.circular(5)), p); + for (final double x in [-w * 0.5, w * 0.5]) { + c.drawLine(Offset(x * 0.55, -w * 0.06), Offset(x, -w * 0.14), p); + c.drawLine(Offset(x - w * 0.16, -w * 0.18), Offset(x + w * 0.16, -w * 0.18), p); + c.drawLine(Offset(x * 0.5, w * 0.11), Offset(x * 0.6, w * 0.28), p); + } + // Nose camera. + c.drawCircle(Offset(w * 0.33, w * 0.04), w * 0.05, Paint()..color = HudColors.danger); + } + + /// From the front: body, two arms each side, rotors, legs. + static void front(Canvas c, double w) { + final Paint p = _stroke; + c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.32, height: w * 0.24), const Radius.circular(5)), p); + for (final double s in [-1, 1]) { + c.drawLine(Offset(s * w * 0.16, -w * 0.04), Offset(s * w * 0.5, -w * 0.12), p); + c.drawLine(Offset(s * w * 0.5 - w * 0.14, -w * 0.17), Offset(s * w * 0.5 + w * 0.14, -w * 0.17), p); + c.drawLine(Offset(s * w * 0.12, w * 0.12), Offset(s * w * 0.22, w * 0.3), p); + } + c.drawCircle(Offset(0, w * 0.02), w * 0.05, Paint()..color = HudColors.danger); + } +} + +/// The remote's two sticks and two dials, live, with the area each stick has +/// swept so far — the RC wizard's "move to every limit" view. +class RcSticksView extends StatefulWidget { + const RcSticksView({super.key, required this.hardware, this.showCoverage = false}); + + final RcHardware? hardware; + final bool showCoverage; + + /// Stick travel either side of centre, as the SDK reports it. + static const int travel = 660; + + @override + State createState() => _RcSticksViewState(); +} + +class _RcSticksViewState extends State { + Rect _left = Rect.zero, _right = Rect.zero; + + @override + void didUpdateWidget(RcSticksView old) { + super.didUpdateWidget(old); + if (!widget.showCoverage) { + _left = _right = Rect.zero; + return; + } + final RcHardware? h = widget.hardware; + if (h == null) return; + _left = _grow(_left, h.lx, h.ly); + _right = _grow(_right, h.rx, h.ry); + } + + static Rect _grow(Rect r, int? x, int? y) { + if (x == null || y == null) return r; + final Offset p = Offset(x.toDouble(), -y.toDouble()); + return r.expandToInclude(Rect.fromCenter(center: p, width: 0, height: 0)); + } + + @override + Widget build(BuildContext context) { + final RcHardware? h = widget.hardware; + return Column(mainAxisSize: MainAxisSize.min, children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + _Stick(x: h?.lx, y: h?.ly, swept: widget.showCoverage ? _left : null, key: const ValueKey('rc-left-stick')), + const SizedBox(width: 28), + _Stick(x: h?.rx, y: h?.ry, swept: widget.showCoverage ? _right : null, key: const ValueKey('rc-right-stick')), + ]), + const SizedBox(height: 14), + Row(mainAxisSize: MainAxisSize.min, children: [ + _Dial(label: 'LEFT DIAL', value: h?.leftDial), + const SizedBox(width: 28), + _Dial(label: 'RIGHT DIAL', value: h?.rightDial), + ]), + ]); + } +} + +class _Stick extends StatelessWidget { + const _Stick({super.key, this.x, this.y, this.swept}); + + final int? x, y; + final Rect? swept; + + @override + Widget build(BuildContext context) => + SizedBox.square(dimension: 104, child: CustomPaint(painter: _StickPainter(x, y, swept))); +} + +class _StickPainter extends CustomPainter { + _StickPainter(this.x, this.y, this.swept); + + final int? x, y; + final Rect? swept; + + @override + void paint(Canvas canvas, Size size) { + final Rect box = Offset.zero & size; + final double half = size.width / 2; + double px(num v) => half + v / RcSticksView.travel * (half - 8); + canvas.drawRRect(RRect.fromRectAndRadius(box, const Radius.circular(10)), Paint()..color = const Color(0xFF111C33)); + if (swept != null && swept != Rect.zero) { + canvas.drawRect( + Rect.fromLTRB(px(swept!.left), px(swept!.top), px(swept!.right), px(swept!.bottom)), + Paint()..color = HudColors.ready.withValues(alpha: 0.25), + ); + } + final Paint cross = Paint() + ..color = _soft + ..strokeWidth = 1; + canvas.drawLine(Offset(half, 6), Offset(half, size.height - 6), cross); + canvas.drawLine(Offset(6, half), Offset(size.width - 6, half), cross); + if (x != null && y != null) { + canvas.drawCircle(Offset(px(x!), px(-y!)), 8, Paint()..color = Glass.accent); + } + } + + @override + bool shouldRepaint(covariant _StickPainter old) => old.x != x || old.y != y || old.swept != swept; +} + +class _Dial extends StatelessWidget { + const _Dial({required this.label, this.value}); + + final String label; + final int? value; + + @override + Widget build(BuildContext context) { + final double t = ((value ?? 0) / RcSticksView.travel).clamp(-1.0, 1.0); + return SizedBox( + width: 104, + child: Column(children: [ + Text(label, style: HudText.label), + const SizedBox(height: 4), + SizedBox( + height: 8, + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final double mid = c.maxWidth / 2; + return Stack(children: [ + Container(decoration: BoxDecoration(color: const Color(0xFF111C33), borderRadius: BorderRadius.circular(4))), + Positioned( + left: t < 0 ? mid + t * mid : mid, + width: t.abs() * mid, + top: 0, + bottom: 0, + child: Container(color: Glass.accent), + ), + ]); + }), + ), + ]), + ); + } +} + +/// Reference lines drawn over the live video for the gimbal adjust step: a +/// dashed level line through the centre and faint thirds to judge the +/// horizon against. +class HorizonPainter extends CustomPainter { + const HorizonPainter(); + + @override + void paint(Canvas canvas, Size size) { + final double mid = size.height / 2; + final Paint ref = Paint() + ..color = Glass.subject + ..strokeWidth = 1.5; + for (double x = 0; x < size.width; x += 18) { + canvas.drawLine(Offset(x, mid), Offset(math.min(x + 10, size.width), mid), ref); + } + final Paint thirds = Paint() + ..color = const Color(0x33FFFFFF) + ..strokeWidth = 1; + for (final double f in [1 / 3, 2 / 3]) { + canvas.drawLine(Offset(0, size.height * f), Offset(size.width, size.height * f), thirds); + } + } + + @override + bool shouldRepaint(covariant HorizonPainter old) => false; +} + +/// A row of sides for the multi-side IMU calibration, done ones ticked. +class SideChips extends StatelessWidget { + const SideChips({super.key, required this.sides, this.current}); + + final List<(String, bool)> sides; + final String? current; + + @override + Widget build(BuildContext context) { + return Wrap(spacing: 6, runSpacing: 6, children: [ + for (final (String side, bool done) in sides) + Container( + key: ValueKey('imu-side-$side'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: side == current ? Glass.accent : (done ? HudColors.ready.withValues(alpha: 0.25) : const Color(0xFF111C33)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + if (done) const PVIcon('check', size: 12, color: HudColors.ready), + if (done) const SizedBox(width: 4), + Text(ImuOrientations.label(side), style: HudText.sans.copyWith(fontSize: 11, fontFamily: PV.fontSans)), + ]), + ), + ]); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart b/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart new file mode 100644 index 0000000..dad833c --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/calibration/wizards.dart @@ -0,0 +1,608 @@ +import 'package:flutter/material.dart'; + +import '../../../../../fly/calibration.dart'; +import '../../../hud_kit.dart'; +import 'calibration_scaffold.dart'; +import 'illustrations.dart'; + +/// The pieces every wizard's pages share: the intro with the gate's checks, +/// the "starting" page, and the success / failure ends. +mixin _Pages on WizardState { + String get title => CalibrationKinds.title(kind); + + Widget intro({ + required Widget illustration, + required String heading, + required String body, + required Future Function() onStart, + String startLabel = 'Start', + Widget? detail, + }) { + final bool b = blocked; + return CalibrationScaffold( + title: title, + onClose: close, + illustration: illustration, + heading: heading, + body: body, + checks: checks, + detail: detail, + actions: [ + WizardAction(startLabel, b ? null : onStart, primary: true, id: 'start'), + ], + ); + } + + Widget starting(Widget illustration) => CalibrationScaffold( + title: title, + onClose: close, + illustration: illustration, + heading: 'Starting…', + body: 'Waiting for the aircraft to begin.', + progress: double.nan, + ); + + Widget succeeded({required Widget illustration, required String heading, required String body, List more = const []}) => + CalibrationScaffold( + title: title, + onClose: close, + illustration: illustration, + heading: heading, + body: body, + tone: HudColors.ready, + actions: [...more, WizardAction('Done', close, primary: true, id: 'done')], + ); + + Widget failed({required Widget illustration, required VoidCallback onRetry}) => CalibrationScaffold( + title: title, + onClose: close, + illustration: illustration, + heading: 'Calibration failed', + body: run.failure ?? 'Try again.', + tone: HudColors.danger, + checks: checks, + actions: [ + WizardAction('Close', close, id: 'close'), + WizardAction('Try again', blocked ? null : onRetry, primary: true, id: 'retry'), + ], + ); + + /// Ends a run from its Stop button: undo, then back to the intro. + Future stopHere() async { + await abort(); + if (mounted) setState(run.reset); + } + + WizardAction get stopAction => WizardAction('Stop', stopHere, danger: true, id: 'stop'); +} + +// ── Compass ───────────────────────────────────────────────────────────────── + +class CompassWizard extends CalibrationWizard { + const CompassWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _CompassWizardState(); +} + +class _CompassWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.compass; + @override + final CompassRun run = CompassRun(); + + @override + Future abort() => send('Stop compass calibration', dji.stopCompassCalibration); + + Future _start() => start(dji.startCompassCalibration); + + @override + Widget build(BuildContext context) { + final CompassStep step = run.step ?? CompassStep.horizontal; + final int count = model.calibration.compassCount ?? model.profile.flightController?.compassCount ?? 1; + return switch (run.phase) { + RunPhase.idle => intro( + illustration: const CompassIllustration(step: CompassStep.horizontal), + heading: 'Calibrate the compass', + body: 'Do it outdoors, away from metal, cars, reinforced concrete and power lines, and without a phone or keys ' + '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, + ), + RunPhase.starting => starting(const CompassIllustration(step: CompassStep.horizontal, animate: false)), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: CompassIllustration(key: ValueKey(step), step: step), + stepLabel: step == CompassStep.horizontal ? 'STEP 1 OF 2' : 'STEP 2 OF 2', + heading: step == CompassStep.horizontal ? 'Hold it level and turn a full circle' : 'Hold it nose down and turn a full circle', + body: step == CompassStep.horizontal + ? '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, + actions: [stopAction], + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('compass', progress: 1, color: HudColors.ready), + heading: 'Compass calibrated', + body: 'Recalibrate after flying somewhere far away or if the app warns about compass interference.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('compass', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── IMU ───────────────────────────────────────────────────────────────────── + +class ImuWizard extends CalibrationWizard { + const ImuWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _ImuWizardState(); +} + +class _ImuWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.imu; + @override + final ImuRun run = ImuRun(); + + /// The SDK has no stop: leaving the page lets it finish on its own. + @override + bool get canAbort => false; + + Future _start() => start(dji.startIMUCalibration); + + @override + Widget build(BuildContext context) { + final int count = model.calibration.imuCount ?? model.profile.flightController?.imuCount ?? 1; + final double? p = run.progress; + return switch (run.phase) { + RunPhase.idle => intro( + illustration: const ImuIllustration(), + 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.' : ''}', + onStart: _start, + ), + RunPhase.starting => starting(const ImuIllustration()), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: ImuIllustration(key: ValueKey(run.orientation), orientation: run.orientation), + stepLabel: run.orientation == null + ? null + : 'SIDE ${run.sides.where(((String, bool) s) => s.$2).length + 1} OF ${run.sides.length}', + heading: run.orientation == null + ? 'Calibrating — do not move the aircraft' + : 'Put it ${ImuOrientations.label(run.orientation!).toLowerCase()}', + body: run.orientation == null + ? 'Keep it still until calibration finishes. Leaving this screen does not stop it.' + : 'Hold it steady on that side until the side is ticked, then turn it as shown.', + detail: run.sides.isEmpty ? null : SideChips(sides: run.sides, current: run.orientation), + progress: p ?? double.nan, + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('gauge', progress: 1, color: HudColors.ready), + heading: 'IMU calibrated', + body: 'Restart the aircraft before flying.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('gauge', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Remote controller ─────────────────────────────────────────────────────── + +class RcWizard extends CalibrationWizard { + const RcWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _RcWizardState(); +} + +class _RcWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.rc; + @override + final RcRun run = RcRun(); + + @override + void initState() { + super.initState(); + dji.watchRcHardware(true).catchError((Object _) {}); + } + + @override + void dispose() { + dji.watchRcHardware(false).catchError((Object _) {}); + super.dispose(); + } + + /// QUIT is the only way out the remote knows. + @override + Future abort() => send('Leave RC calibration', () => dji.setRcCalibrationStep('QUIT')); + + Future _start() => start(() => dji.setRcCalibrationStep('MIDDLE')); + + Future _finish() async { + if (!await send('Save RC calibration', () => dji.setRcCalibrationStep('QUIT'))) return; + if (!mounted) return; + setState(() { + run.finishing(); + run.observe(model); + }); + } + + Widget? _axes() { + final Map axes = model.calibration.rcAxes; + if (axes.isEmpty) return null; + final int? segments = model.calibration.rcSegments; + return Text( + [for (final MapEntry e in axes.entries) '${e.key} ${e.value}${segments == null ? '' : '/$segments'}'].join(' '), + key: const ValueKey('rc-axes'), + style: HudText.mono, + ); + } + + @override + Widget build(BuildContext context) { + final bool limits = run.step == RcStep.limits; + final Widget sticks = RcSticksView(hardware: model.calibration.rcHardware, showCoverage: run.active && limits); + return switch (run.phase) { + RunPhase.idle => intro( + illustration: sticks, + heading: 'Calibrate the remote controller', + body: 'Keep the aircraft on the ground with the motors off. Let go of both sticks and centre both dials, then start. ' + 'Calibrate when the aircraft drifts with the sticks centred or the sticks feel off.', + onStart: _start, + ), + RunPhase.starting => starting(sticks), + RunPhase.running when !limits => CalibrationScaffold( + title: title, + onClose: close, + illustration: sticks, + stepLabel: 'STEP 1 OF 2', + heading: 'Leave the sticks and dials centred', + body: 'The remote is recording their centre. Do not touch them, then tap Next.', + progress: 0.3, + actions: [ + stopAction, + WizardAction('Next', () => send('RC calibration', () => dji.setRcCalibrationStep('LIMITS')), primary: true, id: 'next'), + ], + ), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: sticks, + stepLabel: 'STEP 2 OF 2', + heading: 'Move every stick and dial to its limits', + body: 'Push both sticks around their full range a few times and turn both dials all the way each way. ' + 'Tap Finish when the green areas fill both boxes.', + detail: _axes(), + progress: 0.7, + actions: [ + stopAction, + WizardAction('Finish', _finish, primary: true, id: 'finish'), + ], + ), + RunPhase.succeeded => succeeded(illustration: sticks, heading: 'Remote controller calibrated', body: 'The new centre and limits are saved.'), + RunPhase.failed => failed(illustration: sticks, onRetry: _start), + }; + } +} + +// ── Gimbal auto calibration ───────────────────────────────────────────────── + +class GimbalCalibrationWizard extends CalibrationWizard { + const GimbalCalibrationWizard({super.key, required super.model, required super.dji, required super.onError, this.onAdjust}); + + /// Offered after a calibration: opens the horizon adjust step. + final VoidCallback? onAdjust; + + @override + State createState() => _GimbalCalibrationWizardState(); +} + +class _GimbalCalibrationWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.gimbal; + @override + final GimbalRun run = GimbalRun(); + + /// The SDK has no stop for gimbal auto calibration. + @override + bool get canAbort => false; + + Future _start() => start(dji.startGimbalCalibration); + + @override + Widget build(BuildContext context) { + final double? p = run.progress == null ? null : run.progress! / 100; + return switch (run.phase) { + RunPhase.idle => intro( + illustration: const IconIllustration('gimbal'), + heading: 'Auto calibrate the gimbal', + body: 'Put the aircraft on a flat, level surface with the motors off, the gimbal clamp removed and nothing touching ' + 'the camera. Do not touch it until calibration finishes — about 30 seconds.', + onStart: _start, + ), + RunPhase.starting => starting(const IconIllustration('gimbal')), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: IconIllustration('gimbal', progress: p), + heading: 'Calibrating — do not touch the aircraft', + body: 'The gimbal moves through its range on its own.', + progress: p ?? double.nan, + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('gimbal', progress: 1, color: HudColors.ready), + heading: 'Gimbal calibrated', + body: 'If the horizon still looks tilted, adjust it against the live video.', + more: [ + if (widget.onAdjust != null) WizardAction('Adjust horizon', widget.onAdjust, id: 'adjust'), + ], + ), + RunPhase.failed => failed(illustration: const IconIllustration('gimbal', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Lens ──────────────────────────────────────────────────────────────────── + +class LensWizard extends CalibrationWizard { + const LensWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _LensWizardState(); +} + +class _LensWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.lens; + @override + final LensRun run = LensRun(); + + @override + Future abort() => send('Stop lens calibration', dji.stopLensCalibration); + + Future _start() => start(dji.startLensCalibration); + + @override + Widget build(BuildContext context) { + return switch (run.phase) { + RunPhase.idle => intro( + illustration: const IconIllustration('focus'), + heading: 'Calibrate the lens', + body: 'Do this after fitting a lens, or when manual focus does not match the distance scale. ' + 'Point the camera at a distant, detailed scene in good light and keep the aircraft still.', + onStart: _start, + ), + RunPhase.starting => starting(const IconIllustration('focus')), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: const IconIllustration('focus'), + heading: 'Calibrating the lens…', + body: 'The lens runs through its focus range. Keep the camera still.', + progress: double.nan, + actions: [stopAction], + ), + 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.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('focus', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Sensor cleaning ───────────────────────────────────────────────────────── + +class SensorCleaningWizard extends CalibrationWizard { + const SensorCleaningWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _SensorCleaningWizardState(); +} + +class _SensorCleaningWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.sensorCleaning; + @override + final SensorCleaningRun run = SensorCleaningRun(); + + @override + Future abort() => send('Leave sensor cleaning', dji.exitSensorCleaning); + + Future _start() => start(dji.initSensorCleaning); + + Future _finish() async { + setState(run.exiting); + await send('Leave sensor cleaning', dji.exitSensorCleaning); + if (mounted) setState(() => run.observe(model)); + } + + @override + Widget build(BuildContext context) { + final CleaningStep step = run.step; + const Widget art = IconIllustration('aperture'); + return switch (run.phase) { + RunPhase.idle => intro( + illustration: art, + heading: 'Clean the image sensor', + body: '${SensorCleaning.instruction(CleaningStep.off)} Do it indoors, out of the wind, with the aircraft on a table.', + onStart: _start, + ), + RunPhase.starting => starting(art), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: art, + stepLabel: switch (step) { + CleaningStep.removeLens || CleaningStep.entering => 'STEP 1 OF 3', + CleaningStep.ready || CleaningStep.cleaning || CleaningStep.finishing => 'STEP 2 OF 3', + CleaningStep.remountLens || CleaningStep.off => 'STEP 3 OF 3', + }, + heading: switch (step) { + CleaningStep.removeLens => 'Remove the lens', + CleaningStep.ready => 'Ready to clean', + CleaningStep.cleaning => 'Cleaning', + CleaningStep.remountLens => 'Refit the lens', + _ => 'Please wait', + }, + body: SensorCleaning.instruction(step), + progress: step == CleaningStep.cleaning || step == CleaningStep.entering || step == CleaningStep.finishing ? double.nan : null, + actions: [ + stopAction, + if (step == CleaningStep.ready) WizardAction('Clean', () => send('Sensor cleaning', dji.startSensorCleaning), primary: true, id: 'clean'), + if (step == CleaningStep.remountLens) WizardAction('Finish', _finish, primary: true, id: 'finish'), + ], + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('aperture', progress: 1, color: HudColors.ready), + heading: 'Sensor cleaned', + body: 'If spots remain on a plain sky, have the sensor cleaned by DJI support.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('aperture', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Center of gravity ─────────────────────────────────────────────────────── + +class GravityCenterWizard extends CalibrationWizard { + const GravityCenterWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _GravityCenterWizardState(); +} + +class _GravityCenterWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.gravityCenter; + @override + final GravityCenterRun run = GravityCenterRun(); + + @override + Future abort() => send('Stop gravity calibration', dji.stopGravityCenterCalibration); + + Future _start() => start(dji.startGravityCenterCalibration); + + @override + Widget build(BuildContext context) { + const Widget art = IconIllustration('drone'); + return switch (run.phase) { + RunPhase.idle => intro( + illustration: art, + heading: 'Calibrate the center of gravity', + body: 'After fitting, moving or changing a payload. Take off, hover at least 2 m above open ground in calm air, ' + 'let go of the sticks, then start. The aircraft measures its balance for about a minute.', + onStart: _start, + ), + RunPhase.starting => starting(art), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: art, + heading: 'Measuring — keep the sticks centred', + body: 'The aircraft holds its hover while it measures. Take over at once if it drifts towards anything.', + progress: double.nan, + actions: [stopAction], + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready), + heading: 'Center of gravity calibrated', + body: 'Land when ready. Recalibrate whenever the payload changes.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Propellers ────────────────────────────────────────────────────────────── + +class PropellerWizard extends CalibrationWizard { + const PropellerWizard({super.key, required super.model, required super.dji, required super.onError}); + + @override + State createState() => _PropellerWizardState(); +} + +class _PropellerWizardState extends WizardState with _Pages { + @override + CalibrationKind get kind => CalibrationKind.propeller; + @override + final PropellerRun run = PropellerRun(); + + @override + Future abort() => send('Stop propeller calibration', dji.stopPropellerCalibration); + + Future _start() async { + await start(dji.startPropellerCalibration); + if (mounted) setState(run.accepted); + } + + Future _done() async { + await send('Stop propeller calibration', dji.stopPropellerCalibration); + if (mounted) setState(run.complete); + } + + @override + Widget build(BuildContext context) { + const Widget art = IconIllustration('drone'); + return switch (run.phase) { + RunPhase.idle => intro( + illustration: art, + heading: 'Calibrate the propellers', + body: 'Put the aircraft on open, level ground with the propellers fitted. The motors spin during calibration: ' + 'step back and keep everyone clear.', + onStart: _start, + ), + RunPhase.starting => starting(art), + RunPhase.running => CalibrationScaffold( + title: title, + onClose: close, + illustration: art, + heading: 'Calibrating — keep clear', + body: 'The aircraft does not report progress to the app. When the motors have stopped, tap Done.', + progress: double.nan, + actions: [ + stopAction, + WizardAction('Done', _done, primary: true, id: 'finish'), + ], + ), + RunPhase.succeeded => succeeded( + illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready), + heading: 'Propeller calibration ended', + body: 'If the aircraft reported an error on its status lights, run it again.', + ), + RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start), + }; + } +} + +// ── Vision sensors ────────────────────────────────────────────────────────── + +/// No SDK call exists; GO 4 also only points to DJI Assistant 2. +class VisionSensorGuide extends StatelessWidget { + const VisionSensorGuide({super.key}); + + @override + Widget build(BuildContext context) { + void close() => Navigator.of(context).pop(); + return CalibrationScaffold( + title: CalibrationKinds.title(CalibrationKind.visionSensors), + onClose: close, + illustration: const IconIllustration('obstacle'), + heading: 'Calibrate on a computer with DJI Assistant 2', + body: 'The vision sensors cannot be calibrated from a mobile app. Connect the aircraft to a computer with ' + 'DJI Assistant 2 (the Consumer or Enterprise series that matches the aircraft), open Calibration and ' + 'follow the steps on screen. Do it when the app reports a vision sensor error or after a hard knock.', + actions: [WizardAction('Close', close, primary: true, id: 'done')], + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart index 7551f3f..bca2aa9 100644 --- a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart +++ b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart @@ -17,6 +17,7 @@ import '../../dialogs/slide_to_confirm.dart'; import '../../hud_kit.dart'; import '../camera_menu/camera_rows.dart'; import '../gimbal_sheet.dart'; +import 'calibration/calibration_hub.dart'; import 'setting_tiles.dart'; /// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass @@ -379,11 +380,18 @@ class _FlySettingsSheetState extends State { }}', valueColor: compassError == true ? HudColors.danger : null, ), + ButtonTile( + id: 'openCalibration', + label: 'Calibration & maintenance', + hint: 'Compass, IMU, remote, gimbal and more', + button: 'Open', + onTap: () => showCalibrationHub(context, model: _m, dji: widget.dji, onError: widget.onError), + ), if (widget.onOpenStatus != null) ButtonTile( id: 'openStatus', - label: 'Aircraft status & calibration', - hint: 'Compass and IMU calibration', + label: 'Aircraft status', + hint: 'Every subsystem at a glance', button: 'Open', onTap: widget.onOpenStatus, ), diff --git a/Fly App/test/fly/calibration_test.dart b/Fly App/test/fly/calibration_test.dart new file mode 100644 index 0000000..10b1b5a --- /dev/null +++ b/Fly App/test/fly/calibration_test.dart @@ -0,0 +1,499 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/calibration.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/safety_gate.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/calibration_hub.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +/// A connected aircraft on the ground, fed through the real reducer; [tick] +/// sends the next telemetry snapshot with [patch] on top. +class Rig { + Rig({Map? profile, bool flying = false}) { + model + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + tick(flying ? const {} : const {'isFlying': false, 'areMotorsOn': false}); + } + + final FlightModel model = FlightModel(); + late final TelemetryReducer reducer = TelemetryReducer(model); + final Map _state = {}; + + void tick(Map patch) { + _state.addAll(patch); + reducer.apply({...telemetryTick(), ..._state}); + } + + void calibration(Map group) => tick({'calibration': group}); +} + +Map withLens(Map p) => { + ...p, + 'cameras': [ + for (final Object? c in p['cameras'] as List) {...(c as Map), 'interchangeableLensSupported': true}, + ], + }; + +void main() { + group('State', () { + test('the calibration group lands in CalibrationState and clears when absent', () { + final Rig r = Rig(); + r.calibration({ + 'support': {'compassCount': 2, 'imuCount': 2, 'gravityCenter': false, 'rc': true}, + 'imu': { + 'units': [ + {'index': 0, 'state': 'CALIBRATING', 'progress': 40, 'connected': true}, + ], + 'multiSide': true, + 'toCalibrate': ['NOSE_DOWN'], + 'calibrated': ['BOTTOM_DOWN'], + }, + 'rc': {'state': 'MIDDLE', 'segments': 4, 'axes': {'A': 2}}, + 'rcHardware': {'lx': 100, 'ly': -660, 'rx': 0, 'ry': 0, 'leftDial': 10, 'rightDial': -5}, + }); + final c = r.model.calibration; + expect(c.compassCount, 2); + expect(c.gravityCenterSupported, isFalse); + expect(c.imuUnits.single.progress, 40); + expect(c.imuToCalibrate, ['NOSE_DOWN']); + expect(c.rcState, 'MIDDLE'); + expect(c.rcAxes['A'], 2); + expect(c.rcHardware!.ly, -660); + + r.tick({'calibration': null}); + expect(c.compassCount, isNull); + expect(c.imuUnits, isEmpty); + expect(c.rcHardware, isNull); + }); + }); + + group('Availability', () { + test('profile features, then runtime support, decide the hub', () { + final Rig r = Rig(); + List kinds() => CalibrationKinds.available(r.model.profile, r.model.calibration); + expect(kinds(), containsAll([ + CalibrationKind.compass, + CalibrationKind.imu, + CalibrationKind.rc, + CalibrationKind.gimbal, + CalibrationKind.gimbalAdjust, + CalibrationKind.visionSensors, + ])); + expect(kinds(), isNot(contains(CalibrationKind.lens)), reason: 'fixed lens'); + expect(kinds(), isNot(contains(CalibrationKind.sensorCleaning))); + expect(kinds(), contains(CalibrationKind.gravityCenter), reason: 'unknown until the aircraft answers'); + + r.calibration({ + 'support': {'gravityCenter': false, 'rc': false, 'compassCount': 1}, + }); + expect(kinds(), isNot(contains(CalibrationKind.gravityCenter))); + expect(kinds(), isNot(contains(CalibrationKind.rc))); + expect(kinds(), contains(CalibrationKind.compass)); + }); + + test('interchangeable-lens cameras get lens calibration and sensor cleaning; Spark has no RC wizard', () { + final Rig x5s = Rig(profile: withLens(inspire2X5s)); + final List k = CalibrationKinds.available(x5s.model.profile, x5s.model.calibration); + expect(k, containsAll([CalibrationKind.lens, CalibrationKind.sensorCleaning])); + + final Rig spark = Rig(profile: sparkWifi); + expect(CalibrationKinds.available(spark.model.profile, spark.model.calibration), isNot(contains(CalibrationKind.rc))); + }); + }); + + group('SafetyGate', () { + test('ground work is refused in the air and with the motors running', () { + final Rig r = Rig(flying: true); + final SafetyGate gate = SafetyGate(model: r.model, dji: FakeDjiService()); + for (final CalibrationKind k in [ + CalibrationKind.compass, + CalibrationKind.imu, + CalibrationKind.rc, + CalibrationKind.gimbal, + CalibrationKind.lens, + CalibrationKind.sensorCleaning, + CalibrationKind.propeller, + ]) { + expect(gate.checkMaintenance(k).where((GateCheck c) => c.blocks).map((GateCheck c) => c.message), contains('Land first.'), reason: k.name); + } + r.tick({'isFlying': false, 'areMotorsOn': true}); + expect(gate.checkMaintenance(CalibrationKind.compass).single.message, 'Stop the motors first.'); + r.tick({'areMotorsOn': false}); + expect(gate.checkMaintenance(CalibrationKind.compass), isEmpty); + }); + + test('center of gravity is the exception: it needs a hover', () { + final Rig ground = Rig(); + final SafetyGate g1 = SafetyGate(model: ground.model, dji: FakeDjiService()); + expect(g1.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isTrue); + final Rig air = Rig(flying: true); + final SafetyGate g2 = SafetyGate(model: air.model, dji: FakeDjiService()); + expect(g2.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isFalse); + expect(g2.checkMaintenance(CalibrationKind.gimbalAdjust), isEmpty, reason: 'trim is fine in flight'); + }); + + test('runMaintenance re-checks and refuses without sending', () async { + final Rig r = Rig(flying: true); + final FakeDjiService dji = FakeDjiService(); + final SafetyGate gate = SafetyGate(model: r.model, dji: dji); + await expectLater(gate.runMaintenance(CalibrationKind.imu, dji.startIMUCalibration), throwsA(isA())); + expect(dji.calls, isEmpty); + }); + + test('a low battery and a cold IMU only warn', () { + final Rig r = Rig(); + r.tick({ + 'isIMUPreheating': true, + 'battery': {'percent': 20}, + }); + final List checks = SafetyGate(model: r.model, dji: FakeDjiService()).checkMaintenance(CalibrationKind.imu); + expect(checks, isNotEmpty); + expect(checks.any((GateCheck c) => c.blocks), isFalse); + }); + }); + + group('Runs', () { + test('compass: a stale result never ends a new run; steps, then success', () { + final Rig r = Rig(); + r.tick({'compassCalibrationState': 'SUCCESSFUL'}); + final CompassRun run = CompassRun()..begin(); + run.observe(r.model); + expect(run.phase, RunPhase.starting, reason: 'SUCCESSFUL from before the start'); + + r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); + run.observe(r.model); + expect((run.phase, run.step), (RunPhase.running, CompassStep.horizontal)); + r.tick({'compassCalibrationState': 'VERTICAL'}); + run.observe(r.model); + expect(run.step, CompassStep.vertical); + r.tick({'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'}); + run.observe(r.model); + expect(run.phase, RunPhase.succeeded); + }); + + test('compass: an end without a verdict counts by the error flag', () { + final Rig r = Rig(); + final CompassRun run = CompassRun()..begin(); + r.tick({'compassCalibrating': true, 'compassCalibrationState': 'VERTICAL'}); + run.observe(r.model); + r.tick({'compassCalibrating': false, 'compassCalibrationState': 'NOT_CALIBRATING', 'compassHasError': true}); + run.observe(r.model); + expect(run.phase, RunPhase.failed); + expect(run.failure, isNotNull); + }); + + test('IMU: multi-side sequence walks the sides in order and averages progress', () { + final Rig r = Rig(); + final ImuRun run = ImuRun()..begin(); + Map unit(int i, String state, int progress) => + {'index': i, 'state': state, 'progress': progress, 'connected': true}; + r.calibration({ + 'imu': { + 'units': [unit(0, 'CALIBRATING', 20), unit(1, 'CALIBRATING', 40)], + 'multiSide': true, + 'orientationState': 'CALIBRATING', + 'toCalibrate': ['NOSE_DOWN', 'LEFT_DOWN', 'BOTTOM_DOWN'], + 'calibrated': ['BOTTOM_DOWN'], + }, + }); + run.observe(r.model); + expect(run.phase, RunPhase.running); + expect(run.progress, closeTo(0.3, 1e-9)); + expect(run.orientation, 'NOSE_DOWN'); + expect(run.sides, <(String, bool)>[('BOTTOM_DOWN', true), ('NOSE_DOWN', false), ('LEFT_DOWN', false)]); + + r.calibration({ + 'imu': { + 'units': [unit(0, 'SUCCESSFUL', 100), unit(1, 'SUCCESSFUL', 100)], + 'multiSide': true, + 'orientationState': 'COMPLETED', + }, + }); + run.observe(r.model); + expect(run.phase, RunPhase.succeeded); + }); + + test('IMU: one failed unit fails the run', () { + final Rig r = Rig(); + final ImuRun run = ImuRun()..begin(); + r.calibration({ + 'imu': { + 'units': [{'index': 0, 'state': 'CALIBRATING', 'progress': 10}], + }, + }); + run.observe(r.model); + r.calibration({ + 'imu': { + 'units': [{'index': 0, 'state': 'FAILED'}], + }, + }); + run.observe(r.model); + expect(run.phase, RunPhase.failed); + }); + + test('RC: MIDDLE → LIMITS → finish; a timeout fails', () { + final Rig r = Rig(); + final RcRun run = RcRun()..begin(); + r.calibration({'rc': {'state': 'MIDDLE'}}); + run.observe(r.model); + expect((run.phase, run.step), (RunPhase.running, RcStep.middle)); + r.calibration({'rc': {'state': 'LIMITS'}}); + run.observe(r.model); + expect(run.step, RcStep.limits); + run + ..finishing() + ..observe(r.model); + expect(run.phase, RunPhase.succeeded); + + run.begin(); + r.calibration({'rc': {'state': 'MIDDLE'}}); + run.observe(r.model); + r.calibration({'rc': {'state': 'TIMEOUT'}}); + run.observe(r.model); + expect(run.phase, RunPhase.failed); + }); + + test('sensor cleaning ends well only after leaving the mode', () { + final Rig r = Rig(); + final SensorCleaningRun run = SensorCleaningRun()..begin(); + for (final String s in ['INITIATING', 'WAITING_FOR_LENS_REMOVAL', 'READY', 'CLEANING', 'WAITING_FOR_LENS_REMOUNT']) { + r.calibration({'sensorCleaning': s}); + run.observe(r.model); + expect(run.phase, RunPhase.running, reason: s); + } + expect(run.step, CleaningStep.remountLens); + run.exiting(); + run.observe(r.model); + expect(run.phase, RunPhase.running, reason: 'still in the mode'); + r.calibration({'sensorCleaning': 'IDLE'}); + run.observe(r.model); + expect(run.phase, RunPhase.succeeded); + }); + + test('lens and center of gravity read their verdicts', () { + final Rig r = Rig(); + final LensRun lens = LensRun()..begin(); + r.calibration({'lens': {'state': 'CALIBRATING', 'result': 'IDLE'}}); + lens.observe(r.model); + r.calibration({'lens': {'state': 'CALIBRATED', 'result': 'DATA_ERROR'}}); + lens.observe(r.model); + expect(lens.phase, RunPhase.failed); + + final GravityCenterRun gc = GravityCenterRun()..begin(); + r.calibration({'gravityCenter': {'state': 'CALCULATING'}}); + gc.observe(r.model); + r.calibration({'gravityCenter': {'state': 'SUCCESSFUL'}}); + gc.observe(r.model); + expect(gc.phase, RunPhase.succeeded); + }); + }); + + group('Wizards', () { + // The compass illustration spins forever, so pumpAndSettle never would. + Future settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + + Future open(WidgetTester tester, Rig r, CalibrationKind kind) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService dji = FakeDjiService(); + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (BuildContext context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => openCalibration(context, kind, model: r.model, dji: dji, onError: (_, __) {}), + child: const Text('open'), + ), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await settle(tester); + return dji; + } + + String heading(WidgetTester tester) => tester.widget(find.byKey(const ValueKey('wizard-heading'))).data!; + + testWidgets('compass: start, the two steps follow telemetry, done', (WidgetTester tester) async { + final Rig r = Rig(); + final FakeDjiService dji = await open(tester, r, CalibrationKind.compass); + expect(heading(tester), 'Calibrate the compass'); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + expect(dji.calls, ['startCompassCalibration']); + expect(heading(tester), 'Starting…'); + + r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); + await tester.pump(); + expect(find.text('STEP 1 OF 2'), findsOneWidget); + r.tick({'compassCalibrationState': 'VERTICAL'}); + await tester.pump(); + expect(find.text('STEP 2 OF 2'), findsOneWidget); + r.tick({'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'}); + await tester.pump(); + expect(heading(tester), 'Compass calibrated'); + await tester.tap(find.byKey(const ValueKey('wizard-done'))); + await settle(tester); + expect(find.byType(CompassWizard), findsNothing); + }); + + testWidgets('compass: closing mid-run asks, then stops the calibration', (WidgetTester tester) async { + final Rig r = Rig(); + final FakeDjiService dji = await open(tester, r, CalibrationKind.compass); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('wizard-close'))); + await settle(tester); + await tester.tap(find.byKey(const ValueKey('wizard-confirm-stop'))); + await settle(tester); + expect(dji.calls, contains('stopCompassCalibration')); + expect(find.byType(CompassWizard), findsNothing); + }); + + testWidgets('a blocked start says why and sends nothing', (WidgetTester tester) async { + final Rig r = Rig(flying: true); + final FakeDjiService dji = await open(tester, r, CalibrationKind.imu); + expect(find.text('Land first.'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + expect(dji.calls, isEmpty); + }); + + testWidgets('IMU: shows the side to put down; leaving does not try to stop it', (WidgetTester tester) async { + final Rig r = Rig(); + final FakeDjiService dji = await open(tester, r, CalibrationKind.imu); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + r.calibration({ + 'imu': { + 'units': [{'index': 0, 'state': 'CALIBRATING', 'progress': 50}], + 'multiSide': true, + 'toCalibrate': ['NOSE_DOWN', 'TAIL_DOWN'], + 'calibrated': ['BOTTOM_DOWN'], + }, + }); + await tester.pump(); + expect(heading(tester), 'Put it nose down'); + expect(find.text('SIDE 2 OF 3'), findsOneWidget); + expect(find.byKey(const ValueKey('imu-side-TAIL_DOWN')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('wizard-close'))); + await tester.pumpAndSettle(); + expect(find.byType(ImuWizard), findsNothing, reason: 'no stop to confirm'); + expect(dji.calls, ['startIMUCalibration']); + }); + + testWidgets('RC: watches the sticks, steps MIDDLE → LIMITS → QUIT, unwatches on the way out', (WidgetTester tester) async { + final Rig r = Rig(); + final FakeDjiService dji = await open(tester, r, CalibrationKind.rc); + expect(dji.calls, ['watchRcHardware(true)']); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + expect(dji.calls.last, 'setRcCalibrationStep(MIDDLE)'); + r.calibration({'rc': {'state': 'MIDDLE'}}); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('wizard-next'))); + await tester.pump(); + expect(dji.calls.last, 'setRcCalibrationStep(LIMITS)'); + r.calibration({ + 'rc': {'state': 'LIMITS'}, + 'rcHardware': {'lx': 660, 'ly': 660, 'rx': -660, 'ry': -660}, + }); + await tester.pump(); + expect(heading(tester), 'Move every stick and dial to its limits'); + await tester.tap(find.byKey(const ValueKey('wizard-finish'))); + await tester.pump(); + expect(dji.calls.last, 'setRcCalibrationStep(QUIT)'); + expect(heading(tester), 'Remote controller calibrated'); + await tester.tap(find.byKey(const ValueKey('wizard-done'))); + await tester.pumpAndSettle(); + expect(dji.calls.last, 'watchRcHardware(false)'); + }); + + testWidgets('sensor cleaning: the buttons follow the camera, Stop leaves the mode', (WidgetTester tester) async { + final Rig r = Rig(profile: withLens(inspire2X5s)); + final FakeDjiService dji = await open(tester, r, CalibrationKind.sensorCleaning); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); + await tester.pump(); + r.calibration({'sensorCleaning': 'READY'}); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('wizard-clean'))); + await tester.pump(); + expect(dji.calls, ['initSensorCleaning', 'startSensorCleaning']); + await tester.tap(find.byKey(const ValueKey('wizard-stop'))); + await tester.pump(); + expect(dji.calls.last, 'exitSensorCleaning'); + expect(heading(tester), 'Clean the image sensor', reason: 'back at the start'); + }); + + testWidgets('the hub lists the offered wizards and opens one', (WidgetTester tester) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final Rig r = Rig(); + r.calibration({ + 'support': {'gravityCenter': false}, + }); + await tester.pumpWidget(MaterialApp(home: CalibrationHubPage(model: r.model, dji: FakeDjiService(), onError: (_, __) {}))); + expect(find.byKey(const ValueKey('hub-compass')), findsOneWidget); + expect(find.byKey(const ValueKey('hub-gravityCenter')), findsNothing); + expect(find.byKey(const ValueKey('hub-lens')), findsNothing); + await tester.tap(find.byKey(const ValueKey('hub-imu'))); + await tester.pumpAndSettle(); + expect(find.byType(ImuWizard), findsOneWidget); + }); + + testWidgets('Aircraft Status: Calibrate opens the compass wizard instead of starting at once', (WidgetTester tester) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final Rig r = Rig(); + final FakeDjiService dji = FakeDjiService(); + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => showAircraftStatusSheet(context, model: r.model, dji: dji, onError: (_, __) {}), + child: const Text('status'), + ), + ), + )); + await tester.tap(find.text('status')); + await settle(tester); + final Finder calibrate = find.byKey(const ValueKey('status-action-compass')); + await tester.scrollUntilVisible(calibrate, 60, scrollable: find.byType(Scrollable).last); + await tester.tap(calibrate); + await settle(tester); + expect(find.byType(CompassWizard), findsOneWidget); + expect(dji.calls, isNot(contains('startCompassCalibration'))); + }); + + testWidgets('gimbal adjust lays the horizon over the screen and nudges each axis', (WidgetTester tester) async { + final Rig r = Rig(profile: inspire2X5s); + final FakeDjiService dji = await open(tester, r, CalibrationKind.gimbalAdjust); + expect(find.byType(GimbalAdjustOverlay), findsOneWidget); + expect(find.text('open'), findsOneWidget, reason: 'the page underneath stays visible'); + await tester.tap(find.byKey(const ValueKey('step-adjustRoll-plus'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('step-adjustPitch-minus'))); + await tester.pump(); + expect(dji.calls, ['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(pitch, -0.1)']); + await tester.tap(find.byKey(const ValueKey('adjust-done'))); + await tester.pumpAndSettle(); + expect(find.byType(GimbalAdjustOverlay), findsNothing); + }); + }); +} diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index 78b0527..9b3840a 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -37,6 +37,34 @@ class FakeDjiService extends DjiService { Future stopActiveTrack() => _record('stopActiveTrack'); @override Future startCompassCalibration() => _record('startCompassCalibration'); + + // ── Calibration (plan P6b) ───────────────────────────────────────────────── + @override + Future stopCompassCalibration() => _record('stopCompassCalibration'); + @override + Future startIMUCalibration({int? index}) => _record('startIMUCalibration'); + @override + Future startGravityCenterCalibration() => _record('startGravityCenterCalibration'); + @override + Future stopGravityCenterCalibration() => _record('stopGravityCenterCalibration'); + @override + Future startPropellerCalibration() => _record('startPropellerCalibration'); + @override + Future stopPropellerCalibration() => _record('stopPropellerCalibration'); + @override + Future startLensCalibration() => _record('startLensCalibration'); + @override + Future stopLensCalibration() => _record('stopLensCalibration'); + @override + Future initSensorCleaning() => _record('initSensorCleaning'); + @override + Future startSensorCleaning() => _record('startSensorCleaning'); + @override + Future exitSensorCleaning() => _record('exitSensorCleaning'); + @override + Future setRcCalibrationStep(String step) => _record('setRcCalibrationStep($step)'); + @override + Future watchRcHardware(bool on) async => calls.add('watchRcHardware($on)'); @override Future formatStorage({String location = 'SDCARD'}) => _record('formatStorage'); @override diff --git a/Fly App/test/fly/gimbal_test.dart b/Fly App/test/fly/gimbal_test.dart index 5674a37..06454af 100644 --- a/Fly App/test/fly/gimbal_test.dart +++ b/Fly App/test/fly/gimbal_test.dart @@ -6,10 +6,10 @@ 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/telemetry_reducer.dart'; -import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; import 'package:pilotvault_fly/ui/fly/panels/camera_write.dart'; import 'package:pilotvault_fly/ui/fly/panels/gimbal_sheet.dart'; +import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart'; import 'fake_dji.dart'; import 'profile_fixtures.dart'; @@ -250,7 +250,7 @@ void main() { expect(dji.calls, containsAllInOrder(['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(roll, -0.1)'])); }); - testWidgets('calibration is blocked in flight and slide-confirmed on the ground', (WidgetTester tester) async { + testWidgets('calibration is blocked in flight and runs through its wizard on the ground', (WidgetTester tester) async { final FlightModel m = gimbalModel(phantom4); final FakeDjiService dji = await pump(tester, m); await openSheet(tester); @@ -260,8 +260,8 @@ void main() { await tester.pump(); expect(find.text('Land and stop the motors first'), findsOneWidget); await tester.tap(calibrate); - await tester.pump(); - expect(find.byType(SlideConfirmDialog), findsNothing); + await tester.pumpAndSettle(); + expect(find.byType(GimbalCalibrationWizard), findsNothing); m.aircraft ..isFlying = false @@ -269,12 +269,11 @@ void main() { ..notifyListeners(); await tester.pump(); await tester.tap(calibrate); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.byType(GimbalCalibrationWizard), findsOneWidget); expect(dji.calls, isNot(contains('startGimbalCalibration'))); - await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); expect(dji.calls, contains('startGimbalCalibration')); });