The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no screen may assume one model. This adds the capability layer the rest of the plan builds on (P0). ProductProfileBridge reads what the SDK can report at runtime (cameras and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and RC feature flags, batteries) and emits it as a productProfile event on connect and component change. Capability ranges stay empty until the camera handshakes, so it re-probes at 3, 10 and 30 s and emits only when something changed. On the Dart side, ProductProfile merges that with a static product_matrix keyed by the MSDK Model enum name, for facts the SDK cannot report: vision sensor layout, battery bay count, intelligent modes. SDK answers win and every fact records its source. HudFeatures lists each capability-gated element with its predicate, and Requires hides a widget the profile rules out. The flight page now gates its gimbal slider, camera column and vision pill, labels the link Wi-Fi or HD, and takes the gimbal pitch range from the profile. Settings > About > Capability profile shows the active profile, fact sources and which features are on. Matrix rows are seeded from published specs until runtime capture confirms them; nothing has run on a real aircraft yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.1 KiB
Dart
168 lines
7.1 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
|
||
import 'fly/product_profile.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;
|
||
|
||
/// 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 ────────────────────────────────────────────
|
||
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();
|
||
}
|