Files
PilotVault/Fly App/lib/flight_model.dart
T
tajniak81andClaude Opus 5.5 cfab444c65 Fix what the first secondary-screen run on the Mavic Pro turned up
- Leaving the Album put the camera in video mode even when it had been in
  photo mode: it restored FlightModel.captureMode, which only follows the HUD
  switch. It now restores the mode the camera reported before it was taken.
- Playing a video on the aircraft drew the player over the whole HUD. The
  Album now sets FlightModel.hudHidden, and the flight screen shows only the
  feed, full-screen, while it is set (the HUD stays mounted, offstage).
- Map tiles that failed once stayed as dark squares. Every map now uses one
  OpenStreetMap provider whose client also retries dropped connections, and
  failed tiles are fetched again when panned back into view.
- Find My Drone saved simulated positions as the aircraft's last fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:13:24 +02:00

331 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:ui' show Size;
import 'package:flutter/foundation.dart';
import 'fly/product_profile.dart';
import 'fly/state/aircraft_state.dart';
import 'fly/state/airsense_state.dart';
import 'fly/state/alert_state.dart';
import 'fly/state/battery_state.dart';
import 'fly/state/calibration_state.dart';
import 'fly/state/camera_state.dart';
import 'fly/state/fly_zone_state.dart';
import 'fly/state/gimbal_state.dart';
import 'fly/state/key_state.dart';
import 'fly/state/link_extras.dart';
import 'fly/state/link_state.dart';
import 'fly/state/live_stream_state.dart';
import 'fly/state/mission_state.dart';
import 'fly/state/vision_state.dart';
import 'uploader.dart';
enum RegistrationState { idle, registering, success, failed }
/// Camera capture family, mirrors the right-rail Photo/Video/Pano switch.
enum CaptureMode { photo, video, pano }
/// Exposure program — Auto (PROGRAM) vs Pro (MANUAL) tab on Camera settings.
enum ExposureProgram { auto, pro }
/// A media file listed off the aircraft's SD card by the native MediaManager.
class MediaItem {
MediaItem({
required this.index,
required this.fileName,
required this.isVideo,
this.key,
this.mediaType,
this.durationSeconds,
this.sizeBytes,
this.createdMs,
this.thumbPath,
this.localPath,
});
final int index;
final String fileName;
final bool isVideo;
/// Stable across re-listing (the index is not): file name + creation time.
final String? key;
/// The SDK's `MediaFile.MediaType` name: `JPEG`, `RAW_DNG`, `MP4`, `PANORAMA` …
final String? mediaType;
final int? durationSeconds;
final int? sizeBytes;
final int? createdMs;
/// On-disk path of the fetched thumbnail (cache dir), once available.
String? thumbPath;
/// On-disk path of the fully downloaded original, once available.
String? localPath;
/// The camera's screen-sized preview (photos), once fetched.
String? previewPath;
bool get isPano => mediaType == 'PANORAMA' || mediaType == 'PHOTO_FOLDER';
/// `12.4 MB`, or null when the camera did not say.
String? get sizeLabel {
final int? b = sizeBytes;
if (b == null || b <= 0) return null;
if (b >= 1 << 30) return '${(b / (1 << 30)).toStringAsFixed(1)} GB';
if (b >= 1 << 20) return '${(b / (1 << 20)).toStringAsFixed(1)} MB';
return '${(b / 1024).ceil()} kB';
}
/// `null` for anything that shouldn't carry a duration. The SDK reports
/// `durationInSeconds` for *every* file, so stills come back with junk —
/// a Mavic Pro's photos yielded -36:35, -136:23 and 545:26 on the SD card.
String? get durationLabel {
if (!isVideo) return null;
final int? s = durationSeconds;
if (s == null || s <= 0) return null;
final String mm = (s ~/ 60).toString();
final String ss = (s % 60).toString().padLeft(2, '0');
return '$mm:$ss';
}
factory MediaItem.fromMap(Map<String, dynamic> m) => MediaItem(
index: (m['index'] as num).toInt(),
fileName: (m['fileName'] as String?) ?? 'file',
isVideo: (m['type'] as String?) == 'video',
key: m['key'] as String?,
mediaType: m['mediaType'] as String?,
durationSeconds: (m['durationSeconds'] as num?)?.toInt(),
sizeBytes: (m['sizeBytes'] as num?)?.toInt(),
createdMs: (m['createdMs'] as num?)?.toInt(),
);
}
/// Live aircraft/session state, shared across every screen. `_HomePageState`
/// owns the DJI/uploader plumbing and feeds bridge events through
/// [TelemetryReducer]; the screens observe it.
///
/// Telemetry lives in per-subsystem sub-models (plan P1) — [aircraft],
/// [battery], [links], [camera], [gimbal], [vision], [alerts], [mission] — each
/// its own [ChangeNotifier], so a HUD widget can listen to just the one it
/// draws (`ListenableBuilder(listenable: model.battery, …)`) instead of
/// rebuilding with every 10 Hz tick. The model itself re-notifies on any
/// sub-model change — once per [batch], not once per sub-model — which keeps
/// the pre-P2 screens (`AnimatedBuilder(animation: model)`) and their flat
/// getters below working unchanged.
class FlightModel extends ChangeNotifier {
FlightModel() {
for (final ChangeNotifier sub in _subs) {
sub.addListener(_onSub);
}
}
bool _batching = false;
bool _dirty = false;
void _onSub() {
if (_batching) {
_dirty = true;
} else {
notifyListeners();
}
}
/// Runs [update] (several sub-model writes) and notifies this model's own
/// listeners once at the end, if anything changed. Sub-model listeners still
/// hear their own change immediately.
void batch(void Function() update) {
if (_batching) return update();
_batching = true;
try {
update();
} finally {
_batching = false;
if (_dirty) {
_dirty = false;
notifyListeners();
}
}
}
// ── Telemetry sub-models ───────────────────────────────────────────────────
final AircraftState aircraft = AircraftState();
final BatteryState battery = BatteryState();
final LinkState links = LinkState();
final CameraState camera = CameraState();
final GimbalState gimbal = GimbalState();
final VisionState vision = VisionState();
final AlertState alerts = AlertState();
final MissionState mission = MissionState();
/// Manned aircraft the AirSense receiver hears (plan P8).
final AirSenseState airSense = AirSenseState();
/// DJI GEO fly zones around and the latest FlySafe notice (plan P8).
final FlyZoneState flyZones = FlyZoneState();
/// KeyManager values the settings sheet listens to (plan P6).
final KeyState keys = KeyState();
/// The calibration wizards' progress (plan P6b).
final CalibrationState calibration = CalibrationState();
/// RTMP live streaming (plan P9); app-level, survives disconnects.
final LiveStreamState liveStream = LiveStreamState();
/// The aircraft's video player while a video plays from the SD card (plan P9).
final ValueNotifier<MediaPlayback> mediaPlayback = ValueNotifier<MediaPlayback>(const MediaPlayback());
/// Set while a page over the flight screen shows the video feed on its own
/// (the Album playing a file on the aircraft): the flight screen then draws
/// the feed full-screen with no HUD on it.
final ValueNotifier<bool> hudHidden = ValueNotifier<bool>(false);
/// The live feed's picture size in pixels, once the decoder reports it: the
/// native view letterboxes to it, and the touch layers map taps into it.
final ValueNotifier<Size?> videoSize = ValueNotifier<Size?>(null);
/// The remote's latest C1 / C2 press (plan P6); the flight screen acts on it.
final ValueNotifier<RcButtonPress?> rcButton = ValueNotifier<RcButtonPress?>(null);
/// Master remotes in range while the dual-operator search runs.
final ValueNotifier<List<RcPeer>> rcMasters = ValueNotifier<List<RcPeer>>(const <RcPeer>[]);
/// The video link's interference while the spectrum chart watches it.
final ValueNotifier<Spectrum?> spectrum = ValueNotifier<Spectrum?>(null);
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, liveStream, //
];
/// Wipes the aircraft-bound sub-models on disconnect.
void clearTelemetry() => batch(() {
aircraft.clear();
battery.clear();
links.clear();
camera.clear();
gimbal.clear();
vision.clear();
alerts.clear();
keys.clear();
calibration.clear();
airSense.clear();
flyZones.clearAircraft();
});
// ── SDK / registration / connection ────────────────────────────────────────
String sdkVersion = '…';
RegistrationState registration = RegistrationState.idle;
String? registrationError;
bool connected = false;
String? model;
String? firmwareVersion;
/// The remote controller's own firmware — distinct from the aircraft's above.
String? controllerFirmwareVersion;
/// The *flight controller's* serial, which is the only serial MSDK v4 exposes —
/// the airframe serial on the sticker (the one a drone is registered under) is
/// unreadable over the SDK and is entered by hand in the Web App's fleet.
String? flightControllerSerial;
/// What the connected product can do (plan P0). [ProductProfile.disconnected]
/// until the bridge's first `productProfile` event after connect.
ProductProfile profile = ProductProfile.disconnected;
// ── Flight controller telemetry (pre-P2 flat view of [aircraft]) ───────────
int? get satellites => aircraft.satellites;
int? get gpsSignalLevel => aircraft.gpsSignalLevel; // 0..5
bool? get isFlying => aircraft.isFlying;
bool? get motorsOn => aircraft.motorsOn;
String? get flightMode => aircraft.flightMode;
double? get altitude => aircraft.altitude; // m, relative to home
double? get latitude => aircraft.latitude;
double? get longitude => aircraft.longitude;
double? get homeLatitude => aircraft.homeLatitude;
double? get homeLongitude => aircraft.homeLongitude;
double? get homeDistance => aircraft.homeDistance; // m, aircraft ↔ home
double? get horizontalSpeed => aircraft.horizontalSpeed; // m/s
double? get verticalSpeed => aircraft.verticalSpeed; // m/s (up positive)
double? get heading => aircraft.heading; // deg
int? get flightTimeSeconds => aircraft.flightTimeSeconds;
double? get goHomeHeight => aircraft.goHomeHeight; // m
// Phone's own GPS (independent of the drone's fix); streamed to the server as
// a location fallback for the Web App's automatic bounding box.
double? phoneLatitude;
double? phoneLongitude;
// ── Battery (flat view of [battery]) ────────────────────────────────────────
int? get batteryPercent => battery.percent;
double? get batteryVoltage => battery.voltage; // V
double? get batteryTemperature => battery.temperature; // °C
// ── Camera ──────────────────────────────────────────────────────────────────
CaptureMode captureMode = CaptureMode.video;
String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA
bool get isRecording => camera.isRecording;
int get recordSeconds => camera.recordSeconds;
bool get sdInserted => camera.storage?.inserted ?? false;
int? get sdRemainingMB => camera.storage?.remainingMB;
int? get sdPhotoCount => camera.storage?.availableCaptureCount;
int? get sdRecordSeconds => camera.storage?.availableRecordingSeconds;
// ── Exposure ────────────────────────────────────────────────────────────────
// Read back into [camera]; the camera settings page writes them optimistically
// ahead of the next `exposure` event.
ExposureProgram exposureProgram = ExposureProgram.auto;
String? get iso => camera.iso; // "100", "AUTO", …
set iso(String? v) => camera.iso = v;
String? get shutter => camera.shutter; // "1/240"
set shutter(String? v) => camera.shutter = v;
String? get aperture => camera.aperture; // "f/2.8"
set aperture(String? v) => camera.aperture = v;
String? get ev => camera.ev; // "-0.3"
set ev(String? v) => camera.ev = v;
String? whiteBalance; // "5200K" / "AUTO"
List<double>? histogram; // 0..1 normalized bins
// ── Gimbal (flat view of [gimbal]) ──────────────────────────────────────────
double? get gimbalPitch => gimbal.pitch; // deg (−90..30-ish)
double? get gimbalRoll => gimbal.roll;
double? get gimbalYaw => gimbal.yaw;
// ── Missions (flat view of [mission]) ───────────────────────────────────────
String get missionState => mission.state; // idle / ready / uploading / executing / …
bool get missionRunning => mission.running;
bool get tracking => mission.tracking; // ActiveTrack engaged
String? get missionError => mission.error;
// ── On-drone media ──────────────────────────────────────────────────────────
List<MediaItem> media = <MediaItem>[];
bool mediaLoading = false;
/// The aircraft can play its videos into the live feed (`MediaManager.isVideoPlaybackSupported`).
bool mediaVideoPlayback = false;
// ── DJI account (optional, in addition to PilotVault) ───────────────────────
String djiAccountState = 'unknown'; // notLoggedIn / tokenOutOfDate / authorized / …
String? djiAccountUser;
// ── Telemetry upload channel ────────────────────────────────────────────────
UploadStatus upload = UploadStatus.disabled;
bool get registered => registration == RegistrationState.success;
/// Notify observers after a batch of field writes.
void bump() => notifyListeners();
@override
void dispose() {
for (final ChangeNotifier sub in _subs) {
sub.removeListener(_onSub);
sub.dispose();
}
videoSize.dispose();
mediaPlayback.dispose();
hudHidden.dispose();
rcButton.dispose();
rcMasters.dispose();
spectrum.dispose();
super.dispose();
}
}