Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
473 lines
18 KiB
Dart
473 lines
18 KiB
Dart
import 'product_matrix.dart';
|
|
|
|
export 'product_matrix.dart' show BatteryKind, IntelligentMode, LinkType, MatrixSource, VisionSensors;
|
|
|
|
/// Where a single resolved fact in a [ProductProfile] came from.
|
|
enum FactSource {
|
|
/// Reported by the SDK at runtime for this aircraft.
|
|
sdk,
|
|
|
|
/// Taken from the static [ProductMatrix] row for the model.
|
|
matrix,
|
|
|
|
/// Neither knew; the profile fell back to a conservative default.
|
|
unknown,
|
|
}
|
|
|
|
/// One camera, as `ProductProfileBridge.kt` reports it.
|
|
///
|
|
/// The option lists use the same labels the camera setters accept (`"100"`,
|
|
/// `"AUTO"`, `"1/240"`, `"f/2.8"`, `"+0.3"`), so P4 can build its pickers from
|
|
/// them directly. They are empty until the camera has finished handshaking.
|
|
class CameraProfile {
|
|
const CameraProfile({
|
|
this.index = 0,
|
|
this.displayName,
|
|
this.isThermal = false,
|
|
this.ssdSupported = false,
|
|
this.opticalZoomSupported = false,
|
|
this.hybridZoomSupported = false,
|
|
this.digitalZoomSupported = false,
|
|
this.tapZoomSupported = false,
|
|
this.adjustableApertureSupported = false,
|
|
this.adjustableFocalPointSupported = false,
|
|
this.meteringSupported = false,
|
|
this.playbackSupported = false,
|
|
this.mediaDownloadSupported = false,
|
|
this.timeLapseSupported = false,
|
|
this.multiLensSupported = false,
|
|
this.interchangeableLensSupported = false,
|
|
this.internalStorageSupported = false,
|
|
this.mechanicalShutterSupported = false,
|
|
this.flatCameraModeSupported = false,
|
|
this.modes = const <String>[],
|
|
this.exposureModes = const <String>[],
|
|
this.isos = const <String>[],
|
|
this.shutterSpeeds = const <String>[],
|
|
this.apertures = const <String>[],
|
|
this.exposureCompensations = const <String>[],
|
|
this.videoResolutions = const <String>[],
|
|
this.photoModes = const <String>[],
|
|
this.whiteBalancePresets = const <String>[],
|
|
this.antiFlickerModes = const <String>[],
|
|
this.videoStandards = const <String>[],
|
|
this.colors = const <String>[],
|
|
});
|
|
|
|
factory CameraProfile.fromMap(Map<String, dynamic> m) {
|
|
bool b(String k) => m[k] as bool? ?? false;
|
|
List<String> l(String k) => ((m[k] as List<dynamic>?) ?? const <dynamic>[]).cast<String>();
|
|
return CameraProfile(
|
|
index: (m['index'] as num?)?.toInt() ?? 0,
|
|
displayName: m['displayName'] as String?,
|
|
isThermal: b('isThermal'),
|
|
ssdSupported: b('ssdSupported'),
|
|
opticalZoomSupported: b('opticalZoomSupported'),
|
|
hybridZoomSupported: b('hybridZoomSupported'),
|
|
digitalZoomSupported: b('digitalZoomSupported'),
|
|
tapZoomSupported: b('tapZoomSupported'),
|
|
adjustableApertureSupported: b('adjustableApertureSupported'),
|
|
adjustableFocalPointSupported: b('adjustableFocalPointSupported'),
|
|
meteringSupported: b('meteringSupported'),
|
|
playbackSupported: b('playbackSupported'),
|
|
mediaDownloadSupported: b('mediaDownloadSupported'),
|
|
timeLapseSupported: b('timeLapseSupported'),
|
|
multiLensSupported: b('multiLensSupported'),
|
|
interchangeableLensSupported: b('interchangeableLensSupported'),
|
|
internalStorageSupported: b('internalStorageSupported'),
|
|
mechanicalShutterSupported: b('mechanicalShutterSupported'),
|
|
flatCameraModeSupported: b('flatCameraModeSupported'),
|
|
modes: l('modes'),
|
|
exposureModes: l('exposureModes'),
|
|
isos: l('isos'),
|
|
shutterSpeeds: l('shutterSpeeds'),
|
|
apertures: l('apertures'),
|
|
exposureCompensations: l('exposureCompensations'),
|
|
videoResolutions: l('videoResolutions'),
|
|
photoModes: l('photoModes'),
|
|
whiteBalancePresets: l('whiteBalancePresets'),
|
|
antiFlickerModes: l('antiFlickerModes'),
|
|
videoStandards: l('videoStandards'),
|
|
colors: l('colors'),
|
|
);
|
|
}
|
|
|
|
final int index;
|
|
final String? displayName;
|
|
final bool isThermal;
|
|
final bool ssdSupported;
|
|
final bool opticalZoomSupported;
|
|
final bool hybridZoomSupported;
|
|
final bool digitalZoomSupported;
|
|
final bool tapZoomSupported;
|
|
final bool adjustableApertureSupported;
|
|
final bool adjustableFocalPointSupported;
|
|
final bool meteringSupported;
|
|
final bool playbackSupported;
|
|
final bool mediaDownloadSupported;
|
|
final bool timeLapseSupported;
|
|
final bool multiLensSupported;
|
|
final bool interchangeableLensSupported;
|
|
final bool internalStorageSupported;
|
|
final bool mechanicalShutterSupported;
|
|
final bool flatCameraModeSupported;
|
|
final List<String> modes;
|
|
final List<String> exposureModes;
|
|
final List<String> isos;
|
|
final List<String> shutterSpeeds;
|
|
final List<String> apertures;
|
|
final List<String> exposureCompensations;
|
|
|
|
/// `RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS` pairs.
|
|
final List<String> videoResolutions;
|
|
|
|
// SDK enum names, from the key cache's range keys.
|
|
final List<String> photoModes; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA …
|
|
final List<String> whiteBalancePresets; // AUTO / SUNNY / CLOUDY / … / CUSTOM
|
|
final List<String> antiFlickerModes; // AUTO / MANUAL_50HZ / MANUAL_60HZ
|
|
final List<String> videoStandards; // PAL / NTSC
|
|
final List<String> colors; // NONE / D_LOG / D_CINELIKE / …
|
|
|
|
/// Optical or hybrid zoom — the cameras that get the zoom column (Mavic 2
|
|
/// Zoom, Z30). Digital-only zoom does not count.
|
|
bool get hasZoom => opticalZoomSupported || hybridZoomSupported;
|
|
}
|
|
|
|
/// A closed range of degrees on one gimbal axis.
|
|
class AxisRange {
|
|
const AxisRange(this.min, this.max);
|
|
final double min;
|
|
final double max;
|
|
|
|
static AxisRange? fromMap(Object? m) {
|
|
if (m is! Map) return null;
|
|
final num? lo = m['min'] as num?, hi = m['max'] as num?;
|
|
if (lo == null || hi == null) return null;
|
|
return AxisRange(lo.toDouble(), hi.toDouble());
|
|
}
|
|
|
|
@override
|
|
String toString() => '${min.toStringAsFixed(0)}…${max.toStringAsFixed(0)}°';
|
|
}
|
|
|
|
class GimbalProfile {
|
|
const GimbalProfile({
|
|
this.index = 0,
|
|
this.pitch,
|
|
this.yaw,
|
|
this.roll,
|
|
this.pitchAdjustable = false,
|
|
this.yawAdjustable = false,
|
|
this.rollAdjustable = false,
|
|
this.pitchRangeExtensionSupported = false,
|
|
this.controller = const <String, AxisRange>{},
|
|
});
|
|
|
|
/// A gimbal whose capability map has not arrived yet (`capabilitiesKnown`
|
|
/// false) is assumed to pitch — every DJI gimbal does — and nothing more, so
|
|
/// the pitch slider does not vanish while the SDK is still handshaking.
|
|
factory GimbalProfile.fromMap(Map<String, dynamic> m) => GimbalProfile(
|
|
index: (m['index'] as num?)?.toInt() ?? 0,
|
|
pitch: AxisRange.fromMap(m['pitch']),
|
|
yaw: AxisRange.fromMap(m['yaw']),
|
|
roll: AxisRange.fromMap(m['roll']),
|
|
pitchAdjustable: m['capabilitiesKnown'] == false || (m['pitchAdjustable'] as bool? ?? false),
|
|
yawAdjustable: m['yawAdjustable'] as bool? ?? false,
|
|
rollAdjustable: m['rollAdjustable'] as bool? ?? false,
|
|
pitchRangeExtensionSupported: m['pitchRangeExtensionSupported'] as bool? ?? false,
|
|
controller: <String, AxisRange>{
|
|
if (m['controller'] case final Map<dynamic, dynamic> c)
|
|
for (final MapEntry<dynamic, dynamic> e in c.entries)
|
|
if (AxisRange.fromMap(e.value) case final AxisRange r) '${e.key}': r,
|
|
},
|
|
);
|
|
|
|
final int index;
|
|
final AxisRange? pitch;
|
|
final AxisRange? yaw;
|
|
final AxisRange? roll;
|
|
final bool pitchAdjustable;
|
|
final bool yawAdjustable;
|
|
final bool rollAdjustable;
|
|
|
|
/// The +30° upward extension (Phantom 4 / Mavic Pro "Extend gimbal pitch").
|
|
final bool pitchRangeExtensionSupported;
|
|
|
|
/// Ranges of the controller tuning values this gimbal supports, keyed by
|
|
/// their `gimbalSettings` id (`pitchSmoothing`, `yawMaxSpeed`,
|
|
/// `pitchSpeedCoefficient`, …). Empty on gimbals without advanced settings.
|
|
final Map<String, AxisRange> controller;
|
|
}
|
|
|
|
class BatteryProfile {
|
|
const BatteryProfile({this.index = 0, this.isSmart, this.cells});
|
|
|
|
factory BatteryProfile.fromMap(Map<String, dynamic> m) => BatteryProfile(
|
|
index: (m['index'] as num?)?.toInt() ?? 0,
|
|
isSmart: m['isSmart'] as bool?,
|
|
cells: (m['cells'] as num?)?.toInt(),
|
|
);
|
|
|
|
final int index;
|
|
final bool? isSmart;
|
|
final int? cells;
|
|
}
|
|
|
|
/// Flight-controller feature flags. Null means the SDK did not answer.
|
|
class FlightControllerCaps {
|
|
const FlightControllerCaps({
|
|
this.flightAssistantSupported,
|
|
this.virtualStickAvailable,
|
|
this.rtkSupported,
|
|
this.compassCount,
|
|
this.imuCount,
|
|
this.propellerCalibrationSupported,
|
|
this.virtualFenceSupported,
|
|
this.accessLockerSupported,
|
|
this.onboardSdkAvailable,
|
|
this.simulatorAvailable,
|
|
this.precisionTakeOffSupported,
|
|
});
|
|
|
|
factory FlightControllerCaps.fromMap(Map<String, dynamic> m) => FlightControllerCaps(
|
|
precisionTakeOffSupported: m['precisionTakeOffSupported'] as bool?,
|
|
flightAssistantSupported: m['flightAssistantSupported'] as bool?,
|
|
virtualStickAvailable: m['virtualStickAvailable'] as bool?,
|
|
rtkSupported: m['rtkSupported'] as bool?,
|
|
compassCount: (m['compassCount'] as num?)?.toInt(),
|
|
imuCount: (m['imuCount'] as num?)?.toInt(),
|
|
propellerCalibrationSupported: m['propellerCalibrationSupported'] as bool?,
|
|
virtualFenceSupported: m['virtualFenceSupported'] as bool?,
|
|
accessLockerSupported: m['accessLockerSupported'] as bool?,
|
|
onboardSdkAvailable: m['onboardSdkAvailable'] as bool?,
|
|
simulatorAvailable: m['simulatorAvailable'] as bool?,
|
|
);
|
|
|
|
final bool? flightAssistantSupported;
|
|
final bool? virtualStickAvailable;
|
|
final bool? rtkSupported;
|
|
final int? compassCount;
|
|
final int? imuCount;
|
|
final bool? propellerCalibrationSupported;
|
|
final bool? virtualFenceSupported;
|
|
final bool? accessLockerSupported;
|
|
final bool? onboardSdkAvailable;
|
|
final bool? simulatorAvailable;
|
|
|
|
/// `IS_PRECISION_TAKE_OFF_SUPPORTED`; null until the flight controller answered.
|
|
final bool? precisionTakeOffSupported;
|
|
}
|
|
|
|
class RemoteControllerCaps {
|
|
const RemoteControllerCaps({
|
|
this.masterSlaveSupported,
|
|
this.focusControllerSupported,
|
|
this.customizableButtonSupported,
|
|
this.multiDevicePairingSupported,
|
|
});
|
|
|
|
factory RemoteControllerCaps.fromMap(Map<String, dynamic> m) => RemoteControllerCaps(
|
|
masterSlaveSupported: m['masterSlaveSupported'] as bool?,
|
|
focusControllerSupported: m['focusControllerSupported'] as bool?,
|
|
customizableButtonSupported: m['customizableButtonSupported'] as bool?,
|
|
multiDevicePairingSupported: m['multiDevicePairingSupported'] as bool?,
|
|
);
|
|
|
|
final bool? masterSlaveSupported;
|
|
final bool? focusControllerSupported;
|
|
final bool? customizableButtonSupported;
|
|
final bool? multiDevicePairingSupported;
|
|
}
|
|
|
|
/// What the connected product is and can do (plan P0). Every HUD element,
|
|
/// settings row and mode tile asks this — through a `HudFeature` predicate —
|
|
/// whether to show, so no screen assumes one model.
|
|
///
|
|
/// Built by [ProductProfile.fromBridge] from the bridge's `productProfile` event,
|
|
/// merged with the [ProductMatrix] row for the model: SDK answers win, the matrix
|
|
/// fills what the SDK cannot report, and [sources] records which one each
|
|
/// resolved fact came from so the debug screen can show it.
|
|
class ProductProfile {
|
|
const ProductProfile({
|
|
this.connected = false,
|
|
this.model,
|
|
this.displayName,
|
|
this.kind = 'unknown',
|
|
this.cameras = const <CameraProfile>[],
|
|
this.gimbals = const <GimbalProfile>[],
|
|
this.batteries = const <BatteryProfile>[],
|
|
this.link = LinkType.unknown,
|
|
this.vision = VisionSensors.unknown,
|
|
this.batteryCount = 0,
|
|
this.batteryKind = BatteryKind.unknown,
|
|
this.modes = const <IntelligentMode>{},
|
|
this.supportsVirtualStick = false,
|
|
this.virtualStickPrimary = false,
|
|
this.flightController,
|
|
this.remoteController,
|
|
this.matrix,
|
|
this.sources = const <String, FactSource>{},
|
|
});
|
|
|
|
/// No product. Every predicate reads false against it.
|
|
static const ProductProfile disconnected = ProductProfile();
|
|
|
|
factory ProductProfile.fromBridge(Map<String, dynamic> m) {
|
|
if (m['connected'] != true) return disconnected;
|
|
|
|
final String? model = m['model'] as String?;
|
|
final ProductMatrixEntry? row = ProductMatrix.lookup(model);
|
|
final Map<String, FactSource> sources = <String, FactSource>{};
|
|
|
|
List<Map<String, dynamic>> maps(String k) => ((m[k] as List<dynamic>?) ?? const <dynamic>[])
|
|
.map((dynamic e) => Map<String, dynamic>.from(e as Map))
|
|
.toList();
|
|
Map<String, dynamic>? map(String k) => m[k] == null ? null : Map<String, dynamic>.from(m[k] as Map);
|
|
|
|
final List<CameraProfile> cameras = maps('cameras').map(CameraProfile.fromMap).toList();
|
|
final List<GimbalProfile> gimbals = maps('gimbals').map(GimbalProfile.fromMap).toList();
|
|
final List<BatteryProfile> batteries = maps('batteries').map(BatteryProfile.fromMap).toList();
|
|
final Map<String, dynamic>? fcMap = map('flightController');
|
|
final Map<String, dynamic>? rcMap = map('remoteController');
|
|
final FlightControllerCaps? fc = fcMap == null ? null : FlightControllerCaps.fromMap(fcMap);
|
|
final RemoteControllerCaps? rc = rcMap == null ? null : RemoteControllerCaps.fromMap(rcMap);
|
|
|
|
// Link: the SDK knows which link is actually up (a Mavic Pro can fly over
|
|
// Wi-Fi); the matrix knows what the model ships with.
|
|
LinkType link = LinkType.unknown;
|
|
final LinkType? sdkLink = switch (m['link'] as String?) {
|
|
'wifi' => LinkType.wifi,
|
|
'lightbridge' => LinkType.lightbridge,
|
|
'ocusync' => LinkType.ocusync,
|
|
_ => null,
|
|
};
|
|
if (sdkLink != null) {
|
|
link = sdkLink;
|
|
sources['link'] = FactSource.sdk;
|
|
} else if (row != null && row.link != LinkType.unknown) {
|
|
link = row.link;
|
|
sources['link'] = FactSource.matrix;
|
|
} else {
|
|
sources['link'] = FactSource.unknown;
|
|
}
|
|
|
|
// Vision layout: the SDK has no query for it. A product that reports no
|
|
// flight assistant at all has no obstacle sensing whatever the matrix says.
|
|
VisionSensors vision = row?.vision ?? VisionSensors.unknown;
|
|
sources['vision'] = row == null ? FactSource.unknown : FactSource.matrix;
|
|
if (fc?.flightAssistantSupported == false && vision.index > VisionSensors.downward.index) {
|
|
vision = VisionSensors.downward;
|
|
sources['vision'] = FactSource.sdk;
|
|
}
|
|
|
|
// Battery count: what the SDK enumerates, else the model's bay count.
|
|
int batteryCount;
|
|
if (batteries.isNotEmpty) {
|
|
batteryCount = batteries.length;
|
|
sources['batteryCount'] = FactSource.sdk;
|
|
} else if (row != null) {
|
|
batteryCount = row.batteryCount;
|
|
sources['batteryCount'] = FactSource.matrix;
|
|
} else {
|
|
batteryCount = 0;
|
|
sources['batteryCount'] = FactSource.unknown;
|
|
}
|
|
|
|
BatteryKind batteryKind;
|
|
final bool? sdkSmart = batteries.isEmpty ? null : batteries.first.isSmart;
|
|
if (sdkSmart != null) {
|
|
batteryKind = sdkSmart ? BatteryKind.smart : BatteryKind.nonSmart;
|
|
sources['batteryKind'] = FactSource.sdk;
|
|
} else if (row != null) {
|
|
batteryKind = row.batteryKind;
|
|
sources['batteryKind'] = FactSource.matrix;
|
|
} else {
|
|
batteryKind = BatteryKind.unknown;
|
|
sources['batteryKind'] = FactSource.unknown;
|
|
}
|
|
|
|
final bool? sdkSticks = fc?.virtualStickAvailable;
|
|
sources['virtualStick'] = sdkSticks == null ? FactSource.unknown : FactSource.sdk;
|
|
|
|
sources['modes'] = row == null ? FactSource.unknown : FactSource.matrix;
|
|
sources['cameras'] = FactSource.sdk;
|
|
sources['gimbals'] = FactSource.sdk;
|
|
|
|
return ProductProfile(
|
|
connected: true,
|
|
model: model,
|
|
displayName: m['modelDisplayName'] as String? ?? model,
|
|
kind: m['productKind'] as String? ?? 'unknown',
|
|
cameras: cameras,
|
|
gimbals: gimbals,
|
|
batteries: batteries,
|
|
link: link,
|
|
vision: vision,
|
|
batteryCount: batteryCount,
|
|
batteryKind: batteryKind,
|
|
modes: row?.modes ?? const <IntelligentMode>{},
|
|
supportsVirtualStick: sdkSticks ?? false,
|
|
virtualStickPrimary: row?.virtualStickPrimary ?? false,
|
|
flightController: fc,
|
|
remoteController: rc,
|
|
matrix: row,
|
|
sources: sources,
|
|
);
|
|
}
|
|
|
|
final bool connected;
|
|
|
|
/// MSDK `Model` enum name (`MAVIC_PRO`, `Spark`, …), the [ProductMatrix] key.
|
|
final String? model;
|
|
final String? displayName;
|
|
|
|
/// `aircraft` / `handheld` / `unknown`.
|
|
final String kind;
|
|
final List<CameraProfile> cameras;
|
|
final List<GimbalProfile> gimbals;
|
|
final List<BatteryProfile> batteries;
|
|
final LinkType link;
|
|
final VisionSensors vision;
|
|
final int batteryCount;
|
|
final BatteryKind batteryKind;
|
|
final Set<IntelligentMode> modes;
|
|
final bool supportsVirtualStick;
|
|
final bool virtualStickPrimary;
|
|
final FlightControllerCaps? flightController;
|
|
final RemoteControllerCaps? remoteController;
|
|
|
|
/// The matrix row the profile was merged with, or null for an unlisted model.
|
|
final ProductMatrixEntry? matrix;
|
|
final Map<String, FactSource> sources;
|
|
|
|
bool get isAircraft => kind == 'aircraft';
|
|
|
|
/// The main camera / gimbal (index 0 — the left/primary payload on dual-gimbal
|
|
/// Matrice frames).
|
|
CameraProfile? get camera => cameras.isEmpty ? null : cameras.first;
|
|
GimbalProfile? get gimbal => gimbals.isEmpty ? null : gimbals.first;
|
|
|
|
bool get hasCamera => cameras.isNotEmpty;
|
|
bool get hasGimbal => gimbals.isNotEmpty;
|
|
bool get hasZoom => cameras.any((CameraProfile c) => c.hasZoom);
|
|
bool get hasThermal => cameras.any((CameraProfile c) => c.isThermal);
|
|
bool get hasSsd => cameras.any((CameraProfile c) => c.ssdSupported);
|
|
|
|
/// RC-class links show separate RC uplink and HD downlink bars; Wi-Fi products
|
|
/// show a single Wi-Fi signal instead.
|
|
bool get hasRcLink => link == LinkType.lightbridge || link == LinkType.ocusync;
|
|
bool get hasWifiLink => link == LinkType.wifi;
|
|
|
|
/// Downward VPS (any layout except none/unknown).
|
|
bool get hasVps => vision.index >= VisionSensors.downward.index;
|
|
|
|
/// Forward obstacle sensing or better — the vision radar has something to draw.
|
|
bool get hasObstacleSensing => vision.index >= VisionSensors.front.index;
|
|
|
|
bool get hasRtk => flightController?.rtkSupported == true;
|
|
bool get hasSimulator => flightController?.simulatorAvailable == true;
|
|
bool get hasMasterSlaveRc => remoteController?.masterSlaveSupported == true;
|
|
|
|
bool supportsMode(IntelligentMode mode) => modes.contains(mode);
|
|
}
|