The GO 4-style HUD needs far more live data than the bridge sent, and pushing every SDK callback straight to Dart would rebuild the screen dozens of times a second. This is the telemetry foundation (plan P1). On the Kotlin side, every source now writes into a TelemetryHub that emits one telemetry event per tick: flight-controller state, gimbal and vision at up to 10 Hz, battery, links, RC, storage and camera settings folded in at up to 2 Hz. Each event carries the full latest snapshot, so a consumer that drops packets never loses a slow group. New bridges cover batteries (every pack, cell voltages, warning thresholds), link quality and RC battery, diagnostics, and obstacle sensing per sensor. KeyBridge adds generic getKey/setKey/listenKey over KeyManager for the settings rows to come. The flight controller also gains smart-RTH confirmation and simulator start/stop. On the Dart side, FlightModel now owns one ChangeNotifier per subsystem (aircraft, battery, links, camera, gimbal, vision, alerts, mission), so a HUD widget can listen to just the one it draws. TelemetryReducer routes events into them, only notifies groups that changed, and gives FlightModel one notification per tick. Flat getters keep the existing screens working. Diagnostics become graded, de-duplicated alerts. Settings > About > Telemetry inspector shows every field live and can start the simulator. Battery no longer arrives as its own event, so the API Server reads the percentage from the telemetry snapshot (the old event is still accepted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
11 KiB
Dart
259 lines
11 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
|
||
import 'fly/product_profile.dart';
|
||
import 'fly/state/aircraft_state.dart';
|
||
import 'fly/state/alert_state.dart';
|
||
import 'fly/state/battery_state.dart';
|
||
import 'fly/state/camera_state.dart';
|
||
import 'fly/state/gimbal_state.dart';
|
||
import 'fly/state/link_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.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 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();
|
||
|
||
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
|
||
aircraft, battery, links, camera, gimbal, vision, alerts, mission, //
|
||
];
|
||
|
||
/// 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();
|
||
});
|
||
|
||
// ── 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;
|
||
|
||
// ── 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 (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;
|
||
|
||
// ── 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();
|
||
}
|
||
super.dispose();
|
||
}
|
||
}
|