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 <noreply@anthropic.com>
139 lines
5.1 KiB
Dart
139 lines
5.1 KiB
Dart
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<GimbalModeOption> optionsFor(GimbalProfile? g) =>
|
||
<GimbalModeOption>[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<GimbalTuning> all = <GimbalTuning>[
|
||
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;
|
||
}
|
||
}
|