Files
PilotVault/Fly App/lib/fly/product_profile.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
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>
2026-10-20 23:06:16 +02:00

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);
}