Files
PilotVault/Fly App/lib/flight_model.dart
T
tajniak81andClaude Opus 4.8 183c83c177 Stop reporting the flight controller's serial as the drone's
getSerialNumber() is a BaseComponent method, so every component answers for
itself — and the bridge reads it off the flight controller. A Mavic Pro reports
08RDE1J00103H1 (what DJI Go labels "Flight Controller SN") where the airframe
sticker, and the registration, say 08QDE3H012032E. We were publishing the former
as the drone's serial, onto records that exist to satisfy BEK 1649 §5.

Same trap as 002e484, where a component's own firmware stood in for the
aircraft's, but with no correct source to switch to: MSDK v4 exposes no
aircraft-level serial at all — BaseProduct offers only the model and the
firmware package version — so the registered serial can only be typed by hand.

So split the two rather than pick one:

  serial                    the airframe's, hand-entered, and the only one that
                            reaches the logbook and the CSV export
  flight_controller_serial  what the aircraft reports; auto-filled on connect,
                            and what POST /api/drones/auto now upserts on

Keying auto-add on the flight controller's serial keeps the fleet recognising a
connected drone without typing — it is stable per airframe — while leaving the
compliance record's serial to the pilot. A flight controller swapped in a repair
now costs a duplicate fleet entry to merge, where before it would have quietly
rewritten what the logbook claimed the drone was.

Note droneInput.payload() is a whole-record write, so any UI editing a drone must
round-trip flightControllerSerial; blanking it forks the drone into a duplicate
on its next connect. Drones.vue carries it through the edit form for that reason.

The migration copies existing serials into flight_controller_serial rather than
moving them: every current value came from auto-add and is therefore a flight
controller's, but a pilot may since have corrected one by hand and this cannot
tell them apart. Copying keeps auto-add matching the airframes it matched before.
Applied to the remote PocketBase, where drones held no records, so the backfill
was a no-op there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:36:14 +02:00

163 lines
6.9 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 'package:flutter/foundation.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.durationSeconds,
this.sizeBytes,
this.createdMs,
this.thumbPath,
this.localPath,
});
final int index;
final String fileName;
final bool isVideo;
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;
/// `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',
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 pushes updates here; the screens observe
/// it via [AnimatedBuilder]. Call [bump] after a batch of field writes.
class FlightModel extends ChangeNotifier {
// ── 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;
// ── Flight controller telemetry ────────────────────────────────────────────
int? satellites;
int? gpsSignalLevel; // 0..5
bool? isFlying;
bool? motorsOn;
String? flightMode;
double? altitude; // m, relative to home
double? latitude;
double? longitude;
double? homeLatitude;
double? homeLongitude;
double? homeDistance; // m, aircraft ↔ home
double? horizontalSpeed; // m/s
double? verticalSpeed; // m/s (up positive)
double? heading; // deg
int? flightTimeSeconds;
double? 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 ─────────────────────────────────────────────────────────────────
int? batteryPercent;
double? batteryVoltage; // V
double? batteryTemperature; // °C
// ── Camera ──────────────────────────────────────────────────────────────────
CaptureMode captureMode = CaptureMode.video;
String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA
bool isRecording = false;
int recordSeconds = 0;
bool sdInserted = false;
int? sdRemainingMB;
int? sdPhotoCount;
int? sdVideoCount;
// ── Exposure ────────────────────────────────────────────────────────────────
ExposureProgram exposureProgram = ExposureProgram.auto;
String? iso; // "100", "AUTO", …
String? shutter; // "1/240"
String? aperture; // "f/2.8"
String? ev; // "-0.3"
String? whiteBalance; // "5200K" / "AUTO"
List<double>? histogram; // 0..1 normalized bins
// ── Gimbal ──────────────────────────────────────────────────────────────────
double? gimbalPitch; // deg (90..30-ish)
double? gimbalRoll;
double? gimbalYaw;
// ── Flight settings (Safety / Control) ──────────────────────────────────────
int? maxHeight; // m
int? maxRadius; // m
bool? maxRadiusEnabled;
int? rthHeight; // m
String? obstacleAvoidance; // "On" / "Bypass" / "Off"
bool? noviceMode;
bool arHomePoint = true; // client-side toggle (AR overlay)
// ── Missions ────────────────────────────────────────────────────────────────
String missionState = 'idle'; // idle / ready / uploading / executing / …
bool missionRunning = false;
bool tracking = false; // ActiveTrack engaged
String? missionError;
// ── On-drone media ──────────────────────────────────────────────────────────
List<MediaItem> media = <MediaItem>[];
bool mediaLoading = 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();
}