Files
PilotVault/Fly App/lib/fly/aircraft_checklist.dart
T
tajniak81andClaude Opus 5.5 6ebc567e97 Put every flight command behind a SafetyGate
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>
2026-10-19 19:24:17 +02:00

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);
}
}