Take-off, land, RTH, the landing cancel/confirm and Stop now all go through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its preconditions into blockers and warnings: connection, flight state, battery against the thresholds, IMU warm-up, compass, critical diagnostics, GPS, home point, SD card and the RTH battery budget. It checks again right before sending. The left rail is now a state machine (lib/fly/action_rail_state.dart): Take-off becomes Land, then Cancel or Confirm landing. RTH becomes Cancel RTH. A Stop button shows while an intelligent mode is flying. Moving the aircraft needs a slide-to-confirm dialog, which can offer Precision Takeoff or set the RTH altitude. A blocked command shows why and has no slider. When the pilot is more than 30 m from home, RTH asks whether to return to them or to the original home point. The HUD gains an error stack. Its cards can be dismissed, it fits the space it has, and a new warning triggers haptic feedback. It also gets a smart-RTH countdown banner with Cancel / Go home now, a forced-landing banner, and an Aircraft Status sheet. The sheet opens from the status chip and appears before each connection's first take-off. It offers Calibrate compass/IMU and Format SD. On the bridge side this adds precisionTakeOff, setHomeLocation, compass and IMU calibration start/stop, formatStorage, and compass health in the telemetry tick. The classic flight screen stays available until the simulator run passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
11 KiB
Dart
246 lines
11 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import '../flight_model.dart';
|
|
import 'flight_status.dart';
|
|
import 'state/alert_state.dart';
|
|
import 'state/battery_state.dart';
|
|
import 'state/camera_state.dart';
|
|
import 'state/link_state.dart';
|
|
import 'state/vision_state.dart';
|
|
|
|
/// A row's traffic light on the Aircraft Status sheet.
|
|
enum CheckState { ok, caution, danger, unknown }
|
|
|
|
/// What a row's button does.
|
|
enum ChecklistAction { calibrateCompass, stopCompassCalibration, calibrateImu, formatSd }
|
|
|
|
@immutable
|
|
class ChecklistItem {
|
|
const ChecklistItem(this.id, this.label, this.value, this.state, {this.detail, this.action});
|
|
|
|
final String id;
|
|
final String label;
|
|
final String value;
|
|
final CheckState state;
|
|
|
|
/// The live fault behind a caution / danger, when one explains it.
|
|
final String? detail;
|
|
final ChecklistAction? action;
|
|
|
|
@override
|
|
String toString() => 'ChecklistItem($id: $value, $state${action == null ? '' : ', $action'})';
|
|
}
|
|
|
|
/// The flight controller's distance and altitude limits, read on demand from
|
|
/// the KeyManager when the sheet opens (they are not in the telemetry tick).
|
|
@immutable
|
|
class FlightLimits {
|
|
const FlightLimits({this.maxHeight, this.maxRadius, this.maxRadiusEnabled});
|
|
|
|
final int? maxHeight; // m
|
|
final int? maxRadius; // m
|
|
final bool? maxRadiusEnabled;
|
|
}
|
|
|
|
/// The Aircraft Status sheet's rows (plan P3, GO 4's `checklist_content_view`),
|
|
/// read off the telemetry sub-models and the live diagnostics.
|
|
///
|
|
/// Pure, so the grading is unit-tested. Rows for hardware the connected
|
|
/// product lacks (vision sensors, gimbal, camera storage, an RC link) are left
|
|
/// out, the same way the HUD hides them.
|
|
abstract final class AircraftChecklist {
|
|
static final RegExp _imu = RegExp(r'\bimu\b|accelerometer|gyro', caseSensitive: false);
|
|
static final RegExp _compass = RegExp(r'compass|magnet', caseSensitive: false);
|
|
static final RegExp _esc = RegExp(r'\besc\b|motor|propeller', caseSensitive: false);
|
|
|
|
static List<ChecklistItem> build(FlightModel m, {FlightLimits limits = const FlightLimits()}) {
|
|
final List<DiagnosticAlert> alerts = m.alerts.all;
|
|
DiagnosticAlert? worst(bool Function(DiagnosticAlert a) match) {
|
|
for (final DiagnosticAlert a in alerts) {
|
|
if (match(a)) return a; // [AlertState.all] is most severe first.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
final FlightStatus status = FlightStatus.of(connected: m.connected, a: m.aircraft, alerts: m.alerts);
|
|
final bool flying = m.aircraft.isFlying == true;
|
|
final List<ChecklistItem> items = <ChecklistItem>[
|
|
ChecklistItem('overall', 'Overall status', status.text, _fromStatus(status.level)),
|
|
ChecklistItem(
|
|
'mode',
|
|
'Flight mode',
|
|
m.aircraft.flightMode ?? '—',
|
|
m.aircraft.flightMode == null
|
|
? CheckState.unknown
|
|
: ((m.aircraft.gpsSignalLevel ?? 0) >= FlightStatus.minGpsLevel ? CheckState.ok : CheckState.caution),
|
|
),
|
|
_compassRow(m, worst((DiagnosticAlert a) => _compass.hasMatch(a.reason)), flying),
|
|
_faultRow(
|
|
'imu',
|
|
'IMU',
|
|
worst((DiagnosticAlert a) => _imu.hasMatch(a.reason)),
|
|
preheating: m.aircraft.imuPreheating == true,
|
|
reported: m.aircraft.updatedAt != null,
|
|
action: flying ? null : ChecklistAction.calibrateImu,
|
|
),
|
|
ChecklistItem(
|
|
'maxHeight',
|
|
'Max altitude',
|
|
limits.maxHeight == null ? '—' : '${limits.maxHeight} m',
|
|
limits.maxHeight == null ? CheckState.unknown : CheckState.ok,
|
|
),
|
|
ChecklistItem(
|
|
'maxRadius',
|
|
'Max distance',
|
|
limits.maxRadiusEnabled == false ? 'No limit' : (limits.maxRadius == null ? '—' : '${limits.maxRadius} m'),
|
|
limits.maxRadiusEnabled == null && limits.maxRadius == null ? CheckState.unknown : CheckState.ok,
|
|
),
|
|
_faultRow('esc', 'ESC', worst((DiagnosticAlert a) => _esc.hasMatch(a.reason)), reported: m.aircraft.updatedAt != null),
|
|
if (m.profile.hasVps || !m.profile.connected) _visionRow(m.vision, worst((DiagnosticAlert a) => a.component == 'VISION')),
|
|
if (m.profile.hasRcLink || !m.profile.connected)
|
|
ChecklistItem('rcMode', 'RC mode', _rcMode(m.links), m.links.updatedAt == null ? CheckState.unknown : CheckState.ok),
|
|
_radioRow(m.links, wifi: m.profile.hasWifiLink),
|
|
if (!m.profile.hasWifiLink) _rcBatteryRow(m.links),
|
|
_batteryRow(m.battery, worst((DiagnosticAlert a) => a.component == 'BATTERY')),
|
|
if (m.profile.hasGimbal || !m.profile.connected)
|
|
_faultRow('gimbal', 'Gimbal', worst((DiagnosticAlert a) => a.component == 'GIMBAL'),
|
|
reported: m.gimbal.updatedAt != null),
|
|
if (m.profile.hasCamera || !m.profile.connected) _storageRow(m.camera, flying),
|
|
];
|
|
return items;
|
|
}
|
|
|
|
/// The sheet's header colour: the worst row.
|
|
static CheckState overall(List<ChecklistItem> items) {
|
|
if (items.any((ChecklistItem i) => i.state == CheckState.danger)) return CheckState.danger;
|
|
if (items.any((ChecklistItem i) => i.state == CheckState.caution)) return CheckState.caution;
|
|
if (items.every((ChecklistItem i) => i.state == CheckState.unknown)) return CheckState.unknown;
|
|
return CheckState.ok;
|
|
}
|
|
|
|
static CheckState _fromStatus(StatusLevel l) => switch (l) {
|
|
StatusLevel.ready => CheckState.ok,
|
|
StatusLevel.caution => CheckState.caution,
|
|
StatusLevel.danger => CheckState.danger,
|
|
StatusLevel.neutral => CheckState.unknown,
|
|
};
|
|
|
|
static CheckState _fromSeverity(AlertSeverity s) => switch (s) {
|
|
AlertSeverity.critical => CheckState.danger,
|
|
AlertSeverity.warning => CheckState.caution,
|
|
AlertSeverity.info => CheckState.ok,
|
|
};
|
|
|
|
static ChecklistItem _compassRow(FlightModel m, DiagnosticAlert? alert, bool flying) {
|
|
final ChecklistAction? calibrate = flying ? null : ChecklistAction.calibrateCompass;
|
|
if (m.aircraft.compassCalibrating == true) {
|
|
return ChecklistItem('compass', 'Compass', _calibrationStep(m.aircraft.compassCalibrationState), CheckState.caution,
|
|
action: ChecklistAction.stopCompassCalibration);
|
|
}
|
|
if (m.aircraft.compassHasError == true) {
|
|
return ChecklistItem('compass', 'Compass', 'Error', CheckState.danger, detail: alert?.reason, action: calibrate);
|
|
}
|
|
if (alert != null) {
|
|
return ChecklistItem('compass', 'Compass', 'Interference', _fromSeverity(alert.severity), detail: alert.reason, action: calibrate);
|
|
}
|
|
if (m.aircraft.compassHasError == null && m.aircraft.updatedAt == null) {
|
|
return const ChecklistItem('compass', 'Compass', '—', CheckState.unknown);
|
|
}
|
|
return ChecklistItem('compass', 'Compass', 'Normal', CheckState.ok, action: calibrate);
|
|
}
|
|
|
|
static String _calibrationStep(String? s) => switch (s) {
|
|
'HORIZONTAL' => 'Calibrating: rotate level',
|
|
'VERTICAL' => 'Calibrating: rotate nose down',
|
|
'SUCCESSFUL' => 'Calibrated',
|
|
'FAILED' => 'Calibration failed',
|
|
_ => 'Calibrating',
|
|
};
|
|
|
|
static ChecklistItem _faultRow(
|
|
String id,
|
|
String label,
|
|
DiagnosticAlert? alert, {
|
|
bool preheating = false,
|
|
required bool reported,
|
|
ChecklistAction? action,
|
|
}) {
|
|
if (alert != null && alert.severity != AlertSeverity.info) {
|
|
return ChecklistItem(id, label, alert.severity == AlertSeverity.critical ? 'Error' : 'Warning', _fromSeverity(alert.severity),
|
|
detail: alert.reason, action: action);
|
|
}
|
|
if (preheating) return ChecklistItem(id, label, 'Warming up', CheckState.caution);
|
|
if (!reported) return ChecklistItem(id, label, '—', CheckState.unknown);
|
|
return ChecklistItem(id, label, 'Normal', CheckState.ok, action: action);
|
|
}
|
|
|
|
static ChecklistItem _visionRow(VisionState v, DiagnosticAlert? alert) {
|
|
if (alert != null) {
|
|
return ChecklistItem('vision', 'Vision sensors', 'Warning', _fromSeverity(alert.severity), detail: alert.reason);
|
|
}
|
|
if (v.updatedAt == null || v.sensors.isEmpty) return const ChecklistItem('vision', 'Vision sensors', '—', CheckState.unknown);
|
|
final Iterable<VisionReading> r = v.sensors.values;
|
|
if (r.any((VisionReading s) => s.dangerous)) return const ChecklistItem('vision', 'Vision sensors', 'Obstacle close', CheckState.danger);
|
|
if (r.every((VisionReading s) => s.isDisabled)) return const ChecklistItem('vision', 'Vision sensors', 'Off', CheckState.caution);
|
|
return const ChecklistItem('vision', 'Vision sensors', 'Normal', CheckState.ok);
|
|
}
|
|
|
|
static String _rcMode(LinkState l) => switch (l.rcMode) {
|
|
null => l.updatedAt == null ? '—' : 'Normal',
|
|
'MASTER' || 'MASTER_SUB' => 'Master${l.masterSlaveConnected == false ? ' (slave not linked)' : ''}',
|
|
'SLAVE' || 'SLAVE_SUB' => 'Slave',
|
|
'NORMAL' => 'Normal',
|
|
final String m => m,
|
|
};
|
|
|
|
static ChecklistItem _radioRow(LinkState l, {required bool wifi}) {
|
|
final int? up = l.uplinkQuality, down = l.downlinkQuality;
|
|
final int? q = up == null ? down : (down == null ? up : (up < down ? up : down));
|
|
if (q == null) return ChecklistItem('radio', wifi ? 'Wi-Fi signal' : 'Radio signal', '—', CheckState.unknown);
|
|
final String value = wifi ? '$q%' : 'RC ${up ?? '—'}% · HD ${down ?? '—'}%';
|
|
return ChecklistItem('radio', wifi ? 'Wi-Fi signal' : 'Radio signal', value,
|
|
q < 20 ? CheckState.danger : (q < 40 ? CheckState.caution : CheckState.ok));
|
|
}
|
|
|
|
static ChecklistItem _rcBatteryRow(LinkState l) {
|
|
final int? p = l.rcBatteryPercent;
|
|
if (p == null) return const ChecklistItem('rcBattery', 'RC battery', '—', CheckState.unknown);
|
|
return ChecklistItem('rcBattery', 'RC battery', '$p%${l.rcCharging == true ? ' ⚡' : ''}',
|
|
p <= 10 ? CheckState.danger : (p <= 25 ? CheckState.caution : CheckState.ok));
|
|
}
|
|
|
|
static ChecklistItem _batteryRow(BatteryState b, DiagnosticAlert? alert) {
|
|
final int? p = b.percent;
|
|
if (p == null) return const ChecklistItem('battery', 'Aircraft battery', '—', CheckState.unknown);
|
|
final double? t = b.temperature;
|
|
final String value = <String>[
|
|
'$p%',
|
|
if (t != null) '${t.toStringAsFixed(0)} °C',
|
|
if (b.isMultiPack) '${b.packs.length} packs',
|
|
].join(' · ');
|
|
CheckState s = p <= (b.seriousLowWarningThreshold ?? 15)
|
|
? CheckState.danger
|
|
: (p <= (b.lowWarningThreshold ?? 30) ? CheckState.caution : CheckState.ok);
|
|
// Too cold to fly hard, or overheating.
|
|
if (t != null && (t < 15 || t > 60) && s == CheckState.ok) s = CheckState.caution;
|
|
if (alert != null && alert.severity != AlertSeverity.info && s.index < _fromSeverity(alert.severity).index) {
|
|
s = _fromSeverity(alert.severity);
|
|
}
|
|
final String? detail = alert?.reason ?? (t != null && t < 15 ? 'Cold battery: let it warm up before flying hard.' : null);
|
|
return ChecklistItem('battery', 'Aircraft battery', value, s, detail: detail);
|
|
}
|
|
|
|
static ChecklistItem _storageRow(CameraState c, bool flying) {
|
|
final StorageInfo? s = c.storage;
|
|
// Never offer to erase the card mid-flight or while it is being written.
|
|
final ChecklistAction? format = flying || c.isRecording || s == null || !s.inserted ? null : ChecklistAction.formatSd;
|
|
if (s == null) return const ChecklistItem('sd', 'SD card', '—', CheckState.unknown);
|
|
if (!s.usable) {
|
|
return ChecklistItem('sd', 'SD card', s.status, s.inserted && !s.error ? CheckState.caution : CheckState.danger, action: format);
|
|
}
|
|
final int? mb = s.remainingMB;
|
|
final String free = mb == null ? 'OK' : (mb >= 1024 ? '${(mb / 1024).toStringAsFixed(1)} GB free' : '$mb MB free');
|
|
return ChecklistItem('sd', 'SD card', free, mb != null && mb < 1024 ? CheckState.caution : CheckState.ok, action: format);
|
|
}
|
|
}
|