From 60c2fd7f6408db95c5a9426ff085d5bb33651bde Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:42 +0200 Subject: [PATCH] Give the Fly App GO 4-style gimbal controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan P5: a value bubble on the pitch slider while dragging, press-and-drag on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down / yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness rows gated by the gimbal's capability ranges. GimbalBridge reports mode, trim, calibration and stop/overload state in the gimbal telemetry group, and getter-only settings in a new gimbalSettings slow group. The reported-plus-pending overlay moves out of CameraState into a SettingsOverlay mixin shared by camera and gimbal. Co-Authored-By: Claude Opus 5.5 --- .../com/pilotvault/flyapp/GimbalBridge.kt | 189 +++++++++- .../pilotvault/flyapp/ProductProfileBridge.kt | 9 + Fly App/lib/dji_service.dart | 32 ++ Fly App/lib/fly/gimbal_control.dart | 138 ++++++++ Fly App/lib/fly/hud_features.dart | 17 +- Fly App/lib/fly/product_profile.dart | 11 + Fly App/lib/fly/state/camera_state.dart | 50 +-- Fly App/lib/fly/state/gimbal_state.dart | 69 +++- Fly App/lib/fly/state/settings_overlay.dart | 53 +++ Fly App/lib/fly/telemetry_reducer.dart | 5 +- Fly App/lib/ui/fly/fly_screen.dart | 57 ++- Fly App/lib/ui/fly/left/gimbal_slider.dart | 57 ++- .../ui/fly/overlays/gimbal_drag_layer.dart | 201 +++++++++++ .../fly/panels/camera_menu/camera_rows.dart | 114 +++++- Fly App/lib/ui/fly/panels/camera_write.dart | 12 +- Fly App/lib/ui/fly/panels/gimbal_sheet.dart | 335 ++++++++++++++++++ Fly App/test/fly/fake_dji.dart | 21 ++ Fly App/test/fly/gimbal_test.dart | 321 +++++++++++++++++ Fly App/test/fly/profile_fixtures.dart | 11 +- 19 files changed, 1600 insertions(+), 102 deletions(-) create mode 100644 Fly App/lib/fly/gimbal_control.dart create mode 100644 Fly App/lib/fly/state/settings_overlay.dart create mode 100644 Fly App/lib/ui/fly/overlays/gimbal_drag_layer.dart create mode 100644 Fly App/lib/ui/fly/panels/gimbal_sheet.dart create mode 100644 Fly App/test/fly/gimbal_test.dart diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt index e09f877..15c8dcc 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/GimbalBridge.kt @@ -1,7 +1,12 @@ package com.pilotvault.flyapp +import dji.common.error.DJIError +import dji.common.gimbal.Axis +import dji.common.gimbal.CapabilityKey +import dji.common.gimbal.GimbalMode import dji.common.gimbal.Rotation import dji.common.gimbal.RotationMode +import dji.common.util.CommonCallbacks import dji.sdk.base.BaseProduct import dji.sdk.gimbal.Gimbal import dji.sdk.products.Aircraft @@ -9,11 +14,91 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel /** - * Gimbal pitch control (from the flight HUD's pitch slider) and gimbal attitude, - * published as the `gimbal` fast group of the [TelemetryHub] (`{pitch, roll, yaw}`). + * Gimbal control and state (plan P5). + * + * - **Attitude and status** go out as the `gimbal` fast group of the + * [TelemetryHub]: `{pitch, roll, yaw, mode, yawRelative, *FineTune, + * calibrating, calibrationProgress, calibrationSuccessful, pitchAtStop, + * yawAtStop, motorOverloaded}`. + * - **Settings** the SDK only exposes as getters (pitch extension, yaw follow, + * the controller's smoothing / max speed / speed coefficient per axis) are + * read on bind, again once the gimbal has handshaken, and after every write, + * into the `gimbalSettings` slow group. A setting the gimbal does not support + * is never read, so it stays absent and its row hides. + * - **Control**: `rotateGimbalPitch` (absolute, the pitch slider), + * `rotateGimbalSpeed` (speed mode, the drag-on-video joystick — the caller + * repeats it at 10 Hz and ends with zero), `setGimbalMode`, `resetGimbal`, + * `resetGimbalYaw`, `fineTuneGimbal`, `startGimbalCalibration`, + * `setGimbalSetting {id, value}` and `resetGimbalControlParameters`. */ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge { + /** A setting backed by an SDK getter / setter pair, by its `gimbalSettings` id. */ + private class Setting( + val capability: CapabilityKey?, + val read: (Gimbal, CommonCallbacks.CompletionCallbackWith) -> Unit, + val write: (Gimbal, Any, CommonCallbacks.CompletionCallback) -> Unit, + ) + + private fun intSetting( + capability: CapabilityKey, + get: (Gimbal, CommonCallbacks.CompletionCallbackWith) -> Unit, + set: (Gimbal, Int, CommonCallbacks.CompletionCallback) -> Unit, + ) = Setting( + capability, + { g, cb -> get(g, forward(cb)) }, + { g, v, cb -> set(g, (v as Number).toInt(), cb) }, + ) + + private fun forward(cb: CommonCallbacks.CompletionCallbackWith) = object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(value: T) = cb.onSuccess(value as Any) + override fun onFailure(error: DJIError) = cb.onFailure(error) + } + + private val settings: Map = mapOf( + "pitchExtension" to Setting( + CapabilityKey.PITCH_RANGE_EXTENSION, + { g, cb -> g.getPitchRangeExtensionEnabled(forward(cb)) }, + { g, v, cb -> g.setPitchRangeExtensionEnabled(v as Boolean, cb) }, + ), + // No capability key: read it, and a gimbal without it answers with an error. + "yawFollow" to Setting( + null, + { g, cb -> g.getYawSimultaneousFollowEnabled(forward(cb)) }, + { g, v, cb -> g.setYawSimultaneousFollowEnabled(v as Boolean, cb) }, + ), + "pitchSmoothing" to intSetting( + CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR, + { g, cb -> g.getControllerSmoothingFactor(Axis.PITCH, cb) }, + { g, v, cb -> g.setControllerSmoothingFactor(Axis.PITCH, v, cb) }, + ), + "yawSmoothing" to intSetting( + CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR, + { g, cb -> g.getControllerSmoothingFactor(Axis.YAW, cb) }, + { g, v, cb -> g.setControllerSmoothingFactor(Axis.YAW, v, cb) }, + ), + "pitchMaxSpeed" to intSetting( + CapabilityKey.PITCH_CONTROLLER_MAX_SPEED, + { g, cb -> g.getControllerMaxSpeed(Axis.PITCH, cb) }, + { g, v, cb -> g.setControllerMaxSpeed(Axis.PITCH, v, cb) }, + ), + "yawMaxSpeed" to intSetting( + CapabilityKey.YAW_CONTROLLER_MAX_SPEED, + { g, cb -> g.getControllerMaxSpeed(Axis.YAW, cb) }, + { g, v, cb -> g.setControllerMaxSpeed(Axis.YAW, v, cb) }, + ), + "pitchSpeedCoefficient" to intSetting( + CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT, + { g, cb -> g.getControllerSpeedCoefficient(Axis.PITCH, cb) }, + { g, v, cb -> g.setControllerSpeedCoefficient(Axis.PITCH, v, cb) }, + ), + "yawSpeedCoefficient" to intSetting( + CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT, + { g, cb -> g.getControllerSpeedCoefficient(Axis.YAW, cb) }, + { g, v, cb -> g.setControllerSpeedCoefficient(Axis.YAW, v, cb) }, + ), + ) + private fun gimbal(): Gimbal? = ctx.aircraft()?.gimbal override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { @@ -28,7 +113,54 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge { .build() it.rotate(rotation, ctx.completion(result)) } + "rotateGimbalSpeed" -> guard(g, result) { + // Degrees per second; an axis left at NO_ROTATION does not move. + val b = Rotation.Builder().mode(RotationMode.SPEED) + call.argument("pitch")?.let { p -> b.pitch(p.toFloat()) } + call.argument("yaw")?.let { y -> b.yaw(y.toFloat()) } + it.rotate(b.build(), ctx.completion(result)) + } + "setGimbalMode" -> guard(g, result) { + val mode = enumByName(call.argument("mode")) + if (mode == null || mode == GimbalMode.UNKNOWN) ctx.fail(result, "Unknown gimbal mode") + else it.setMode(mode, ctx.completion(result)) + } + // Pitch and yaw back to centre (GO 4 "Recenter Gimbal"). "resetGimbal" -> guard(g, result) { it.reset(ctx.completion(result)) } + "resetGimbalYaw" -> guard(g, result) { it.resetYaw(ctx.completion(result)) } + "fineTuneGimbal" -> guard(g, result) { + val deg = (call.argument("degrees") ?: 0.0).toFloat() + when (call.argument("axis")) { + "roll" -> it.fineTuneRollInDegrees(deg, ctx.completion(result)) + "pitch" -> it.fineTunePitchInDegrees(deg, ctx.completion(result)) + "yaw" -> it.fineTuneYawInDegrees(deg, ctx.completion(result)) + else -> ctx.fail(result, "Unknown axis") + } + } + "startGimbalCalibration" -> guard(g, result) { it.startCalibration(ctx.completion(result)) } + "setGimbalSetting" -> guard(g, result) { + val id = call.argument("id") + val s = settings[id] + val value = call.argument("value") + if (s == null || id == null || value == null) { + ctx.fail(result, "Unknown gimbal setting $id") + } else { + s.write(it, value, object : CommonCallbacks.CompletionCallback { + override fun onResult(error: DJIError?) { + ctx.completion(result).onResult(error) + readSetting(it, id, s) + } + }) + } + } + "resetGimbalControlParameters" -> guard(g, result) { + it.resetControlParameters(object : CommonCallbacks.CompletionCallback { + override fun onResult(error: DJIError?) { + ctx.completion(result).onResult(error) + readSettings(it) + } + }) + } else -> return false } return true @@ -40,10 +172,59 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge { override fun bind(product: BaseProduct?) { if (product !is Aircraft) return - product.gimbal?.setStateCallback { state -> + val g = product.gimbal ?: return + g.setStateCallback { state -> val a = state.attitudeInDegrees - val values = mapOf("pitch" to a?.pitch, "roll" to a?.roll, "yaw" to a?.yaw) + val values = mapOf( + "pitch" to a?.pitch, + "roll" to a?.roll, + "yaw" to a?.yaw, + "mode" to state.mode?.name, + "yawRelative" to state.yawRelativeToAircraftHeading, + "pitchFineTune" to state.pitchFineTuneInDegrees, + "rollFineTune" to state.rollFineTuneInDegrees, + "yawFineTune" to state.yawFineTuneInDegrees, + "calibrating" to state.isCalibrating, + "calibrationProgress" to state.calibrationProgress, + "calibrationSuccessful" to state.isCalibrationSuccessful, + "pitchAtStop" to state.isPitchAtStop, + "yawAtStop" to state.isYawAtStop, + "motorOverloaded" to state.isMotorOverloaded, + ) ctx.mainHandler.post { ctx.hub.putFast(mapOf("gimbal" to values)) } } + readSettings(g) + // Capabilities are empty until the gimbal has handshaken; read again then. + ctx.mainHandler.postDelayed({ if (gimbal() === g) readSettings(g) }, 3000) } + + private fun readSettings(g: Gimbal) { + for ((id, s) in settings) readSetting(g, id, s) + } + + private fun readSetting(g: Gimbal, id: String, s: Setting) { + if (s.capability != null) { + val caps = try { + g.capabilities + } catch (_: Throwable) { + null + } + if (caps?.get(s.capability)?.isSupported != true) return + } + try { + s.read(g, object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(value: Any) { + val v = KeyValues.toDart(value) + ctx.mainHandler.post { ctx.hub.patchSlow("gimbalSettings", mapOf(id to v)) } + } + + override fun onFailure(error: DJIError) {} + }) + } catch (_: Throwable) { + // Not implemented for this gimbal: the setting stays absent. + } + } + + private inline fun > enumByName(name: String?): T? = + enumValues().firstOrNull { it.name == name } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt index 2d39c1e..5b1f0f6 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt @@ -223,6 +223,15 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge { "yawAdjustable" to supported(CapabilityKey.ADJUST_YAW), "rollAdjustable" to supported(CapabilityKey.ADJUST_ROLL), "pitchRangeExtensionSupported" to supported(CapabilityKey.PITCH_RANGE_EXTENSION), + // The controller tuning ranges behind the gimbal settings' advanced rows (P5). + "controller" to mapOf( + "pitchSmoothing" to range(CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR), + "yawSmoothing" to range(CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR), + "pitchMaxSpeed" to range(CapabilityKey.PITCH_CONTROLLER_MAX_SPEED), + "yawMaxSpeed" to range(CapabilityKey.YAW_CONTROLLER_MAX_SPEED), + "pitchSpeedCoefficient" to range(CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT), + "yawSpeedCoefficient" to range(CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT), + ).filterValues { it != null }, ) } diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index f1fe0ea..b0b2ea7 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -197,9 +197,41 @@ class DjiService { Future restoreCameraDefaults() => _methods.invokeMethod('restoreCameraDefaults'); // ── Gimbal ────────────────────────────────────────────────────────────────── + /// Absolute pitch in degrees (the pitch slider). Future rotateGimbalPitch(double deg) => _methods.invokeMethod('rotateGimbalPitch', {'pitch': deg}); + + /// Speed-mode rotation in °/s (drag on the video). The gimbal only keeps + /// moving while this is repeated (10 Hz); send zero to stop. A null axis is + /// left alone. + Future rotateGimbalSpeed({double? pitch, double? yaw}) => + _methods.invokeMethod('rotateGimbalSpeed', { + if (pitch != null) 'pitch': pitch, + if (yaw != null) 'yaw': yaw, + }); + + /// mode: `YAW_FOLLOW` | `FPV` | `FREE` + Future setGimbalMode(String mode) => + _methods.invokeMethod('setGimbalMode', {'mode': mode}); + + /// Pitch and yaw back to centre. Future resetGimbal() => _methods.invokeMethod('resetGimbal'); + Future resetGimbalYaw() => _methods.invokeMethod('resetGimbalYaw'); + + /// Nudges the gimbal's [axis] (`roll` | `pitch` | `yaw`) trim by [degrees]. + Future fineTuneGimbal(String axis, double degrees) => + _methods.invokeMethod('fineTuneGimbal', {'axis': axis, 'degrees': degrees}); + + /// Auto-calibration; `telemetry.gimbal.calibrating` / `calibrationProgress` follow it. + Future startGimbalCalibration() => _methods.invokeMethod('startGimbalCalibration'); + + /// One `gimbalSettings` value by id (`pitchExtension`, `yawFollow`, + /// `pitchSmoothing`, `yawMaxSpeed`, …) — see the table in `GimbalBridge.kt`. + Future setGimbalSetting(String id, Object value) => + _methods.invokeMethod('setGimbalSetting', {'id': id, 'value': value}); + + /// The controller tuning values back to their defaults. + Future resetGimbalControlParameters() => _methods.invokeMethod('resetGimbalControlParameters'); // ── Missions ──────────────────────────────────────────────────────────────── /// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT diff --git a/Fly App/lib/fly/gimbal_control.dart b/Fly App/lib/fly/gimbal_control.dart new file mode 100644 index 0000000..7e194de --- /dev/null +++ b/Fly App/lib/fly/gimbal_control.dart @@ -0,0 +1,138 @@ +import 'dart:math' as math; +import 'dart:ui' show Offset; + +import 'product_profile.dart'; +import 'state/aircraft_state.dart'; +import 'state/gimbal_state.dart'; + +/// Pitch / yaw speed in °/s for `Gimbal.rotate` in speed mode; [yaw] is null +/// when the gimbal does not pan, so that axis is left alone. +class GimbalSpeed { + const GimbalSpeed(this.pitch, this.yaw); + + static const GimbalSpeed zero = GimbalSpeed(0, 0); + + final double pitch; + final double? yaw; + + bool get isZero => pitch == 0 && (yaw ?? 0) == 0; + + @override + bool operator ==(Object other) => other is GimbalSpeed && other.pitch == pitch && other.yaw == yaw; + + @override + int get hashCode => Object.hash(pitch, yaw); + + @override + String toString() => 'GimbalSpeed(${pitch.toStringAsFixed(1)}, ${yaw?.toStringAsFixed(1)})'; +} + +/// Press-and-drag on the live view as a gimbal joystick (GO 4 +/// `fpv_gimbal_control`, plan P5): the press point is the centre, and how far +/// the finger has moved from it sets the speed — up tilts the camera up, right +/// pans right. A small dead zone keeps a resting finger still, and the curve is +/// squared so short drags give fine control. +abstract final class GimbalJoystick { + /// Finger travel (logical px) for full speed. + static const double reach = 120; + + /// Finger travel that still means "hold". + static const double deadZone = 8; + + /// Full-deflection speed, °/s. + static const double maxSpeed = 30; + + /// The speed for a finger [delta] away from the press point. + static GimbalSpeed speedFor(Offset delta, {required bool yaw}) => + GimbalSpeed(_axis(-delta.dy), yaw ? _axis(delta.dx) : null); + + static double _axis(double d) { + final double mag = d.abs(); + if (mag <= deadZone) return 0; + final double t = ((mag - deadZone) / (reach - deadZone)).clamp(0.0, 1.0); + final double v = maxSpeed * t * t; + return d < 0 ? -v : v; + } + + /// The finger offset clamped to the joystick ring, for drawing the knob. + static Offset knob(Offset delta) { + final double d = delta.distance; + return d <= reach ? delta : delta * (reach / d); + } +} + +/// A gimbal mode the pilot can pick. +class GimbalModeOption { + const GimbalModeOption(this.value, this.label, this.hint); + final String value; + final String label; + final String hint; +} + +abstract final class GimbalModes { + static const GimbalModeOption follow = + GimbalModeOption('YAW_FOLLOW', 'Follow', 'Horizon stays level; the camera turns with the aircraft.'); + static const GimbalModeOption fpv = + GimbalModeOption('FPV', 'FPV', 'The camera rolls with the aircraft, like a cockpit view.'); + static const GimbalModeOption free = + GimbalModeOption('FREE', 'Free', 'The camera holds its heading while the aircraft turns.'); + + /// Follow and FPV on every aircraft gimbal; Free only where the gimbal pans + /// on its own (Inspire-class), since it needs an independent yaw axis. + static List optionsFor(GimbalProfile? g) => + [follow, fpv, if (g?.yawAdjustable == true) free]; + + static String label(String? mode) => switch (mode) { + 'YAW_FOLLOW' => follow.label, + 'FPV' => fpv.label, + 'FREE' => free.label, + _ => '—', + }; +} + +/// One controller tuning value of the gimbal sheet's Advanced section, +/// bound to a `gimbalSettings` id and the profile's range for it. +class GimbalTuning { + const GimbalTuning(this.id, this.label); + final String id; + final String label; + + /// Steps of about a twentieth of the range, so − / + gets across it quickly. + static int stepFor(AxisRange r) => math.max(1, ((r.max - r.min) / 20).round()); + + static const List all = [ + GimbalTuning('pitchMaxSpeed', 'Max pitch speed'), + GimbalTuning('pitchSmoothing', 'Pitch smoothness'), + GimbalTuning('pitchSpeedCoefficient', 'Pitch stick sensitivity'), + GimbalTuning('yawMaxSpeed', 'Max yaw speed'), + GimbalTuning('yawSmoothing', 'Yaw smoothness'), + GimbalTuning('yawSpeedCoefficient', 'Yaw stick sensitivity'), + ]; + + /// The rows this gimbal has a range for; all of them as a preview when + /// nothing is connected. + static List<(GimbalTuning, AxisRange)> rowsFor(ProductProfile p) => <(GimbalTuning, AxisRange)>[ + for (final GimbalTuning t in all) + if (!p.connected) + (t, const AxisRange(0, 100)) + else if (p.gimbal?.controller[t.id] case final AxisRange r) + (t, r), + ]; +} + +/// The one-tap gimbal actions of the sheet and when they are refused. +abstract final class GimbalActions { + /// Fine-tune nudge per tap, degrees (GO 4's Adjust Gimbal step). + static const double fineTuneStep = 0.1; + + /// The pitch for "face down": straight down, or as far as this gimbal goes. + static double faceDownPitch(AxisRange? pitch) => math.max(-90, pitch?.min ?? -90); + + /// Auto-calibration needs the aircraft still on the ground with the motors + /// off, and is pointless while one is already running. + static String? calibrationBlocked(AircraftState a, GimbalState g) { + if (a.isFlying == true || a.motorsOn == true) return 'Land and stop the motors first'; + if (g.calibrating) return 'Calibrating…'; + return null; + } +} diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index eb1c006..9d556c3 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -70,10 +70,20 @@ abstract final class HudFeatures { static final HudFeature gimbalPitch = HudFeature( 'gimbal.pitch', 'Gimbal pitch slider', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true); static final HudFeature gimbalYaw = HudFeature( - 'gimbal.yaw', 'Gimbal yaw drag', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true); + 'gimbal.yaw', 'Gimbal yaw (drag, recenter, Free mode)', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true); static final HudFeature gimbalPitchExtension = HudFeature( 'gimbal.pitchExtension', 'Extend pitch to +30°', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchRangeExtensionSupported == true); + static final HudFeature gimbalDrag = HudFeature( + 'gimbal.drag', 'Drag on video to aim', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true); + static final HudFeature gimbalSettings = HudFeature( + 'gimbal.settings', 'Gimbal settings (mode, recenter)', 'Gimbal', (ProductProfile p) => p.hasGimbal); + static final HudFeature gimbalRollAdjust = HudFeature( + 'gimbal.rollAdjust', 'Adjust gimbal roll', 'Gimbal', (ProductProfile p) => p.hasGimbal); + static final HudFeature gimbalCalibration = HudFeature( + 'gimbal.calibration', 'Auto calibrate gimbal', 'Gimbal', (ProductProfile p) => p.hasGimbal); + static final HudFeature gimbalAdvanced = HudFeature('gimbal.advanced', 'Gimbal speed / smoothness', 'Gimbal', + (ProductProfile p) => p.gimbal?.controller.isNotEmpty == true); // ── Vision / control ────────────────────────────────────────────────────── static final HudFeature vpsReadout = HudFeature( @@ -116,6 +126,11 @@ abstract final class HudFeatures { gimbalPitch, gimbalYaw, gimbalPitchExtension, + gimbalDrag, + gimbalSettings, + gimbalRollAdjust, + gimbalCalibration, + gimbalAdvanced, vpsReadout, visionRadar, virtualSticks, diff --git a/Fly App/lib/fly/product_profile.dart b/Fly App/lib/fly/product_profile.dart index ee5603a..5e77529 100644 --- a/Fly App/lib/fly/product_profile.dart +++ b/Fly App/lib/fly/product_profile.dart @@ -160,6 +160,7 @@ class GimbalProfile { this.yawAdjustable = false, this.rollAdjustable = false, this.pitchRangeExtensionSupported = false, + this.controller = const {}, }); /// A gimbal whose capability map has not arrived yet (`capabilitiesKnown` @@ -174,6 +175,11 @@ class GimbalProfile { yawAdjustable: m['yawAdjustable'] as bool? ?? false, rollAdjustable: m['rollAdjustable'] as bool? ?? false, pitchRangeExtensionSupported: m['pitchRangeExtensionSupported'] as bool? ?? false, + controller: { + if (m['controller'] case final Map c) + for (final MapEntry e in c.entries) + if (AxisRange.fromMap(e.value) case final AxisRange r) '${e.key}': r, + }, ); final int index; @@ -186,6 +192,11 @@ class GimbalProfile { /// The +30° upward extension (Phantom 4 / Mavic Pro "Extend gimbal pitch"). final bool pitchRangeExtensionSupported; + + /// Ranges of the controller tuning values this gimbal supports, keyed by + /// their `gimbalSettings` id (`pitchSmoothing`, `yawMaxSpeed`, + /// `pitchSpeedCoefficient`, …). Empty on gimbals without advanced settings. + final Map controller; } class BatteryProfile { diff --git a/Fly App/lib/fly/state/camera_state.dart b/Fly App/lib/fly/state/camera_state.dart index 1ddaf1e..35d7d60 100644 --- a/Fly App/lib/fly/state/camera_state.dart +++ b/Fly App/lib/fly/state/camera_state.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'parse.dart'; +import 'settings_overlay.dart'; /// The camera's storage (SD card, or internal storage on cameras that have it). @immutable @@ -145,7 +146,7 @@ class ZoomInfo { /// the watched camera settings. Recording and exposure arrive as their own /// `camera` / `exposure` events; storage and settings as the `storage` and /// `cameraSettings` groups of the telemetry tick (≤2 Hz). -class CameraState extends ChangeNotifier { +class CameraState extends ChangeNotifier with SettingsOverlay { bool isRecording = false; int recordSeconds = 0; @@ -189,25 +190,10 @@ class CameraState extends ChangeNotifier { String? get videoFileFormat => asString(settings['videoFileFormat']); // MOV / MP4 String? get colorProfile => asString(settings['colorProfile']); // NONE / D_LOG / … - /// The whole `cameraSettings` group — every id of `CameraBridge.kt`'s - /// settings table plus the composite values — as the camera reported it, - /// overlaid with the camera menu's pending writes. The menu's rows read it - /// through [setting]. - Map get settings => _pending.isEmpty ? _reported : {..._reported, ..._pending}; - - /// Only what the camera itself reported. - Map get reportedSettings => _reported; - - Map _reported = const {}; - - /// Values written ahead of the camera's read-back. Each one stands until the - /// camera reports that setting changed, the write fails, or the menu gives up - /// waiting ([dropPending]) — so a value the camera silently refused snaps back. - final Map _pending = {}; - - bool isPending(String id) => _pending.containsKey(id); - - Object? setting(String id) => settings[id]; + // [settings] ([SettingsOverlay]) is the whole `cameraSettings` group — + // every id of `CameraBridge.kt`'s settings table plus the composite values — + // overlaid with the camera menu's pending writes. The menu's rows read it + // through [setting]. /// `PROGRAM` / `SHUTTER_PRIORITY` / `APERTURE_PRIORITY` / `MANUAL`. String? get exposureMode => asString(settings['exposureMode']); @@ -251,22 +237,6 @@ class CameraState extends ChangeNotifier { histogram.value = bins is List ? [for (final Object? b in bins) asInt(b) ?? 0] : null; } - /// Shows [values] ahead of the camera's read-back (the camera menu's - /// optimistic write). - void setPending(Map values) { - _pending.addAll(values); - notifyListeners(); - } - - /// Rolls [ids] back to what the camera last reported. - void dropPending(Iterable ids) { - bool changed = false; - for (final String id in ids) { - changed = _pending.remove(id) != null || changed; - } - if (changed) notifyListeners(); - } - /// Sets [mode] ahead of the camera's own `camera` event, so the HUD follows /// the photo / video switch at once. void setModeOptimistic(String mode) { @@ -295,10 +265,7 @@ class CameraState extends ChangeNotifier { this.storage = storage == null ? null : StorageInfo.fromMap(storage); this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd); thermalSpot = finite(thermal?['spotTemperature']); - final Map reported = settings ?? const {}; - // The camera answered a pending write once that setting moves. - _pending.removeWhere((String id, _) => !deepEquals(_reported[id], reported[id])); - _reported = reported; + replaceReported(settings ?? const {}); updatedAt = DateTime.now(); notifyListeners(); } @@ -309,8 +276,7 @@ class CameraState extends ChangeNotifier { shootingSingle = shootingInterval = shootingBurst = shootingPanorama = false; panoramaProgress = 0; storingPhoto = overheating = hasError = false; - _reported = const {}; - _pending.clear(); + clearSettings(); histogram.value = null; mode = iso = shutter = aperture = ev = null; storage = null; diff --git a/Fly App/lib/fly/state/gimbal_state.dart b/Fly App/lib/fly/state/gimbal_state.dart index ede176c..115f594 100644 --- a/Fly App/lib/fly/state/gimbal_state.dart +++ b/Fly App/lib/fly/state/gimbal_state.dart @@ -1,25 +1,88 @@ import 'package:flutter/foundation.dart'; import 'parse.dart'; +import 'settings_overlay.dart'; -/// Gimbal attitude in degrees. Fed from the `gimbal` group of the telemetry tick. -class GimbalState extends ChangeNotifier { +/// Gimbal attitude, mode and status, fed from the `gimbal` group of the +/// telemetry tick; the getter-only settings (pitch extension, yaw follow, +/// controller tuning) from the `gimbalSettings` group, overlaid with the +/// gimbal sheet's pending writes ([SettingsOverlay]). +class GimbalState extends ChangeNotifier with SettingsOverlay { double? pitch; // −90..+30-ish double? roll; double? yaw; + /// `YAW_FOLLOW` / `FPV` / `FREE`. + String? mode; + + /// Yaw relative to the aircraft's nose, for gimbals that pan. + double? yawRelative; + + double? pitchFineTune; + double? rollFineTune; + double? yawFineTune; + + bool calibrating = false; + int? calibrationProgress; // 0..100 + bool? calibrationSuccessful; + + /// How the last calibration seen from start to end went; null until one ends. + bool? lastCalibrationOk; + + bool pitchAtStop = false; + bool yawAtStop = false; + bool motorOverloaded = false; + DateTime? updatedAt; + // The `gimbalSettings` group, as the sheet shows it (pending writes included). + bool? get pitchExtension => asBool(settings['pitchExtension']); + bool? get yawFollow => asBool(settings['yawFollow']); + void apply(Map g) { pitch = finite(g['pitch']); roll = finite(g['roll']); yaw = finite(g['yaw']); + mode = asString(g['mode']); + yawRelative = finite(g['yawRelative']); + pitchFineTune = finite(g['pitchFineTune']); + rollFineTune = finite(g['rollFineTune']); + yawFineTune = finite(g['yawFineTune']); + final bool wasCalibrating = calibrating; + calibrating = asBool(g['calibrating']) ?? false; + calibrationProgress = asInt(g['calibrationProgress']); + calibrationSuccessful = asBool(g['calibrationSuccessful']); + if (wasCalibrating && !calibrating) lastCalibrationOk = calibrationSuccessful; + pitchAtStop = asBool(g['pitchAtStop']) ?? false; + yawAtStop = asBool(g['yawAtStop']) ?? false; + motorOverloaded = asBool(g['motorOverloaded']) ?? false; updatedAt = DateTime.now(); notifyListeners(); } + /// From the telemetry tick; an absent group means the gimbal reported none. + void applySettings(Map? s) { + replaceReported(s ?? const {}); + notifyListeners(); + } + + /// Shows [mode] ahead of the gimbal's next state callback. + void setModeOptimistic(String mode) { + if (this.mode == mode) return; + this.mode = mode; + notifyListeners(); + } + void clear() { - pitch = roll = yaw = null; + pitch = roll = yaw = yawRelative = null; + pitchFineTune = rollFineTune = yawFineTune = null; + mode = null; + calibrating = false; + calibrationProgress = null; + calibrationSuccessful = null; + lastCalibrationOk = null; + pitchAtStop = yawAtStop = motorOverloaded = false; + clearSettings(); updatedAt = null; notifyListeners(); } diff --git a/Fly App/lib/fly/state/settings_overlay.dart b/Fly App/lib/fly/state/settings_overlay.dart new file mode 100644 index 0000000..82fa36a --- /dev/null +++ b/Fly App/lib/fly/state/settings_overlay.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; + +import 'parse.dart'; + +/// Settings as a component reported them, overlaid with the HUD's optimistic +/// writes (plan P4, reused by the gimbal in P5). +/// +/// A pending value stands until the component reports that setting changed, +/// the write fails, or the writer gives up waiting ([dropPending]) — so a +/// value the component silently refused snaps back. +mixin SettingsOverlay on ChangeNotifier { + Map _reported = const {}; + final Map _pending = {}; + + /// What the component reported, overlaid with pending writes. + Map get settings => _pending.isEmpty ? _reported : {..._reported, ..._pending}; + + /// Only what the component itself reported. + Map get reportedSettings => _reported; + + bool isPending(String id) => _pending.containsKey(id); + + Object? setting(String id) => settings[id]; + + /// Shows [values] ahead of the component's read-back. + void setPending(Map values) { + _pending.addAll(values); + notifyListeners(); + } + + /// Rolls [ids] back to what the component last reported. + void dropPending(Iterable ids) { + bool changed = false; + for (final String id in ids) { + changed = _pending.remove(id) != null || changed; + } + if (changed) notifyListeners(); + } + + /// Takes a new report; a pending write is answered once its setting moves. + /// Does not notify — the caller does, with its other fields. + @protected + void replaceReported(Map reported) { + _pending.removeWhere((String id, _) => !deepEquals(_reported[id], reported[id])); + _reported = reported; + } + + @protected + void clearSettings() { + _reported = const {}; + _pending.clear(); + } +} diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index e7ed743..962c7e0 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`, -/// `vision`). It arrives at +/// `gimbalSettings`, `vision`). 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', 'vision']; + static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision']; /// Applies [event] if it is one of the telemetry types; returns whether it was. bool apply(Map event) { @@ -83,6 +83,7 @@ class TelemetryReducer { final Map? g = asMap(t['gimbal']); g == null ? model.gimbal.clear() : model.gimbal.apply(g); } + if (changed.contains('gimbalSettings')) model.gimbal.applySettings(asMap(t['gimbalSettings'])); if (changed.contains('vision')) model.vision.apply(asMap(t['vision'])); } } diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 554223f..a948c98 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -10,6 +10,7 @@ import '../../fly/safety_gate.dart'; import '../../theme.dart'; import '../album_page.dart'; import '../capture_modes_page.dart'; +import '../pv_icons.dart'; import '../settings_menu_page.dart'; import 'bottom/attitude_ball.dart'; import 'bottom/minimap.dart'; @@ -21,11 +22,13 @@ import 'left/action_rail.dart'; import 'left/gimbal_slider.dart'; import 'overlays/alert_stack.dart'; import 'overlays/camera_touch_layer.dart'; +import 'overlays/gimbal_drag_layer.dart'; import 'overlays/histogram_view.dart'; import 'overlays/safety_banners.dart'; import 'panels/aircraft_status_sheet.dart'; import 'panels/camera_menu/camera_menu_sheet.dart'; import 'panels/camera_quick_exposure.dart'; +import 'panels/gimbal_sheet.dart'; import 'right/camera_column.dart'; import 'right/zoom_column.dart'; import 'top/camera_info_strip.dart'; @@ -52,6 +55,10 @@ import 'top/status_bar.dart'; /// Camera (plan P4): the camera column, the zoom column on zoom cameras, the /// quick exposure panel (from the camera info strip), the tabbed camera menu, /// tap-to-focus / tap-to-meter on the video and the histogram. +/// +/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag +/// on the video as a speed joystick, and the gimbal settings sheet from the +/// button under the slider. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji}); @@ -154,6 +161,23 @@ class _FlyScreenState extends State { if (!_mapBig) ...[ Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), const Positioned.fill(child: VisualOverlays()), + // Under the camera touch layer, in the same gesture arena: drags aim + // the gimbal, taps and long presses still reach the camera. + Positioned.fill( + child: Requires( + HudFeatures.gimbalDrag, + child: ListenableBuilder( + listenable: _m, + builder: (BuildContext context, _) => GimbalDragLayer( + gimbal: _m.gimbal, + dji: _dji, + connected: _m.connected, + yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)), + onError: _error, + ), + ), + ), + ), Positioned.fill( child: Requires( HudFeatures.cameraControls, @@ -240,17 +264,32 @@ class _FlyScreenState extends State { left: 54, top: 64, bottom: _pipSize.height + 24, - child: Requires( - HudFeatures.gimbalPitch, - child: Builder( - builder: (BuildContext context) => GimbalSlider( - gimbal: _m.gimbal, - dji: _dji, - range: ProductProfileScope.of(context).gimbal?.pitch, - onError: _error, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: Requires( + HudFeatures.gimbalPitch, + child: Builder( + builder: (BuildContext context) => GimbalSlider( + gimbal: _m.gimbal, + dji: _dji, + range: ProductProfileScope.of(context).gimbal?.pitch, + onError: _error, + ), + ), ), ), - ), + const SizedBox(height: 8), + Requires( + HudFeatures.gimbalSettings, + child: GlassPill( + key: const ValueKey('gimbal-settings-button'), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 6), + onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error), + child: const PVIcon('gimbal', size: 16, color: Glass.ink), + ), + ), + ]), ), // ── Top bar, battery bar, camera info ───────────────────────────────── Positioned( diff --git a/Fly App/lib/ui/fly/left/gimbal_slider.dart b/Fly App/lib/ui/fly/left/gimbal_slider.dart index 505c1db..6e2410f 100644 --- a/Fly App/lib/ui/fly/left/gimbal_slider.dart +++ b/Fly App/lib/ui/fly/left/gimbal_slider.dart @@ -6,8 +6,10 @@ import '../../../fly/state/gimbal_state.dart'; import '../../../theme.dart'; import '../hud_kit.dart'; -/// Vertical gimbal pitch slider over the profile's pitch range, with the live -/// pitch read back from the gimbal. P5 adds drag-on-video and the mode settings. +/// Vertical gimbal pitch slider over the profile's pitch range (GO 4 +/// `fpv_gimbal_pitch_view`), with the live pitch read back from the gimbal +/// beside the thumb. While a finger is on it, a value bubble shows the pitch +/// being commanded, so the pilot sees the target before the gimbal gets there. class GimbalSlider extends StatefulWidget { const GimbalSlider({super.key, required this.gimbal, required this.dji, required this.range, required this.onError}); @@ -80,21 +82,12 @@ class _GimbalSliderState extends State { ), // The 0° mark, where the camera looks at the horizon. Positioned(left: 2, top: _fracOf(0) * h - 0.5, child: Container(width: 12, height: 1, color: HudText.dim)), + // Centred on the pitch: the thumb row is 16 high, the bubble 22. Positioned( - top: (frac * h - 8).clamp(0.0, h - 16), + top: _dragPitch == null ? (frac * h - 8).clamp(0.0, h - 16) : (frac * h - 11).clamp(-3.0, h - 19), left: 0, child: Row(children: [ - Container( - width: 16, - height: 16, - decoration: const BoxDecoration( - color: Glass.ink, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], - ), - ), - const SizedBox(width: 3), - Text('${pitch.round()}°', style: HudText.monoSmall), + if (_dragPitch == null) ..._thumb(pitch) else ..._bubble(pitch), ]), ), ]), @@ -104,4 +97,40 @@ class _GimbalSliderState extends State { ); }); } + + List _thumb(double pitch) => [ + Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Glass.ink, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], + ), + ), + const SizedBox(width: 3), + Text('${pitch.round()}°', style: HudText.monoSmall), + ]; + + /// The dragging thumb: accent-filled, with the target pitch in a pill. + List _bubble(double pitch) => [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: Glass.accent, + shape: BoxShape.circle, + border: Border.all(color: Glass.ink, width: 2), + ), + ), + const SizedBox(width: 5), + Container( + key: const ValueKey('gimbal-bubble'), + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)), + alignment: Alignment.center, + child: Text('${pitch.round()}°', style: HudText.value.copyWith(fontSize: 13)), + ), + ]; } diff --git a/Fly App/lib/ui/fly/overlays/gimbal_drag_layer.dart b/Fly App/lib/ui/fly/overlays/gimbal_drag_layer.dart new file mode 100644 index 0000000..e62afce --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/gimbal_drag_layer.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/gimbal_control.dart'; +import '../../../fly/state/gimbal_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// Press-and-drag on the live view aims the gimbal (GO 4 `fpv_gimbal_control`, +/// plan P5). +/// +/// The press point becomes a joystick centre and the finger's offset from it a +/// pitch (and, on gimbals that pan, yaw) speed ([GimbalJoystick]). Speed mode +/// only moves the gimbal while it is refreshed, so the speed is re-sent every +/// [period] while the finger is down — whether or not it moved — and a zero +/// goes out on release. +/// +/// It sits under [CameraTouchLayer] in the same gesture arena: a tap still +/// focuses and a still long press still meters; only a drag past the pan slop +/// lands here. +class GimbalDragLayer extends StatefulWidget { + const GimbalDragLayer({ + super.key, + required this.gimbal, + required this.dji, + required this.connected, + required this.yaw, + required this.onError, + }); + + final GimbalState gimbal; + final DjiService dji; + final bool connected; + + /// Whether this gimbal pans on its own, so horizontal drags drive yaw. + final bool yaw; + final void Function(String label, Object error) onError; + + /// 10 Hz, the rate the plan and DJI's speed mode ask for. + static const Duration period = Duration(milliseconds: 100); + + @override + State createState() => _GimbalDragLayerState(); +} + +class _GimbalDragLayerState extends State { + Offset? _origin; + Offset _delta = Offset.zero; + Timer? _ticker; + GimbalSpeed _lastSent = GimbalSpeed.zero; + bool _reported = false; + + @override + void dispose() { + _ticker?.cancel(); + if (!_lastSent.isZero) _send(_stop); + super.dispose(); + } + + GimbalSpeed get _speed => GimbalJoystick.speedFor(_delta, yaw: widget.yaw); + + void _start(DragStartDetails d) { + _reported = false; + setState(() { + _origin = d.localPosition; + _delta = Offset.zero; + }); + _ticker?.cancel(); + _ticker = Timer.periodic(GimbalDragLayer.period, (_) => _tick()); + } + + void _update(DragUpdateDetails d) { + final Offset? o = _origin; + if (o == null) return; + setState(() => _delta = d.localPosition - o); + } + + void _end() { + _ticker?.cancel(); + _ticker = null; + if (mounted) { + setState(() { + _origin = null; + _delta = Offset.zero; + }); + } + if (!_lastSent.isZero) _send(_stop); + } + + /// Zero on the axes this gimbal drives. + GimbalSpeed get _stop => GimbalSpeed(0, widget.yaw ? 0 : null); + + void _tick() { + final GimbalSpeed s = _speed; + // Holding inside the dead zone after a stop: nothing to refresh. + if (s.isZero && _lastSent.isZero) return; + _send(s); + } + + void _send(GimbalSpeed s) { + _lastSent = s; + if (!widget.connected) return; + widget.dji.rotateGimbalSpeed(pitch: s.pitch, yaw: s.yaw).catchError((Object e) { + // Once per drag: a failing gimbal would otherwise raise ten snack bars a second. + if (_reported) return; + _reported = true; + widget.onError('Gimbal', e); + }); + } + + @override + Widget build(BuildContext context) { + final Offset? o = _origin; + return GestureDetector( + key: const ValueKey('gimbal-drag'), + behavior: HitTestBehavior.translucent, + // The joystick centre is where the finger went down, not where it + // crossed the pan slop. + dragStartBehavior: DragStartBehavior.down, + onPanStart: _start, + onPanUpdate: _update, + onPanEnd: (_) => _end(), + onPanCancel: _end, + child: o == null + ? const SizedBox.expand() + : Stack(clipBehavior: Clip.none, children: [ + Positioned.fill( + child: IgnorePointer( + child: CustomPaint(painter: _JoystickPainter(o, GimbalJoystick.knob(_delta), widget.yaw)), + ), + ), + Positioned( + left: o.dx + 18, + top: o.dy - GimbalJoystick.reach - 22, + child: IgnorePointer( + child: ListenableBuilder( + listenable: widget.gimbal, + builder: (BuildContext context, _) => GlassPill( + child: Text( + 'P ${widget.gimbal.pitch?.round() ?? '—'}°' + '${widget.yaw ? ' Y ${widget.gimbal.yawRelative?.round() ?? '—'}°' : ''}', + style: HudText.mono, + ), + ), + ), + ), + ), + ]), + ); + } +} + +/// The centre mark, the reach ring, an arrow toward the finger and the knob. +class _JoystickPainter extends CustomPainter { + const _JoystickPainter(this.origin, this.knob, this.yaw); + + final Offset origin; + final Offset knob; + final bool yaw; + + @override + void paint(Canvas canvas, Size size) { + final Paint ring = Paint() + ..color = const Color(0x66EAF0FA) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle(origin, GimbalJoystick.reach, ring); + canvas.drawCircle(origin, GimbalJoystick.deadZone, ring); + // Pitch-only gimbals: a vertical track instead of a free ring feel. + if (!yaw) { + canvas.drawLine(origin.translate(0, -GimbalJoystick.reach), origin.translate(0, GimbalJoystick.reach), ring); + } + final Offset k = yaw ? knob : Offset(0, knob.dy); + final Offset end = origin + k; + if (k.distance > GimbalJoystick.deadZone) { + canvas.drawLine( + origin, + end, + Paint() + ..color = Glass.accent + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round, + ); + } + canvas.drawCircle(end, 18, Paint()..color = const Color(0x553D7BF0)); + canvas.drawCircle( + end, + 18, + Paint() + ..color = Glass.accent + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + + @override + bool shouldRepaint(covariant _JoystickPainter old) => old.origin != origin || old.knob != knob || old.yaw != yaw; +} diff --git a/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart index 6766114..0ee226f 100644 --- a/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart +++ b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart @@ -46,7 +46,7 @@ class CameraRowTile extends StatelessWidget { onError: onError, ), ), - SwitchRow() => _SwitchTile( + SwitchRow() => SwitchTile( key: ValueKey('row-${r.id}'), label: r.label, value: r.current(camera), @@ -223,8 +223,9 @@ class _RowFrame extends StatelessWidget { } } -class _SwitchTile extends StatelessWidget { - const _SwitchTile({super.key, required this.label, required this.value, required this.onChanged}); +/// An on / off row; a null [value] (not reported yet) shows as off. +class SwitchTile extends StatelessWidget { + const SwitchTile({super.key, required this.label, required this.value, required this.onChanged}); final String label; final bool? value; @@ -257,7 +258,7 @@ class PrefSwitchTile extends StatelessWidget { final ValueChanged onChanged; @override - Widget build(BuildContext context) => _SwitchTile(label: label, value: value, onChanged: onChanged); + Widget build(BuildContext context) => SwitchTile(label: label, value: value, onChanged: onChanged); } class _StepperTile extends StatelessWidget { @@ -270,14 +271,53 @@ class _StepperTile extends StatelessWidget { @override Widget build(BuildContext context) { final int? v = value; - Widget step(String label, int delta) { - final bool enabled = v != null && (v + delta) >= row.min && (v + delta) <= row.max; + bool can(int delta) => v != null && (v + delta) >= row.min && (v + delta) <= row.max; + return StepperTile( + id: row.id, + label: row.label, + shown: v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}', + canDown: can(-row.step), + canUp: can(row.step), + onStep: (int dir) => onChanged(v! + dir * row.step), + ); + } +} + +/// A − value + row. [onStep] gets −1 or +1; the caller owns the arithmetic, +/// so it fits integer settings and relative nudges (gimbal fine-tune) alike. +class StepperTile extends StatelessWidget { + const StepperTile({ + super.key, + required this.id, + required this.label, + required this.shown, + required this.canDown, + required this.canUp, + required this.onStep, + this.hint, + this.pending = false, + }); + + /// Keys the buttons as `step--minus` / `step--plus`. + final String id; + final String label; + final String shown; + final bool canDown; + final bool canUp; + final ValueChanged onStep; + final String? hint; + final bool pending; + + @override + Widget build(BuildContext context) { + Widget step(String label, int dir) { + final bool enabled = dir > 0 ? canUp : canDown; return Opacity( opacity: enabled ? 1 : 0.35, child: GestureDetector( - key: ValueKey('step-${row.id}-${delta > 0 ? 'plus' : 'minus'}'), + key: ValueKey('step-$id-${dir > 0 ? 'plus' : 'minus'}'), behavior: HitTestBehavior.opaque, - onTap: enabled ? () => onChanged(v + delta) : null, + onTap: enabled ? () => onStep(dir) : null, child: Container( width: 30, height: 28, @@ -290,16 +330,20 @@ class _StepperTile extends StatelessWidget { ); } - final String shown = v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}'; return _RowFrame( - label: row.label, + label: label, + hint: hint, trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - step('−', -row.step), + step('−', -1), SizedBox( width: 66, - child: Text(shown, textAlign: TextAlign.center, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)), + child: Text( + shown, + textAlign: TextAlign.center, + style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700), + ), ), - step('+', row.step), + step('+', 1), ]), ); } @@ -337,19 +381,51 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) { final String? blocked = row.blockedReason(camera); - final Color color = row.danger ? HudColors.danger : Glass.accent; - return _RowFrame( + return ButtonTile( + id: row.id, label: row.label, + button: row.button, hint: blocked, + danger: row.danger, + onTap: blocked == null ? () => _run(context) : null, + ); + } +} + +/// A row with one outlined button, keyed `action-`; a null [onTap] greys it out. +class ButtonTile extends StatelessWidget { + const ButtonTile({ + super.key, + required this.id, + required this.label, + required this.button, + required this.onTap, + this.hint, + this.danger = false, + }); + + final String id; + final String label; + final String button; + final VoidCallback? onTap; + final String? hint; + final bool danger; + + @override + Widget build(BuildContext context) { + final Color color = danger ? HudColors.danger : Glass.accent; + return _RowFrame( + label: label, + hint: hint, trailing: Opacity( - opacity: blocked == null ? 1 : 0.35, + opacity: onTap != null ? 1 : 0.35, child: GestureDetector( - key: ValueKey('action-${row.id}'), - onTap: blocked == null ? () => _run(context) : null, + key: ValueKey('action-$id'), + onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration(borderRadius: BorderRadius.circular(7), border: Border.all(color: color)), - child: Text(row.button, style: HudText.sans.copyWith(color: color)), + child: Text(button, style: HudText.sans.copyWith(color: color)), ), ), ), diff --git a/Fly App/lib/ui/fly/panels/camera_write.dart b/Fly App/lib/ui/fly/panels/camera_write.dart index 34b2663..434076d 100644 --- a/Fly App/lib/ui/fly/panels/camera_write.dart +++ b/Fly App/lib/ui/fly/panels/camera_write.dart @@ -1,18 +1,18 @@ import 'dart:async'; -import '../../../fly/state/camera_state.dart'; +import '../../../fly/state/settings_overlay.dart'; /// How long a written camera setting may stay unconfirmed before the HUD /// shows the camera's own value again. const Duration cameraSettle = Duration(seconds: 3); -/// Writes one camera setting the way every camera-menu row does (plan P4): -/// show [pending] at once, send, roll back with [onError] if the camera -/// refuses, and drop the pending value after [cameraSettle] in any case — by -/// then the camera has reported the new value (which replaces it) or ignored +/// Writes one camera (or gimbal) setting the way every camera-menu row does +/// (plan P4): show [pending] at once, send, roll back with [onError] if the +/// component refuses, and drop the pending value after [cameraSettle] in any +/// case — by then it has reported the new value (which replaces it) or ignored /// the write (and the row snaps back to what it really is). Future writeCameraSetting( - CameraState camera, { + SettingsOverlay camera, { required String label, required Map pending, required Future Function() send, diff --git a/Fly App/lib/ui/fly/panels/gimbal_sheet.dart b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart new file mode 100644 index 0000000..b08c9ab --- /dev/null +++ b/Fly App/lib/ui/fly/panels/gimbal_sheet.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../flight_model.dart'; +import '../../../fly/gimbal_control.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/product_profile.dart'; +import '../../../fly/state/gimbal_state.dart'; +import '../../../fly/state/parse.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../dialogs/slide_to_confirm.dart'; +import '../hud_kit.dart'; +import 'camera_menu/camera_rows.dart'; +import 'camera_write.dart'; + +/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the +/// live view, like the camera menu. +Future showGimbalSettings( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Gimbal settings', + barrierColor: const Color(0x55050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + // The dialog route sits outside the flight screen's scope. + child: ListenableBuilder( + listenable: model, + builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!), + child: _GimbalSheet(model: model, dji: dji, onError: onError), + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); +} + +class _GimbalSheet extends StatelessWidget { + const _GimbalSheet({required this.model, required this.dji, required this.onError}); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + + static const double width = 380; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: width, + height: MediaQuery.sizeOf(context).height, + child: SafeArea( + left: false, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 4, 6), + child: Row(children: [ + const PVIcon('gimbal', size: 16, color: Glass.ink), + const SizedBox(width: 8), + Expanded(child: Text('Gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ), + const Divider(height: 1, color: Glass.hairline), + Expanded(child: GimbalSettingsList(model: model, dji: dji, onError: onError)), + ]), + ), + ), + ); + } +} + +/// The gimbal rows — mode, position, adjust, calibration, advanced — as one +/// scrolling list, so the P6 settings sheet's Gimbal tab can host it as is. +/// +/// Rows hide per [HudFeature] on the connected profile, and the getter-only +/// settings also hide once the gimbal has reported and left them out. +class GimbalSettingsList extends StatelessWidget { + const GimbalSettingsList({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; + + GimbalState get _g => model.gimbal; + + /// A getter-only setting is there unless the gimbal reported and omitted it. + bool _reported(ProductProfile p, String id) => !p.connected || _g.reportedSettings[id] != null; + + Future _run(String label, Future Function() send) async { + try { + await send(); + } catch (e) { + onError(label, e); + } + } + + Future _write(String label, String id, Object value) => writeCameraSetting( + _g, + label: label, + pending: {id: value}, + send: () => dji.setGimbalSetting(id, value), + onError: onError, + ); + + Future _setMode(String mode) async { + final String? before = _g.mode; + _g.setModeOptimistic(mode); + try { + await dji.setGimbalMode(mode); + } catch (e) { + if (before != null) _g.setModeOptimistic(before); + onError('Gimbal mode', e); + } + } + + 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 _resetAdvanced(BuildContext context) async { + final SlideResult? ok = await showSlideToConfirm( + context, + const SlideConfirmSpec( + icon: 'gimbal', + title: 'Reset gimbal settings', + description: 'Speed, smoothness and sensitivity go back to their defaults.', + sliderLabel: 'Slide to reset', + ), + ); + if (ok == null) return; + await _run('Reset gimbal settings', dji.resetGimbalControlParameters); + } + + static Widget _section(String title) => Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text(title, style: HudText.label), + ); + + static String _signed(double v, {int decimals = 1}) => '${v > 0 ? '+' : ''}${v.toStringAsFixed(decimals)}°'; + + @override + Widget build(BuildContext context) { + final ProductProfile p = ProductProfileScope.of(context); + return ListenableBuilder( + listenable: Listenable.merge([_g, model.aircraft]), + builder: (BuildContext context, _) => ListView( + key: const ValueKey('gimbal-settings'), + padding: const EdgeInsets.only(bottom: 16), + children: [ + _status(p), + // ── Mode ────────────────────────────────────────────────────────── + ChoiceTile( + key: const ValueKey('row-gimbalMode'), + label: 'Gimbal mode', + options: <(String, String)>[for (final GimbalModeOption o in GimbalModes.optionsFor(p.gimbal)) (o.value, o.label)], + selected: _g.mode, + onSelect: _setMode, + ), + Requires( + HudFeatures.gimbalPitchExtension, + child: _reported(p, 'pitchExtension') + ? SwitchTile( + key: const ValueKey('row-pitchExtension'), + label: '30° extended upward', + value: _g.pitchExtension, + onChanged: (bool on) => _write('Extended pitch', 'pitchExtension', on), + ) + : const SizedBox.shrink(), + ), + Requires( + HudFeatures.gimbalYaw, + child: _reported(p, 'yawFollow') + ? SwitchTile( + key: const ValueKey('row-yawFollow'), + label: 'Yaw synchronous follow', + value: _g.yawFollow, + onChanged: (bool on) => _write('Yaw follow', 'yawFollow', on), + ) + : const SizedBox.shrink(), + ), + // ── Position ────────────────────────────────────────────────────── + _section('POSITION'), + ButtonTile( + id: 'recenter', + label: 'Recenter gimbal', + button: 'Recenter', + onTap: () => _run('Recenter gimbal', dji.resetGimbal), + ), + ButtonTile( + id: 'faceDown', + label: 'Face down', + button: 'Down', + onTap: () => _run('Gimbal down', () => dji.rotateGimbalPitch(GimbalActions.faceDownPitch(p.gimbal?.pitch))), + ), + Requires( + HudFeatures.gimbalYaw, + child: ButtonTile( + id: 'yawRecenter', + label: 'Yaw recenter', + button: 'Recenter', + onTap: () => _run('Yaw recenter', dji.resetGimbalYaw), + ), + ), + // ── Adjust ──────────────────────────────────────────────────────── + Requires( + HudFeatures.gimbalRollAdjust, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _section('ADJUST'), + StepperTile( + id: 'rollFineTune', + label: 'Roll fine-tune', + hint: 'Level the horizon against the video', + shown: _g.rollFineTune == null ? '—' : _signed(_g.rollFineTune!), + canDown: true, + canUp: true, + onStep: (int dir) => _run('Roll adjust', () => dji.fineTuneGimbal('roll', dir * GimbalActions.fineTuneStep)), + ), + ]), + ), + // ── Calibration ─────────────────────────────────────────────────── + Requires( + HudFeatures.gimbalCalibration, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _section('CALIBRATION'), + _calibrationRow(context), + ]), + ), + // ── Advanced ────────────────────────────────────────────────────── + Requires( + HudFeatures.gimbalAdvanced, + whenDisconnected: false, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _section('ADVANCED'), + for (final (GimbalTuning t, AxisRange r) in GimbalTuning.rowsFor(p)) + if (_g.reportedSettings[t.id] != null) _tuningRow(t, r), + ButtonTile( + id: 'resetAdvanced', + label: 'Reset all gimbal settings', + button: 'Reset', + danger: true, + onTap: () => _resetAdvanced(context), + ), + ]), + ), + ], + ), + ); + } + + /// Live attitude, plus whatever the gimbal is complaining about. + Widget _status(ProductProfile p) { + final List warnings = [ + if (_g.motorOverloaded) 'Motor overloaded — check for obstructions', + if (_g.pitchAtStop) 'Pitch at its limit', + if (_g.yawAtStop && (p.gimbal?.yawAdjustable ?? false)) 'Yaw at its limit', + ]; + String deg(double? v) => v == null ? '—' : '${v.round()}°'; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'PITCH ${deg(_g.pitch)} ROLL ${deg(_g.roll)} YAW ${deg(_g.yawRelative ?? _g.yaw)}', + key: const ValueKey('gimbal-attitude'), + style: HudText.mono, + ), + for (final String w in warnings) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(w, style: HudText.sans.copyWith(color: HudColors.caution)), + ), + ]), + ); + } + + Widget _calibrationRow(BuildContext context) { + final String? blocked = GimbalActions.calibrationBlocked(model.aircraft, _g); + final String? hint = _g.calibrating + ? 'Calibrating… ${_g.calibrationProgress ?? 0}%' + : blocked ?? + switch (_g.lastCalibrationOk) { + true => 'Calibrated', + false => 'Calibration failed — retry on level ground', + null => null, + }; + return ButtonTile( + id: 'calibrateGimbal', + label: 'Auto calibrate gimbal', + button: 'Calibrate', + hint: hint, + onTap: blocked == null ? () => _calibrate(context) : null, + ); + } + + Widget _tuningRow(GimbalTuning t, AxisRange r) { + final int? v = asInt(_g.setting(t.id)); + final int step = GimbalTuning.stepFor(r); + final int lo = r.min.round(), hi = r.max.round(); + return StepperTile( + key: ValueKey('row-${t.id}'), + id: t.id, + label: t.label, + shown: v?.toString() ?? '—', + pending: _g.isPending(t.id), + canDown: v != null && v > lo, + canUp: v != null && v < hi, + onStep: (int dir) => _write(t.label, t.id, (v! + dir * step).clamp(lo, hi)), + ); + } +} diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index d619152..7402240 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -92,4 +92,25 @@ class FakeDjiService extends DjiService { Future restoreCameraDefaults() => _record('restoreCameraDefaults'); @override Future setHistogramEnabled(bool on) => _record('setHistogramEnabled($on)'); + + // ── Gimbal (plan P5) ─────────────────────────────────────────────────────── + @override + Future rotateGimbalPitch(double deg) => _record('rotateGimbalPitch(${deg.round()})'); + @override + Future rotateGimbalSpeed({double? pitch, double? yaw}) => + _record('rotateGimbalSpeed(${pitch?.toStringAsFixed(1)}, ${yaw?.toStringAsFixed(1)})'); + @override + Future setGimbalMode(String mode) => _record('setGimbalMode($mode)'); + @override + Future resetGimbal() => _record('resetGimbal'); + @override + Future resetGimbalYaw() => _record('resetGimbalYaw'); + @override + Future fineTuneGimbal(String axis, double degrees) => _record('fineTuneGimbal($axis, ${degrees.toStringAsFixed(1)})'); + @override + Future startGimbalCalibration() => _record('startGimbalCalibration'); + @override + Future setGimbalSetting(String id, Object value) => _record('setGimbalSetting($id, $value)'); + @override + Future resetGimbalControlParameters() => _record('resetGimbalControlParameters'); } diff --git a/Fly App/test/fly/gimbal_test.dart b/Fly App/test/fly/gimbal_test.dart new file mode 100644 index 0000000..5674a37 --- /dev/null +++ b/Fly App/test/fly/gimbal_test.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/gimbal_control.dart'; +import 'package:pilotvault_fly/fly/hud_features.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/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 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +/// A connected model whose gimbal has reported [gimbal] on top of the fixture +/// tick's attitude, and [settings] as its `gimbalSettings` group. +FlightModel gimbalModel( + Map profile, { + Map gimbal = const {}, + Map? settings, +}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile); + final Map t = telemetryTick(); + t['gimbal'] = { + ...(t['gimbal'] as Map), + 'mode': 'YAW_FOLLOW', + 'rollFineTune': 0.0, + 'calibrating': false, + ...gimbal, + }; + if (settings != null) t['gimbalSettings'] = settings; + TelemetryReducer(m).apply(t); + return m; +} + +void main() { + group('Joystick', () { + test('dead zone holds, up tilts up, full reach is full speed', () { + expect(GimbalJoystick.speedFor(const Offset(3, -5), yaw: true), GimbalSpeed.zero); + final GimbalSpeed up = GimbalJoystick.speedFor(const Offset(0, -GimbalJoystick.reach), yaw: false); + expect(up.pitch, GimbalJoystick.maxSpeed); + expect(up.yaw, isNull, reason: 'pitch-only gimbals leave yaw alone'); + expect(GimbalJoystick.speedFor(const Offset(0, 500), yaw: false).pitch, -GimbalJoystick.maxSpeed, reason: 'clamped'); + expect(GimbalJoystick.speedFor(const Offset(GimbalJoystick.reach, 0), yaw: true).yaw, GimbalJoystick.maxSpeed); + }); + + test('short drags are fine control (squared curve)', () { + final double half = GimbalJoystick.speedFor(const Offset(0, -(GimbalJoystick.deadZone + (GimbalJoystick.reach - GimbalJoystick.deadZone) / 2)), yaw: false).pitch; + expect(half, closeTo(GimbalJoystick.maxSpeed / 4, 0.01)); + }); + + test('the knob stays on the ring', () { + expect(GimbalJoystick.knob(const Offset(300, 400)).distance, closeTo(GimbalJoystick.reach, 0.001)); + expect(GimbalJoystick.knob(const Offset(10, 10)), const Offset(10, 10)); + }); + }); + + group('Modes, tuning and actions', () { + test('Free only on gimbals that pan', () { + List modes(Map p) => [for (final GimbalModeOption o in GimbalModes.optionsFor(ProductProfile.fromBridge(p).gimbal)) o.value]; + expect(modes(phantom4), ['YAW_FOLLOW', 'FPV']); + expect(modes(inspire2X5s), ['YAW_FOLLOW', 'FPV', 'FREE']); + expect(GimbalModes.label('FPV'), 'FPV'); + expect(GimbalModes.label(null), '—'); + }); + + test('advanced rows come from the profile ranges', () { + expect(GimbalTuning.rowsFor(ProductProfile.fromBridge(phantom4)), isEmpty); + expect(HudFeatures.gimbalAdvanced.enabledFor(ProductProfile.fromBridge(phantom4)), isFalse); + final ProductProfile i2 = ProductProfile.fromBridge(inspire2X5s); + expect(HudFeatures.gimbalAdvanced.enabledFor(i2), isTrue); + expect([for (final (GimbalTuning t, _) in GimbalTuning.rowsFor(i2)) t.id], ['pitchMaxSpeed', 'pitchSmoothing', 'yawMaxSpeed']); + expect(GimbalTuning.stepFor(const AxisRange(1, 100)), 5); + expect(GimbalTuning.stepFor(const AxisRange(0, 10)), 1); + }); + + test('face down goes as far as the gimbal does', () { + expect(GimbalActions.faceDownPitch(const AxisRange(-90, 30)), -90); + expect(GimbalActions.faceDownPitch(const AxisRange(-60, 0)), -60); + expect(GimbalActions.faceDownPitch(null), -90); + }); + + test('calibration only on the ground with motors off', () { + final FlightModel m = gimbalModel(phantom4); + expect(GimbalActions.calibrationBlocked(m.aircraft, m.gimbal), isNotNull, reason: 'the fixture is flying'); + m.aircraft + ..isFlying = false + ..motorsOn = false; + expect(GimbalActions.calibrationBlocked(m.aircraft, m.gimbal), isNull); + }); + }); + + group('State', () { + test('telemetry carries mode, trim, calibration and the settings group', () { + final FlightModel m = gimbalModel(inspire2X5s, + gimbal: {'mode': 'FPV', 'rollFineTune': 0.3, 'calibrating': true, 'calibrationProgress': 40}, + settings: {'pitchExtension': true, 'pitchMaxSpeed': 50}); + expect(m.gimbal.mode, 'FPV'); + expect(m.gimbal.rollFineTune, 0.3); + expect(m.gimbal.calibrationProgress, 40); + expect(m.gimbal.pitchExtension, isTrue); + expect(m.gimbal.setting('pitchMaxSpeed'), 50); + }); + + test('the end of a calibration is remembered', () { + final FlightModel m = gimbalModel(phantom4, gimbal: {'calibrating': true}); + m.gimbal.apply({'calibrating': false, 'calibrationSuccessful': true}); + expect(m.gimbal.lastCalibrationOk, isTrue); + }); + + test('a pending write is answered by the report', () { + final FlightModel m = gimbalModel(phantom4, settings: {'pitchExtension': false}); + m.gimbal.setPending({'pitchExtension': true}); + expect(m.gimbal.pitchExtension, isTrue); + m.gimbal.applySettings({'pitchExtension': true}); + expect(m.gimbal.isPending('pitchExtension'), isFalse); + expect(m.gimbal.pitchExtension, isTrue); + }); + }); + + group('Widgets', () { + Future pump(WidgetTester tester, FlightModel m, {Size size = const Size(844, 390)}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService dji = FakeDjiService(); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(const Duration(milliseconds: 50)); + return dji; + } + + Future openSheet(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('gimbal-settings-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + Finder sheetList() => find.descendant(of: find.byType(GimbalSettingsList), matching: find.byType(Scrollable)); + + testWidgets('the slider shows a value bubble while dragging and commands the pitch', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, gimbalModel(phantom4)); + expect(find.byKey(const ValueKey('gimbal-bubble')), findsNothing); + final Offset thumb = tester.getTopLeft(find.text('-30°')) + const Offset(-10, 8); + final TestGesture g = await tester.startGesture(thumb); + for (int i = 0; i < 4; i++) { + await g.moveBy(const Offset(0, 10)); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(find.byKey(const ValueKey('gimbal-bubble')), findsOneWidget); + await g.up(); + await tester.pump(); + expect(find.byKey(const ValueKey('gimbal-bubble')), findsNothing); + expect(dji.calls.where((String c) => c.startsWith('rotateGimbalPitch')), isNotEmpty); + }); + + testWidgets('dragging the video sends speed at 10 Hz and a zero on release', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, gimbalModel(phantom4)); + final TestGesture g = await tester.startGesture(const Offset(422, 220)); + await g.moveBy(const Offset(0, -20)); + await g.moveBy(const Offset(0, -100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + final List speeds = dji.calls.where((String c) => c.startsWith('rotateGimbalSpeed')).toList(); + expect(speeds.length, 3, reason: 'one per 100 ms tick'); + expect(speeds.first, startsWith('rotateGimbalSpeed(')); + expect(speeds.first, endsWith(', null)'), reason: 'Phantom 4 does not pan'); + expect(speeds.every((String c) => !c.contains('(0.0')), isTrue); + await g.up(); + await tester.pump(); + expect(dji.calls.last, 'rotateGimbalSpeed(0.0, null)'); + expect(dji.calls, isNot(contains(startsWith('setFocusTarget'))), reason: 'a drag is not a tap'); + expect(dji.calls, isNot(contains(startsWith('setSpotMeteringTarget')))); + await tester.pump(const Duration(milliseconds: 300)); + expect(dji.calls.last, 'rotateGimbalSpeed(0.0, null)', reason: 'stops refreshing once released'); + }); + + testWidgets('a tap on the video still meters, not the gimbal', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, gimbalModel(phantom4)); + await tester.tapAt(const Offset(422, 195)); + await tester.pump(); + expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)')); + expect(dji.calls.where((String c) => c.startsWith('rotateGimbalSpeed')), isEmpty); + await tester.pump(cameraSettle); + }); + + testWidgets('the sheet sets the mode and recenters', (WidgetTester tester) async { + final FlightModel m = gimbalModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + await openSheet(tester); + expect(find.text('Follow'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('row-gimbalMode'))); + await tester.pump(); + expect(find.byKey(const ValueKey('opt-FREE')), findsNothing, reason: 'Phantom 4 has no yaw axis'); + await tester.tap(find.byKey(const ValueKey('opt-FPV'))); + await tester.pump(); + expect(dji.calls, contains('setGimbalMode(FPV)')); + expect(m.gimbal.mode, 'FPV'); + await tester.tap(find.byKey(const ValueKey('action-recenter'))); + await tester.pump(); + expect(dji.calls, contains('resetGimbal')); + await tester.tap(find.byKey(const ValueKey('action-faceDown'))); + await tester.pump(); + expect(dji.calls, contains('rotateGimbalPitch(-90)')); + expect(find.byKey(const ValueKey('action-yawRecenter')), findsNothing); + }); + + testWidgets('a refused mode change rolls back', (WidgetTester tester) async { + final FlightModel m = gimbalModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported'); + await openSheet(tester); + await tester.tap(find.byKey(const ValueKey('row-gimbalMode'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-FPV'))); + await tester.pump(); + await tester.pump(); + expect(m.gimbal.mode, 'YAW_FOLLOW'); + expect(find.textContaining('Not supported'), findsOneWidget); + }); + + testWidgets('extended pitch writes through the overlay and snaps back unconfirmed', (WidgetTester tester) async { + final FlightModel m = gimbalModel(phantom4, settings: {'pitchExtension': false}); + final FakeDjiService dji = await pump(tester, m); + await openSheet(tester); + await tester.tap(find.byKey(const ValueKey('row-pitchExtension'))); + await tester.pump(); + expect(dji.calls, contains('setGimbalSetting(pitchExtension, true)')); + expect(m.gimbal.pitchExtension, isTrue); + await tester.pump(cameraSettle); + expect(m.gimbal.pitchExtension, isFalse); + }); + + testWidgets('roll fine-tune nudges by 0.1°', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, gimbalModel(phantom4)); + await openSheet(tester); + final Finder plus = find.byKey(const ValueKey('step-rollFineTune-plus')); + await tester.scrollUntilVisible(plus, 60, scrollable: sheetList()); + await tester.ensureVisible(plus); + await tester.pump(); + await tester.tap(plus); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('step-rollFineTune-minus'))); + await tester.pump(); + 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 { + final FlightModel m = gimbalModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + await openSheet(tester); + final Finder calibrate = find.byKey(const ValueKey('action-calibrateGimbal')); + await tester.scrollUntilVisible(calibrate, 60, scrollable: sheetList()); + await tester.ensureVisible(calibrate); + 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); + + m.aircraft + ..isFlying = false + ..motorsOn = false + ..notifyListeners(); + await tester.pump(); + await tester.tap(calibrate); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(dji.calls, isNot(contains('startGimbalCalibration'))); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(dji.calls, contains('startGimbalCalibration')); + }); + + testWidgets('advanced rows on a tunable gimbal step by its range', (WidgetTester tester) async { + final FlightModel m = gimbalModel(inspire2X5s, settings: {'pitchMaxSpeed': 50, 'pitchSmoothing': 10}); + final FakeDjiService dji = await pump(tester, m); + await openSheet(tester); + final Finder plus = find.byKey(const ValueKey('step-pitchMaxSpeed-plus')); + await tester.scrollUntilVisible(plus, 60, scrollable: sheetList()); + await tester.ensureVisible(plus); + await tester.pump(); + expect(find.byKey(const ValueKey('row-yawMaxSpeed')), findsNothing, reason: 'never reported'); + await tester.tap(plus); + await tester.pump(); + expect(dji.calls, contains('setGimbalSetting(pitchMaxSpeed, 55)')); + await tester.pump(cameraSettle); + }); + + testWidgets('a panning gimbal gets yaw recenter and yaw speed', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, gimbalModel(inspire2X5s)); + final TestGesture g = await tester.startGesture(const Offset(422, 220)); + await g.moveBy(const Offset(60, 0)); + await g.moveBy(const Offset(60, 0)); + await tester.pump(const Duration(milliseconds: 150)); + expect(dji.calls.firstWhere((String c) => c.startsWith('rotateGimbalSpeed')), 'rotateGimbalSpeed(0.0, 30.0)'); + await g.up(); + await tester.pump(); + expect(dji.calls.last, 'rotateGimbalSpeed(0.0, 0.0)'); + await openSheet(tester); + await tester.tap(find.byKey(const ValueKey('action-yawRecenter'))); + await tester.pump(); + expect(dji.calls, contains('resetGimbalYaw')); + }); + + testWidgets('lays out without overflow on a small phone and a tablet, sheet open', (WidgetTester tester) async { + for (final Size s in const [Size(740, 340), Size(844, 390), Size(1280, 800)]) { + await pump(tester, gimbalModel(inspire2X5s, settings: {'pitchMaxSpeed': 50, 'yawFollow': true}), size: s); + expect(tester.takeException(), isNull, reason: '$s'); + await openSheet(tester); + expect(tester.takeException(), isNull, reason: '$s sheet'); + } + }); + }); +} diff --git a/Fly App/test/fly/profile_fixtures.dart b/Fly App/test/fly/profile_fixtures.dart index 83756bf..a374874 100644 --- a/Fly App/test/fly/profile_fixtures.dart +++ b/Fly App/test/fly/profile_fixtures.dart @@ -39,7 +39,7 @@ Map _camera({ 'colors': ['NONE', 'D_LOG'], }; -Map _gimbal({bool yaw = false, double pitchMax = 0}) => { +Map _gimbal({bool yaw = false, double pitchMax = 0, bool tuning = false}) => { 'index': 0, 'capabilitiesKnown': true, 'pitch': {'min': -90.0, 'max': pitchMax}, @@ -49,6 +49,13 @@ Map _gimbal({bool yaw = false, double pitchMax = 0}) => 0, + 'controller': tuning + ? { + 'pitchMaxSpeed': {'min': 1.0, 'max': 100.0}, + 'pitchSmoothing': {'min': 0.0, 'max': 30.0}, + 'yawMaxSpeed': {'min': 1.0, 'max': 100.0}, + } + : {}, }; Map _fc({bool assistant = true, bool sticks = true}) => { @@ -106,7 +113,7 @@ final Map inspire2X5s = { 'cameras': [ _camera(name: 'Zenmuse X5S', aperture: true, ssd: true, apertures: ['f/1.7', 'f/2.8', 'f/5.6', 'f/16']), ], - 'gimbals': [_gimbal(yaw: true, pitchMax: 30)], + 'gimbals': [_gimbal(yaw: true, pitchMax: 30, tuning: true)], 'link': 'lightbridge', 'flightController': _fc(), 'remoteController': {'masterSlaveSupported': true},