Give the Fly App GO 4-style gimbal controls
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>
This commit is contained in:
1 parent
c1770fff73
commit
60c2fd7f64
19 files changed
+1600
-102
No files matched your search
@@ -92,4 +92,25 @@ class FakeDjiService extends DjiService {
|
||||
Future<void> restoreCameraDefaults() => _record('restoreCameraDefaults');
|
||||
@override
|
||||
Future<void> setHistogramEnabled(bool on) => _record('setHistogramEnabled($on)');
|
||||
|
||||
// ── Gimbal (plan P5) ───────────────────────────────────────────────────────
|
||||
@override
|
||||
Future<void> rotateGimbalPitch(double deg) => _record('rotateGimbalPitch(${deg.round()})');
|
||||
@override
|
||||
Future<void> rotateGimbalSpeed({double? pitch, double? yaw}) =>
|
||||
_record('rotateGimbalSpeed(${pitch?.toStringAsFixed(1)}, ${yaw?.toStringAsFixed(1)})');
|
||||
@override
|
||||
Future<void> setGimbalMode(String mode) => _record('setGimbalMode($mode)');
|
||||
@override
|
||||
Future<void> resetGimbal() => _record('resetGimbal');
|
||||
@override
|
||||
Future<void> resetGimbalYaw() => _record('resetGimbalYaw');
|
||||
@override
|
||||
Future<void> fineTuneGimbal(String axis, double degrees) => _record('fineTuneGimbal($axis, ${degrees.toStringAsFixed(1)})');
|
||||
@override
|
||||
Future<void> startGimbalCalibration() => _record('startGimbalCalibration');
|
||||
@override
|
||||
Future<void> setGimbalSetting(String id, Object value) => _record('setGimbalSetting($id, $value)');
|
||||
@override
|
||||
Future<void> resetGimbalControlParameters() => _record('resetGimbalControlParameters');
|
||||
}
|
||||
@@ -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<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 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<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.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: <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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -39,7 +39,7 @@ Map<String, dynamic> _camera({
|
||||
'colors': <String>['NONE', 'D_LOG'],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String, dynamic>{
|
||||
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0, bool tuning = false}) => <String, dynamic>{
|
||||
'index': 0,
|
||||
'capabilitiesKnown': true,
|
||||
'pitch': <String, dynamic>{'min': -90.0, 'max': pitchMax},
|
||||
@@ -49,6 +49,13 @@ Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String
|
||||
'yawAdjustable': yaw,
|
||||
'rollAdjustable': false,
|
||||
'pitchRangeExtensionSupported': pitchMax > 0,
|
||||
'controller': tuning
|
||||
? <String, dynamic>{
|
||||
'pitchMaxSpeed': <String, dynamic>{'min': 1.0, 'max': 100.0},
|
||||
'pitchSmoothing': <String, dynamic>{'min': 0.0, 'max': 30.0},
|
||||
'yawMaxSpeed': <String, dynamic>{'min': 1.0, 'max': 100.0},
|
||||
}
|
||||
: <String, dynamic>{},
|
||||
};
|
||||
|
||||
Map<String, dynamic> _fc({bool assistant = true, bool sticks = true}) => <String, dynamic>{
|
||||
@@ -106,7 +113,7 @@ final Map<String, dynamic> inspire2X5s = <String, dynamic>{
|
||||
'cameras': <dynamic>[
|
||||
_camera(name: 'Zenmuse X5S', aperture: true, ssd: true, apertures: <String>['f/1.7', 'f/2.8', 'f/5.6', 'f/16']),
|
||||
],
|
||||
'gimbals': <dynamic>[_gimbal(yaw: true, pitchMax: 30)],
|
||||
'gimbals': <dynamic>[_gimbal(yaw: true, pitchMax: 30, tuning: true)],
|
||||
'link': 'lightbridge',
|
||||
'flightController': _fc(),
|
||||
'remoteController': <String, dynamic>{'masterSlaveSupported': true},
|
||||
|
||||
Reference in new issue
Block a user