P6b: a new CalibrationBridge owns every calibration call and publishes a `calibration` telemetry group (IMU per-unit and per-side progress, gravity centre, lens, sensor cleaning, RC calibration state and live sticks). Guided full-screen wizards cover compass, IMU, remote controller, gimbal auto calibration, lens, sensor cleaning, centre of gravity, propellers and a vision-sensor guide; gimbal adjust is a horizon-line overlay on the live video. Every start goes through SafetyGate.runMaintenance, and closing mid-run stops what the SDK can stop. Reachable from Settings > Safety > Calibration & maintenance, the Aircraft Status calibrate buttons and the gimbal sheet. Not yet run on an aircraft; the RC key sequence still needs checking per remote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
16 KiB
Dart
321 lines
16 KiB
Dart
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/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';
|
|
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<String, dynamic> profile, {
|
|
Map<String, Object?> gimbal = const <String, Object?>{},
|
|
Map<String, Object?>? settings,
|
|
}) {
|
|
final FlightModel m = FlightModel()
|
|
..connected = true
|
|
..profile = ProductProfile.fromBridge(profile);
|
|
final Map<String, dynamic> t = telemetryTick();
|
|
t['gimbal'] = <Object?, Object?>{
|
|
...(t['gimbal'] as Map<Object?, Object?>),
|
|
'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<String> modes(Map<String, dynamic> p) => <String>[for (final GimbalModeOption o in GimbalModes.optionsFor(ProductProfile.fromBridge(p).gimbal)) o.value];
|
|
expect(modes(phantom4), <String>['YAW_FOLLOW', 'FPV']);
|
|
expect(modes(inspire2X5s), <String>['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(<String>[for (final (GimbalTuning t, _) in GimbalTuning.rowsFor(i2)) t.id], <String>['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: <String, Object?>{'mode': 'FPV', 'rollFineTune': 0.3, 'calibrating': true, 'calibrationProgress': 40},
|
|
settings: <String, Object?>{'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: <String, Object?>{'calibrating': true});
|
|
m.gimbal.apply(<String, dynamic>{'calibrating': false, 'calibrationSuccessful': true});
|
|
expect(m.gimbal.lastCalibrationOk, isTrue);
|
|
});
|
|
|
|
test('a pending write is answered by the report', () {
|
|
final FlightModel m = gimbalModel(phantom4, settings: <String, Object?>{'pitchExtension': false});
|
|
m.gimbal.setPending(<String, Object?>{'pitchExtension': true});
|
|
expect(m.gimbal.pitchExtension, isTrue);
|
|
m.gimbal.applySettings(<String, dynamic>{'pitchExtension': true});
|
|
expect(m.gimbal.isPending('pitchExtension'), isFalse);
|
|
expect(m.gimbal.pitchExtension, isTrue);
|
|
});
|
|
});
|
|
|
|
group('Widgets', () {
|
|
Future<FakeDjiService> 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<void> openSheet(WidgetTester tester) async {
|
|
await tester.tap(find.byKey(const ValueKey<String>('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<String>('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<String>('gimbal-bubble')), findsOneWidget);
|
|
await g.up();
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('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<String> 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<String>('row-gimbalMode')));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('opt-FREE')), findsNothing, reason: 'Phantom 4 has no yaw axis');
|
|
await tester.tap(find.byKey(const ValueKey<String>('opt-FPV')));
|
|
await tester.pump();
|
|
expect(dji.calls, contains('setGimbalMode(FPV)'));
|
|
expect(m.gimbal.mode, 'FPV');
|
|
await tester.tap(find.byKey(const ValueKey<String>('action-recenter')));
|
|
await tester.pump();
|
|
expect(dji.calls, contains('resetGimbal'));
|
|
await tester.tap(find.byKey(const ValueKey<String>('action-faceDown')));
|
|
await tester.pump();
|
|
expect(dji.calls, contains('rotateGimbalPitch(-90)'));
|
|
expect(find.byKey(const ValueKey<String>('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<String>('row-gimbalMode')));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('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: <String, Object?>{'pitchExtension': false});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await openSheet(tester);
|
|
await tester.tap(find.byKey(const ValueKey<String>('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<String>('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<String>('step-rollFineTune-minus')));
|
|
await tester.pump();
|
|
expect(dji.calls, containsAllInOrder(<String>['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(roll, -0.1)']));
|
|
});
|
|
|
|
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);
|
|
final Finder calibrate = find.byKey(const ValueKey<String>('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.pumpAndSettle();
|
|
expect(find.byType(GimbalCalibrationWizard), findsNothing);
|
|
|
|
m.aircraft
|
|
..isFlying = false
|
|
..motorsOn = false
|
|
..notifyListeners();
|
|
await tester.pump();
|
|
await tester.tap(calibrate);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(GimbalCalibrationWizard), findsOneWidget);
|
|
expect(dji.calls, isNot(contains('startGimbalCalibration')));
|
|
await tester.tap(find.byKey(const ValueKey<String>('wizard-start')));
|
|
await tester.pump();
|
|
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: <String, Object?>{'pitchMaxSpeed': 50, 'pitchSmoothing': 10});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await openSheet(tester);
|
|
final Finder plus = find.byKey(const ValueKey<String>('step-pitchMaxSpeed-plus'));
|
|
await tester.scrollUntilVisible(plus, 60, scrollable: sheetList());
|
|
await tester.ensureVisible(plus);
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('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<String>('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>[Size(740, 340), Size(844, 390), Size(1280, 800)]) {
|
|
await pump(tester, gimbalModel(inspire2X5s, settings: <String, Object?>{'pitchMaxSpeed': 50, 'yawFollow': true}), size: s);
|
|
expect(tester.takeException(), isNull, reason: '$s');
|
|
await openSheet(tester);
|
|
expect(tester.takeException(), isNull, reason: '$s sheet');
|
|
}
|
|
});
|
|
});
|
|
}
|