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>
336 lines
13 KiB
Dart
336 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../dji_service.dart';
|
|
import '../../../flight_model.dart';
|
|
import '../../../fly/calibration.dart';
|
|
import '../../../fly/gimbal_control.dart';
|
|
import '../../../fly/hud_features.dart';
|
|
import '../../../fly/product_profile.dart';
|
|
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';
|
|
import 'settings/calibration/calibration_hub.dart';
|
|
|
|
/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the
|
|
/// live view, like the camera menu.
|
|
Future<void> showGimbalSettings(
|
|
BuildContext context, {
|
|
required FlightModel model,
|
|
required DjiService dji,
|
|
required void Function(String label, Object error) onError,
|
|
}) {
|
|
return showGeneralDialog<void>(
|
|
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<double> a, _, Widget child) => SlideTransition(
|
|
position: Tween<Offset>(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: <Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
|
|
child: Row(children: <Widget>[
|
|
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<void> _run(String label, Future<void> Function() send) async {
|
|
try {
|
|
await send();
|
|
} catch (e) {
|
|
onError(label, e);
|
|
}
|
|
}
|
|
|
|
Future<void> _write(String label, String id, Object value) => writeCameraSetting(
|
|
_g,
|
|
label: label,
|
|
pending: <String, Object?>{id: value},
|
|
send: () => dji.setGimbalSetting(id, value),
|
|
onError: onError,
|
|
);
|
|
|
|
Future<void> _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<void> _open(BuildContext context, CalibrationKind kind) =>
|
|
openCalibration(context, kind, model: model, dji: dji, onError: onError);
|
|
|
|
Future<void> _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(<Listenable>[_g, model.aircraft]),
|
|
builder: (BuildContext context, _) => ListView(
|
|
key: const ValueKey<String>('gimbal-settings'),
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
children: <Widget>[
|
|
_status(p),
|
|
// ── Mode ──────────────────────────────────────────────────────────
|
|
ChoiceTile(
|
|
key: const ValueKey<String>('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<String>('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<String>('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: <Widget>[
|
|
_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)),
|
|
),
|
|
Requires(
|
|
HudFeatures.gimbalAdjust,
|
|
child: ButtonTile(
|
|
id: 'adjustGimbal',
|
|
label: 'Adjust gimbal',
|
|
hint: 'Roll, pitch and yaw against a horizon line on the video',
|
|
button: 'Adjust',
|
|
onTap: () => _open(context, CalibrationKind.gimbalAdjust),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
// ── Calibration ───────────────────────────────────────────────────
|
|
Requires(
|
|
HudFeatures.gimbalCalibration,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
|
_section('CALIBRATION'),
|
|
_calibrationRow(context),
|
|
]),
|
|
),
|
|
// ── Advanced ──────────────────────────────────────────────────────
|
|
Requires(
|
|
HudFeatures.gimbalAdvanced,
|
|
whenDisconnected: false,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
|
_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<String> warnings = <String>[
|
|
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: <Widget>[
|
|
Text(
|
|
'PITCH ${deg(_g.pitch)} ROLL ${deg(_g.roll)} YAW ${deg(_g.yawRelative ?? _g.yaw)}',
|
|
key: const ValueKey<String>('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 ? () => _open(context, CalibrationKind.gimbal) : 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<String>('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)),
|
|
);
|
|
}
|
|
}
|