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>
438 lines
16 KiB
Dart
438 lines
16 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>[],
|
|
});
|
|
|
|
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'),
|
|
);
|
|
}
|
|
|
|
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;
|
|
final List<String> videoResolutions;
|
|
|
|
/// 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,
|
|
});
|
|
|
|
/// 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,
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
factory FlightControllerCaps.fromMap(Map<String, dynamic> m) => FlightControllerCaps(
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|