Teach the Fly App what the connected aircraft can do

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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 17:56:08 +02:00
1 parent f2a20e541d
commit 606838a0b1
14 files changed
+1648 -42

No files matched your search

@@ -146,18 +146,25 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
}
// ── enum-name → label mapping (for the read-back model) ─────────────────────
private fun shutterLabel(name: String?): String? =
name?.removePrefix("SHUTTER_SPEED_")?.replace("_DOT_", ".")?.replace("_", "/")
// Shared with [ProductProfileBridge], which publishes the capability ranges in
// the same labels the setters above accept.
companion object {
internal fun isoLabel(name: String?): String? =
name?.removePrefix("ISO_")
private fun apertureLabel(name: String?): String? =
name?.removePrefix("F_")?.replace("_DOT_", ".")?.let { "f/$it" }
internal fun shutterLabel(name: String?): String? =
name?.removePrefix("SHUTTER_SPEED_")?.replace("_DOT_", ".")?.replace("_", "/")
private fun evLabel(name: String?): String? {
if (name == null) return null
val neg = name.startsWith("N_")
val body = name.removePrefix("N_").removePrefix("P_").replace("_", ".")
if (body == "0.0") return "0.0"
return (if (neg) "-" else "+") + body
internal fun apertureLabel(name: String?): String? =
name?.removePrefix("F_")?.replace("_DOT_", ".")?.let { "f/$it" }
internal fun evLabel(name: String?): String? {
if (name == null) return null
val neg = name.startsWith("N_")
val body = name.removePrefix("N_").removePrefix("P_").replace("_", ".")
if (body == "0.0") return "0.0"
return (if (neg) "-" else "+") + body
}
}
private inline fun <reified T : Enum<T>> enumOrNull(name: String?): T? {
@@ -22,7 +22,7 @@ import io.flutter.plugin.common.MethodChannel
*
* - [METHOD_CHANNEL] handles imperative calls from Dart, routed to per-subsystem
* helpers ([FlightControllerBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge]).
* [MissionBridge], [MediaBridge], [AccountBridge], [ProductProfileBridge]).
* - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event
* is a map carrying a `type` key.
*
@@ -101,8 +101,9 @@ class DjiSdkBridge(
private val mission = MissionBridge(ctx)
private val media = MediaBridge(ctx)
private val account = AccountBridge(ctx)
private val profile = ProductProfileBridge(ctx)
private val subBridges = listOf(flight, camera, gimbal, mission, media, account)
private val subBridges = listOf(flight, camera, gimbal, mission, media, account, profile)
init {
methodChannel.setMethodCallHandler(this)
@@ -0,0 +1,231 @@
package com.pilotvault.flyapp
import dji.common.gimbal.CapabilityKey
import dji.common.util.DJIParamMinMaxCapability
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.gimbal.Gimbal
import dji.sdk.products.Aircraft
import dji.sdk.products.HandHeld
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The product capability layer (plan P0): what the connected product *is* and
* what it can do, as reported by the SDK itself. Emits a `productProfile` event
* on connect and on every component change, and answers `getProductProfile`.
*
* Only facts the SDK reports go here. What it cannot report — the vision sensor
* layout, the battery count of a disconnected bay, which intelligent modes the
* firmware offers — comes from the Dart-side `product_matrix.dart`, keyed by
* [BaseProduct.getModel]'s enum name, which is why `model` is published raw.
*
* Every probe is wrapped: many `is…Supported()` getters are abstract and some
* products' implementations throw instead of answering false, and one bad
* getter must not cost the pilot the rest of the profile.
*/
class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
companion object {
/** Coalesces the burst of component changes that follows a connect. */
private const val SETTLE_MS = 400L
/**
* Camera capability ranges are backed by the SDK's key cache and come back
* empty until the camera has finished its handshake, so the profile is
* rebuilt a few times after connect; only changed profiles are emitted.
*/
private val REFRESH_AFTER_MS = longArrayOf(3_000L, 10_000L, 30_000L)
}
/** Invalidates queued rebuilds when the product changes again. */
@Volatile private var generation = 0
/** Last profile emitted, so the refresh passes stay quiet when nothing moved. */
private var lastEmitted: Map<String, Any?>? = null
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
if (call.method != "getProductProfile") return false
ctx.mainHandler.post { result.success(build(ctx.product())) }
return true
}
override fun bind(product: BaseProduct?) {
ctx.mainHandler.post {
val gen = ++generation
lastEmitted = null
ctx.mainHandler.postDelayed({ rebuild(gen) }, SETTLE_MS)
for (delay in REFRESH_AFTER_MS) {
ctx.mainHandler.postDelayed({ rebuild(gen) }, SETTLE_MS + delay)
}
}
}
/** Main thread only. */
private fun rebuild(gen: Int) {
if (gen != generation) return
val product = DJISDKManager.getInstance().product
if (product == null || !product.isConnected) return
val profile = build(product)
if (profile == lastEmitted) return
lastEmitted = profile
ctx.emit(profile)
}
private fun build(product: BaseProduct?): Map<String, Any?> {
if (product == null || !product.isConnected) {
return mapOf("type" to "productProfile", "connected" to false)
}
val aircraft = product as? Aircraft
val model = product.model
return mapOf(
"type" to "productProfile",
"connected" to true,
"model" to model?.name,
"modelDisplayName" to model?.displayName,
"productKind" to when (product) {
is Aircraft -> "aircraft"
is HandHeld -> "handheld"
else -> "unknown"
},
"cameras" to probe { product.cameras }.orEmpty().filterNotNull().map(::cameraMap),
"gimbals" to gimbals(product).map(::gimbalMap),
"link" to linkType(product),
"flightController" to aircraft?.flightController?.let { fc ->
mapOf(
"flightAssistantSupported" to probe { fc.isFlightAssistantSupported },
"virtualStickAvailable" to probe { fc.isVirtualStickControlModeAvailable },
"rtkSupported" to probe { fc.isRTKSupported },
"compassCount" to probe { fc.compassCount },
"imuCount" to probe { fc.imuCount },
"propellerCalibrationSupported" to probe { fc.isPropellerCalibrationSupported },
"virtualFenceSupported" to probe { fc.isVirtualFenceSupported },
"accessLockerSupported" to probe { fc.isAccessLockerSupported },
"onboardSdkAvailable" to probe { fc.isOnboardSDKDeviceAvailable },
"simulatorAvailable" to (probe { fc.simulator } != null),
)
},
"remoteController" to aircraft?.remoteController?.let { rc ->
mapOf(
"masterSlaveSupported" to probe { rc.isMasterSlaveModeSupported },
"focusControllerSupported" to probe { rc.isFocusControllerSupported },
"customizableButtonSupported" to probe { rc.isCustomizableButtonSupported },
"multiDevicePairingSupported" to probe { rc.isMultiDevicePairingSupported },
)
},
"batteries" to probe { product.batteries }.orEmpty().filterNotNull().map { b ->
mapOf(
"index" to probe { b.index },
"isSmart" to probe { b.isSmartBattery },
"cells" to probe { b.numberOfCells }?.takeIf { it > 0 },
)
},
)
}
// ── Camera ──────────────────────────────────────────────────────────────────
private fun cameraMap(cam: Camera): Map<String, Any?> {
val caps = probe { cam.capabilities }
return mapOf(
"index" to probe { cam.index },
"displayName" to probe { cam.displayName },
"isThermal" to probe { cam.isThermalCamera },
"ssdSupported" to probe { cam.isSSDSupported },
"opticalZoomSupported" to probe { cam.isOpticalZoomSupported },
"hybridZoomSupported" to probe { cam.isHybridZoomSupported },
"digitalZoomSupported" to probe { cam.isDigitalZoomSupported },
"tapZoomSupported" to probe { cam.isTapZoomSupported },
"adjustableApertureSupported" to probe { cam.isAdjustableApertureSupported },
"adjustableFocalPointSupported" to probe { cam.isAdjustableFocalPointSupported },
"meteringSupported" to probe { cam.isMeteringSupported },
"playbackSupported" to probe { cam.isPlaybackSupported },
"mediaDownloadSupported" to probe { cam.isMediaDownloadModeSupported },
"timeLapseSupported" to probe { cam.isTimeLapseSupported },
"multiLensSupported" to probe { cam.isMultiLensCameraSupported },
"interchangeableLensSupported" to probe { cam.isInterchangeableLensSupported },
"internalStorageSupported" to probe { cam.isInternalStorageSupported },
"mechanicalShutterSupported" to probe { cam.isMechanicalShutterSupported },
"flatCameraModeSupported" to probe { cam.isFlatCameraModeSupported },
"modes" to names(probe { caps?.modeRange() }),
"exposureModes" to names(probe { caps?.exposureModeRange() }),
"isos" to names(probe { caps?.ISORange() }, CameraBridge.Companion::isoLabel),
"shutterSpeeds" to names(probe { caps?.shutterSpeedRange() }, CameraBridge.Companion::shutterLabel),
"apertures" to names(probe { caps?.apertureRange() }, CameraBridge.Companion::apertureLabel),
"exposureCompensations" to names(probe { caps?.exposureCompensationRange() }, CameraBridge.Companion::evLabel),
"videoResolutions" to probe { caps?.videoResolutionAndFrameRateRange() }.orEmpty()
.filterNotNull()
.mapNotNull { r ->
val res = r.resolution?.name ?: return@mapNotNull null
val fps = r.frameRate?.name ?: return@mapNotNull null
"$res@$fps"
}
.distinct(),
)
}
/** Enum names (or their setter labels) with the SDK's UNKNOWN placeholders dropped. */
private fun names(values: Array<out Enum<*>?>?, label: (String?) -> String? = { it }): List<String> =
values.orEmpty()
.mapNotNull { it?.name }
.filter { it != "UNKNOWN" }
.mapNotNull(label)
.distinct()
// ── Gimbal ──────────────────────────────────────────────────────────────────
private fun gimbals(product: BaseProduct): List<Gimbal> {
val many = (product as? Aircraft)?.let { probe { it.gimbals } }.orEmpty().filterNotNull()
if (many.isNotEmpty()) return many
return listOfNotNull(probe { product.gimbal })
}
private fun gimbalMap(g: Gimbal): Map<String, Any?> {
val caps = probe { g.capabilities }.orEmpty()
fun range(key: CapabilityKey): Map<String, Any?>? {
val c = caps[key] as? DJIParamMinMaxCapability ?: return null
if (!c.isSupported) return null
return mapOf("min" to c.min?.toDouble(), "max" to c.max?.toDouble())
}
fun supported(key: CapabilityKey): Boolean = caps[key]?.isSupported == true
return mapOf(
"index" to probe { g.index },
// Empty until the gimbal has handshaken; Dart then assumes pitch only.
"capabilitiesKnown" to caps.isNotEmpty(),
"pitch" to range(CapabilityKey.ADJUST_PITCH),
"yaw" to range(CapabilityKey.ADJUST_YAW),
"roll" to range(CapabilityKey.ADJUST_ROLL),
"pitchAdjustable" to supported(CapabilityKey.ADJUST_PITCH),
"yawAdjustable" to supported(CapabilityKey.ADJUST_YAW),
"rollAdjustable" to supported(CapabilityKey.ADJUST_ROLL),
"pitchRangeExtensionSupported" to supported(CapabilityKey.PITCH_RANGE_EXTENSION),
)
}
// ── Link ────────────────────────────────────────────────────────────────────
/**
* `ocusync` / `lightbridge` / `wifi`, or null when the air link has not been
* enumerated yet (the matrix then supplies the model's usual link). The RC
* links are checked before Wi-Fi so that a product reporting more than one
* resolves to its RC link; a Wi-Fi-only connection still lands on `wifi`.
*/
private fun linkType(product: BaseProduct): String? {
val link = probe { product.airLink } ?: return null
return when {
probe { link.isOcuSyncLinkSupported } == true -> "ocusync"
probe { link.isLightbridgeLinkSupported } == true -> "lightbridge"
probe { link.isWiFiLinkSupported } == true -> "wifi"
else -> null
}
}
/** Runs one SDK probe; a throwing getter reads as "unknown" rather than failing the profile. */
private inline fun <T> probe(block: () -> T): T? = try {
block()
} catch (_: Throwable) {
null
}
}
+9 -1
View File
@@ -11,7 +11,8 @@ import 'package:flutter/services.dart';
/// native side answers with a typed error the UI can surface. The event stream
/// carries maps keyed by `type`: `registration`, `connection`, `identity`,
/// `telemetry`, `battery`, `camera`, `exposure`, `gimbal`, `mission`,
/// `mediaList`, `mediaDownload`, `djiAccount`, `database`, `init`.
/// `mediaList`, `mediaDownload`, `djiAccount`, `productProfile`, `database`,
/// `init`.
class DjiService {
static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
static const EventChannel _events = EventChannel('dji_msdk/events');
@@ -38,6 +39,13 @@ class DjiService {
return Map<String, dynamic>.from(info as Map);
}
/// The raw capability map `ProductProfile.fromBridge` consumes. The same map
/// arrives unasked as a `productProfile` event on connect and component change.
Future<Map<String, dynamic>> getProductProfile() async {
final dynamic profile = await _methods.invokeMethod('getProductProfile');
return Map<String, dynamic>.from(profile as Map);
}
// ── Flight controller commands ──────────────────────────────────────────────
Future<void> takeOff() => _methods.invokeMethod<void>('takeOff');
Future<void> land() => _methods.invokeMethod<void>('land');
+5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'fly/product_profile.dart';
import 'uploader.dart';
enum RegistrationState { idle, registering, success, failed }
@@ -77,6 +78,10 @@ class FlightModel extends ChangeNotifier {
/// 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
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/widgets.dart';
import 'product_profile.dart';
/// One capability-gated piece of UI: a HUD element, settings row or mode tile.
///
/// Its [requires] predicate is the only place that decides whether the element
/// exists on the connected product — GO 4 does the same with per-product `gone`
/// views. Gate a widget with [Requires]; list every feature in
/// [HudFeatures.all] so the debug screen and the widget tests see it.
class HudFeature {
const HudFeature(this.id, this.label, this.group, this.requires);
final String id;
final String label;
/// Where the element lives (`Status bar`, `Camera`, …), for the debug screen.
final String group;
final bool Function(ProductProfile p) requires;
bool enabledFor(ProductProfile p) => requires(p);
}
abstract final class HudFeatures {
// ── Status bar ────────────────────────────────────────────────────────────
static final HudFeature rcHdSignal = HudFeature(
'status.rcHdSignal', 'RC + HD signal bars', 'Status bar', (ProductProfile p) => p.hasRcLink);
static final HudFeature wifiSignal = HudFeature(
'status.wifiSignal', 'Wi-Fi signal', 'Status bar', (ProductProfile p) => p.hasWifiLink);
static final HudFeature visionIndicator = HudFeature(
'status.vision', 'Vision system icon', 'Status bar', (ProductProfile p) => p.hasVps);
static final HudFeature masterSlaveBadge = HudFeature(
'status.masterSlave', 'Master / slave RC badge', 'Status bar', (ProductProfile p) => p.hasMasterSlaveRc);
static final HudFeature rtkStatus = HudFeature(
'status.rtk', 'RTK status', 'Status bar', (ProductProfile p) => p.hasRtk);
// ── Battery ───────────────────────────────────────────────────────────────
static final HudFeature singleBattery = HudFeature(
'battery.single', 'Single battery bar', 'Battery', (ProductProfile p) => p.batteryCount == 1);
static final HudFeature dualBattery = HudFeature(
'battery.dual', 'Dual battery bars', 'Battery', (ProductProfile p) => p.batteryCount == 2);
static final HudFeature multiBattery = HudFeature(
'battery.multi', 'Multi-battery summary', 'Battery', (ProductProfile p) => p.batteryCount > 2);
static final HudFeature nonSmartBattery = HudFeature(
'battery.nonSmart', 'Cell-voltage actions (non-smart)', 'Battery',
(ProductProfile p) => p.batteryKind == BatteryKind.nonSmart);
// ── Camera ────────────────────────────────────────────────────────────────
static final HudFeature cameraControls = HudFeature(
'camera.controls', 'Shutter + mode column', 'Camera', (ProductProfile p) => p.hasCamera);
static final HudFeature apertureReadout = HudFeature(
'camera.aperture', 'Aperture readout + wheel', 'Camera',
(ProductProfile p) => p.camera?.adjustableApertureSupported == true);
static final HudFeature zoomColumn = HudFeature(
'camera.zoom', 'Zoom column', 'Camera', (ProductProfile p) => p.hasZoom);
static final HudFeature ssdStatus = HudFeature(
'camera.ssd', 'SSD status', 'Camera', (ProductProfile p) => p.hasSsd);
static final HudFeature thermalReadout = HudFeature(
'camera.thermal', 'Thermal readouts', 'Camera', (ProductProfile p) => p.hasThermal);
static final HudFeature spotMetering = HudFeature(
'camera.metering', 'Tap-to-meter', 'Camera', (ProductProfile p) => p.camera?.meteringSupported == true);
// ── Gimbal ────────────────────────────────────────────────────────────────
static final HudFeature gimbalPitch = HudFeature(
'gimbal.pitch', 'Gimbal pitch slider', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true);
static final HudFeature gimbalYaw = HudFeature(
'gimbal.yaw', 'Gimbal yaw drag', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true);
static final HudFeature gimbalPitchExtension = HudFeature(
'gimbal.pitchExtension', 'Extend pitch to +30°', 'Gimbal',
(ProductProfile p) => p.gimbal?.pitchRangeExtensionSupported == true);
// ── Vision / control ──────────────────────────────────────────────────────
static final HudFeature vpsReadout = HudFeature(
'telemetry.vps', 'VPS height', 'Telemetry', (ProductProfile p) => p.hasVps);
static final HudFeature visionRadar = HudFeature(
'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing);
static final HudFeature virtualSticks = HudFeature(
'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick);
static final HudFeature simulator = HudFeature(
'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator);
// ── Intelligent flight modes ──────────────────────────────────────────────
static final List<HudFeature> modes = <HudFeature>[
for (final IntelligentMode m in IntelligentMode.values)
HudFeature('mode.${m.name}', _modeLabel(m), 'Intelligent modes', (ProductProfile p) => p.supportsMode(m)),
];
static final List<HudFeature> all = <HudFeature>[
rcHdSignal,
wifiSignal,
visionIndicator,
masterSlaveBadge,
rtkStatus,
singleBattery,
dualBattery,
multiBattery,
nonSmartBattery,
cameraControls,
apertureReadout,
zoomColumn,
ssdStatus,
thermalReadout,
spotMetering,
gimbalPitch,
gimbalYaw,
gimbalPitchExtension,
vpsReadout,
visionRadar,
virtualSticks,
simulator,
...modes,
];
static String _modeLabel(IntelligentMode m) => switch (m) {
IntelligentMode.waypoint => 'Waypoints',
IntelligentMode.waypointV2 => 'Waypoints V2',
IntelligentMode.pointOfInterest => 'Point of Interest',
IntelligentMode.activeTrack => 'ActiveTrack',
IntelligentMode.tapFly => 'TapFly',
IntelligentMode.followMe => 'Follow Me',
IntelligentMode.quickShots => 'QuickShots',
IntelligentMode.courseLock => 'Course Lock',
IntelligentMode.homeLock => 'Home Lock',
IntelligentMode.tripod => 'Tripod',
IntelligentMode.hyperlapse => 'Hyperlapse',
};
}
/// Makes the active [ProductProfile] available to every [Requires] below it.
class ProductProfileScope extends InheritedWidget {
const ProductProfileScope({super.key, required this.profile, required super.child});
final ProductProfile profile;
/// The nearest scope's profile, or [ProductProfile.disconnected] when none.
static ProductProfile of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ProductProfileScope>()?.profile ??
ProductProfile.disconnected;
@override
bool updateShouldNotify(ProductProfileScope oldWidget) => !identical(profile, oldWidget.profile);
}
/// Shows [child] only when [feature] is enabled for the scope's profile.
///
/// With no product connected there is nothing to be capable *of*, so the gate
/// is open by default and the HUD keeps its full layout as a preview; pass
/// `whenDisconnected: false` for elements that must never appear without a
/// product.
class Requires extends StatelessWidget {
const Requires(this.feature, {super.key, required this.child, this.fallback, this.whenDisconnected = true});
final HudFeature feature;
final Widget child;
final Widget? fallback;
final bool whenDisconnected;
@override
Widget build(BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
final bool on = p.connected ? feature.enabledFor(p) : whenDisconnected;
return on ? child : (fallback ?? const SizedBox.shrink());
}
}
+204
View File
@@ -0,0 +1,204 @@
/// Static per-model facts the SDK does not report (plan §1.7 / P0).
///
/// `ProductProfileBridge.kt` publishes everything MSDK V4 can answer at runtime
/// — cameras and their ranges, gimbal axes, the air link, RC and FC feature
/// flags, the batteries it can see. This table fills the rest: the vision sensor
/// layout, the usual link before the air link has enumerated, how many battery
/// bays a model has, and which intelligent flight modes its firmware offers.
///
/// Keys are `dji.common.product.Model` enum names exactly as MSDK 4.18 spells
/// them (the bridge publishes `model` raw), so `Phantom_3_4K` and `Spark` keep
/// their odd casing.
///
/// Every row says where its facts came from ([MatrixSource]). The first seed is
/// [MatrixSource.publishedSpec]; runtime capture against GO 4 on real aircraft
/// (research track R2) upgrades rows to [MatrixSource.runtimeCapture] as it
/// corrects them. Where the SDK reports the same fact at runtime, the SDK wins —
/// see `ProductProfile.fromBridge`.
library;
/// The transmission system between the aircraft and the pilot.
enum LinkType { wifi, lightbridge, ocusync, unknown }
/// The vision sensor layout, coarsest to richest. `downward` is VPS only (no
/// obstacle sensing); `front`, `frontBack` and `omni` all include the downward
/// VPS as well.
enum VisionSensors { unknown, none, downward, front, frontBack, omni }
enum BatteryKind { smart, nonSmart, unknown }
/// Intelligent flight modes, as the IFM picker (P7) will offer them.
enum IntelligentMode {
waypoint,
waypointV2,
pointOfInterest,
activeTrack,
tapFly,
followMe,
quickShots,
courseLock,
homeLock,
tripod,
hyperlapse,
}
/// Where a matrix row's facts come from.
enum MatrixSource {
/// Checked against the MSDK 4.18 jar / runtime SDK answers.
verifiedSdk,
/// Read off GO 4's per-product layouts and strings.
go4Layouts,
/// Observed in GO 4 running against the real aircraft (track R2).
runtimeCapture,
/// DJI's published product specifications; the first seed, still to be
/// confirmed by [runtimeCapture].
publishedSpec,
}
class ProductMatrixEntry {
const ProductMatrixEntry({
required this.model,
required this.family,
required this.link,
required this.vision,
this.batteryCount = 1,
this.batteryKind = BatteryKind.smart,
this.modes = const <IntelligentMode>{},
this.virtualStickPrimary = false,
this.go4Supported = true,
this.source = MatrixSource.publishedSpec,
});
/// MSDK `Model` enum name.
final String model;
/// Human family name, used to group rows and in the debug screen.
final String family;
/// The link the model ships with. Products with more than one (a Mavic Pro
/// flown over Wi-Fi) are corrected at runtime by the SDK's answer.
final LinkType link;
final VisionSensors vision;
final int batteryCount;
final BatteryKind batteryKind;
final Set<IntelligentMode> modes;
/// True for aircraft commonly flown with no RC at all (phone over Wi-Fi), where
/// on-screen virtual sticks are the primary control rather than a test tool.
final bool virtualStickPrimary;
/// False for models MSDK V4 drives but DJI GO 4 itself never supported (DJI
/// Fly / DJI Pilot products). They stay in the table so a connect still
/// resolves to sensible facts.
final bool go4Supported;
final MatrixSource source;
}
const Set<IntelligentMode> _gpsClassic = <IntelligentMode>{
IntelligentMode.waypoint,
IntelligentMode.pointOfInterest,
IntelligentMode.followMe,
IntelligentMode.courseLock,
IntelligentMode.homeLock,
};
const Set<IntelligentMode> _visionClassic = <IntelligentMode>{
..._gpsClassic,
IntelligentMode.activeTrack,
IntelligentMode.tapFly,
};
const Set<IntelligentMode> _visionTripod = <IntelligentMode>{
..._visionClassic,
IntelligentMode.tripod,
};
const Set<IntelligentMode> _mavic2 = <IntelligentMode>{
..._visionTripod,
IntelligentMode.quickShots,
IntelligentMode.hyperlapse,
};
const Set<IntelligentMode> _enterprise = <IntelligentMode>{
IntelligentMode.waypoint,
IntelligentMode.pointOfInterest,
IntelligentMode.activeTrack,
IntelligentMode.tapFly,
};
/// Spark and Mavic Air: MSDK V4 offers no waypoint or POI missions on them.
const Set<IntelligentMode> _wifiConsumer = <IntelligentMode>{
IntelligentMode.activeTrack,
IntelligentMode.tapFly,
IntelligentMode.quickShots,
};
const Set<IntelligentMode> _matrice = <IntelligentMode>{
IntelligentMode.waypoint,
IntelligentMode.pointOfInterest,
IntelligentMode.followMe,
};
abstract final class ProductMatrix {
static const List<ProductMatrixEntry> entries = <ProductMatrixEntry>[
// ── Phantom 3 ──────────────────────────────────────────────────────────────
ProductMatrixEntry(model: 'PHANTOM_3_STANDARD', family: 'Phantom 3', link: LinkType.wifi, vision: VisionSensors.none, modes: _gpsClassic),
ProductMatrixEntry(model: 'Phantom_3_4K', family: 'Phantom 3', link: LinkType.wifi, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'Phantom_3_SE', family: 'Phantom 3', link: LinkType.wifi, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'PHANTOM_3_ADVANCED', family: 'Phantom 3', link: LinkType.lightbridge, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'PHANTOM_3_PROFESSIONAL', family: 'Phantom 3', link: LinkType.lightbridge, vision: VisionSensors.downward, modes: _gpsClassic),
// ── Phantom 4 ──────────────────────────────────────────────────────────────
ProductMatrixEntry(model: 'PHANTOM_4', family: 'Phantom 4', link: LinkType.lightbridge, vision: VisionSensors.front, modes: _visionClassic),
ProductMatrixEntry(model: 'PHANTOM_4_ADVANCED', family: 'Phantom 4 Pro', link: LinkType.lightbridge, vision: VisionSensors.front, modes: _visionTripod),
ProductMatrixEntry(model: 'PHANTOM_4_PRO', family: 'Phantom 4 Pro', link: LinkType.lightbridge, vision: VisionSensors.frontBack, modes: _visionTripod),
ProductMatrixEntry(model: 'PHANTOM_4_PRO_V2', family: 'Phantom 4 Pro', link: LinkType.ocusync, vision: VisionSensors.frontBack, modes: _visionTripod),
ProductMatrixEntry(model: 'PHANTOM_4_RTK', family: 'Phantom 4 RTK', link: LinkType.ocusync, vision: VisionSensors.frontBack, modes: <IntelligentMode>{IntelligentMode.waypoint}),
ProductMatrixEntry(model: 'P_4_MULTISPECTRAL', family: 'Phantom 4 RTK', link: LinkType.ocusync, vision: VisionSensors.frontBack, modes: <IntelligentMode>{IntelligentMode.waypoint}, go4Supported: false),
// ── Mavic ──────────────────────────────────────────────────────────────────
ProductMatrixEntry(model: 'MAVIC_PRO', family: 'Mavic Pro', link: LinkType.ocusync, vision: VisionSensors.front, modes: _visionTripod),
ProductMatrixEntry(model: 'MAVIC_AIR', family: 'Mavic Air', link: LinkType.wifi, vision: VisionSensors.frontBack, modes: _wifiConsumer, virtualStickPrimary: true),
ProductMatrixEntry(model: 'MAVIC_2_PRO', family: 'Mavic 2', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _mavic2),
ProductMatrixEntry(model: 'MAVIC_2_ZOOM', family: 'Mavic 2', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _mavic2),
ProductMatrixEntry(model: 'MAVIC_2', family: 'Mavic 2', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _mavic2),
ProductMatrixEntry(model: 'MAVIC_2_ENTERPRISE', family: 'Mavic 2 Enterprise', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _enterprise),
ProductMatrixEntry(model: 'MAVIC_2_ENTERPRISE_DUAL', family: 'Mavic 2 Enterprise', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _enterprise),
ProductMatrixEntry(model: 'MAVIC_2_ENTERPRISE_ADVANCED', family: 'Mavic 2 Enterprise', link: LinkType.ocusync, vision: VisionSensors.omni, modes: _enterprise, go4Supported: false),
// ── Spark / Mini (Wi-Fi consumer) ──────────────────────────────────────────
ProductMatrixEntry(model: 'Spark', family: 'Spark', link: LinkType.wifi, vision: VisionSensors.front, modes: _wifiConsumer, virtualStickPrimary: true),
ProductMatrixEntry(model: 'MAVIC_MINI', family: 'Mavic Mini', link: LinkType.wifi, vision: VisionSensors.downward, virtualStickPrimary: true, go4Supported: false),
ProductMatrixEntry(model: 'DJI_MINI_SE', family: 'Mavic Mini', link: LinkType.wifi, vision: VisionSensors.downward, virtualStickPrimary: true, go4Supported: false),
ProductMatrixEntry(model: 'DJI_MINI_2', family: 'Mavic Mini', link: LinkType.ocusync, vision: VisionSensors.downward, go4Supported: false),
ProductMatrixEntry(model: 'MAVIC_AIR_2', family: 'Air 2', link: LinkType.ocusync, vision: VisionSensors.frontBack, go4Supported: false),
ProductMatrixEntry(model: 'DJI_AIR_2S', family: 'Air 2', link: LinkType.ocusync, vision: VisionSensors.frontBack, go4Supported: false),
// ── Inspire ────────────────────────────────────────────────────────────────
ProductMatrixEntry(model: 'INSPIRE_1', family: 'Inspire 1', link: LinkType.lightbridge, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'INSPIRE_1_PRO', family: 'Inspire 1', link: LinkType.lightbridge, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'INSPIRE_1_RAW', family: 'Inspire 1', link: LinkType.lightbridge, vision: VisionSensors.downward, modes: _gpsClassic),
ProductMatrixEntry(model: 'INSPIRE_2', family: 'Inspire 2', link: LinkType.lightbridge, vision: VisionSensors.front, batteryCount: 2, modes: _visionClassic),
// ── Matrice ────────────────────────────────────────────────────────────────
ProductMatrixEntry(model: 'MATRICE_100', family: 'Matrice 100', link: LinkType.lightbridge, vision: VisionSensors.none, modes: _matrice),
ProductMatrixEntry(model: 'MATRICE_200', family: 'Matrice 200', link: LinkType.lightbridge, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise),
ProductMatrixEntry(model: 'MATRICE_210', family: 'Matrice 200', link: LinkType.lightbridge, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise),
ProductMatrixEntry(model: 'MATRICE_210_RTK', family: 'Matrice 200', link: LinkType.lightbridge, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise),
ProductMatrixEntry(model: 'MATRICE_200_V2', family: 'Matrice 200', link: LinkType.ocusync, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise, go4Supported: false),
ProductMatrixEntry(model: 'MATRICE_210_V2', family: 'Matrice 200', link: LinkType.ocusync, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise, go4Supported: false),
ProductMatrixEntry(model: 'MATRICE_210_RTK_V2', family: 'Matrice 200', link: LinkType.ocusync, vision: VisionSensors.front, batteryCount: 2, modes: _enterprise, go4Supported: false),
ProductMatrixEntry(model: 'MATRICE_600', family: 'Matrice 600', link: LinkType.lightbridge, vision: VisionSensors.none, batteryCount: 6, modes: _matrice),
ProductMatrixEntry(model: 'MATRICE_600_PRO', family: 'Matrice 600', link: LinkType.lightbridge, vision: VisionSensors.none, batteryCount: 6, modes: _matrice),
ProductMatrixEntry(model: 'MATRICE_300_RTK', family: 'Matrice 300', link: LinkType.ocusync, vision: VisionSensors.omni, batteryCount: 2, modes: <IntelligentMode>{IntelligentMode.waypointV2}, go4Supported: false),
// ── Flight controllers on custom frames (A3 / N3) ──────────────────────────
ProductMatrixEntry(model: 'A3', family: 'A3 / N3', link: LinkType.unknown, vision: VisionSensors.none, batteryKind: BatteryKind.nonSmart, modes: _matrice),
ProductMatrixEntry(model: 'N3', family: 'A3 / N3', link: LinkType.unknown, vision: VisionSensors.none, batteryKind: BatteryKind.nonSmart, modes: _matrice),
];
static final Map<String, ProductMatrixEntry> _byModel = <String, ProductMatrixEntry>{
for (final ProductMatrixEntry e in entries) e.model: e,
};
/// The row for an MSDK `Model` enum name, or null for unknown / handheld /
/// placeholder models (`UNKNOWN_AIRCRAFT`, `DISCONNECT`, the Osmo family).
static ProductMatrixEntry? lookup(String? model) => model == null ? null : _byModel[model];
}
+437
View File
@@ -0,0 +1,437 @@
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);
}
+12
View File
@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
import 'dji_service.dart';
import 'flight_model.dart';
import 'fly/product_profile.dart';
import 'login_page.dart';
import 'pb_auth.dart';
import 'theme.dart';
@@ -156,6 +157,16 @@ class _HomePageState extends State<HomePage> {
_model.controllerFirmwareVersion = event['controllerFirmware'] as String?;
_model.flightControllerSerial = event['flightControllerSerial'] as String?;
if (!_model.connected) _clearTelemetry();
// A product swap reports connected=true with a new model; drop the old
// aircraft's profile until the new one arrives.
if (_model.profile.displayName != _model.model) _model.profile = ProductProfile.disconnected;
_model.bump();
break;
case 'productProfile':
// Rebuilt natively on every component change; a profile trailing a
// disconnect must not resurrect the old aircraft's capabilities.
if (!_model.connected) break;
_model.profile = ProductProfile.fromBridge(event);
_model.bump();
break;
case 'identity':
@@ -274,6 +285,7 @@ class _HomePageState extends State<HomePage> {
_model.firmwareVersion = null;
_model.controllerFirmwareVersion = null;
_model.flightControllerSerial = null;
_model.profile = ProductProfile.disconnected;
}
Future<void> _register() async {
+33 -22
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../fly/hud_features.dart';
import '../fly/product_profile.dart';
import '../theme.dart';
import 'camera_settings_page.dart';
import 'capture_modes_page.dart';
@@ -139,9 +141,13 @@ class _FlightControlPageState extends State<FlightControlPage> {
return r ?? false;
}
/// Fallback pitch range (+30° … −90°) until the profile reports the gimbal's.
static const AxisRange _defaultPitch = AxisRange(-90, 30);
void _onGimbalDrag(double frac) {
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
final double pitch = 30 - _gimbalFrac * 120; // +30 (top) … −90 (bottom)
final AxisRange r = _m.profile.gimbal?.pitch ?? _defaultPitch;
final double pitch = r.max - _gimbalFrac * (r.max - r.min); // max (top) … min (bottom)
_run('Gimbal', () => _dji.rotateGimbalPitch(pitch));
}
@@ -176,24 +182,27 @@ class _FlightControlPageState extends State<FlightControlPage> {
body: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) {
return Stack(
children: <Widget>[
Positioned.fill(
child: _m.connected
? const DjiVideoView()
: const CustomPaint(painter: _FeedPainter()),
),
if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))),
const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
Positioned(top: 12, left: 14, right: 14, child: _topBar()),
Positioned(left: 14, top: 58, child: _leftRail()),
Positioned(left: 70, top: 58, bottom: 96, child: _gimbalSlider()),
Positioned(right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls())),
Positioned(left: 14, bottom: 12, child: _minimap()),
Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())),
Positioned(right: 92, bottom: 20, child: _rthButton()),
Positioned(right: 92, bottom: 72, child: _takeoffButton()),
],
return ProductProfileScope(
profile: _m.profile,
child: Stack(
children: <Widget>[
Positioned.fill(
child: _m.connected
? const DjiVideoView()
: const CustomPaint(painter: _FeedPainter()),
),
if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))),
const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
Positioned(top: 12, left: 14, right: 14, child: _topBar()),
Positioned(left: 14, top: 58, child: _leftRail()),
Positioned(left: 70, top: 58, bottom: 96, child: Requires(HudFeatures.gimbalPitch, child: _gimbalSlider())),
Positioned(right: 16, top: 0, bottom: 0, child: Center(child: Requires(HudFeatures.cameraControls, child: _cameraControls()))),
Positioned(left: 14, bottom: 12, child: _minimap()),
Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())),
Positioned(right: 92, bottom: 20, child: _rthButton()),
Positioned(right: 92, bottom: 72, child: _takeoffButton()),
],
),
);
},
),
@@ -223,12 +232,14 @@ class _FlightControlPageState extends State<FlightControlPage> {
_mono(_m.satellites?.toString() ?? '0'),
])),
const SizedBox(width: 8),
_pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
const SizedBox(width: 8),
Requires(HudFeatures.visionIndicator, child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
)),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('radio', size: 14, color: Glass.ink),
const SizedBox(width: 4),
_mono('HD'),
Requires(HudFeatures.wifiSignal, whenDisconnected: false, fallback: _mono('HD'), child: _mono('Wi-Fi')),
])),
const Spacer(),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/product_profile.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
/// Debug screen for the capability layer (plan P0): the active profile, where
/// each resolved fact came from, and which HUD features it switches on or off.
class ProductProfilePage extends StatelessWidget {
const ProductProfilePage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
Future<void> _refresh(BuildContext context) async {
try {
final Map<String, dynamic> raw = await dji.getProductProfile();
if (!model.connected) return;
model.profile = ProductProfile.fromBridge(raw);
model.bump();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text('Profile: ${e is PlatformException ? (e.message ?? e.code) : e}')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: SafeArea(
child: AnimatedBuilder(
animation: model,
builder: (BuildContext context, _) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 10, 6),
child: Row(children: <Widget>[
const Expanded(
child: Text('Capability profile',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
),
IconButton(
tooltip: 'Re-read from the aircraft',
onPressed: model.connected ? () => _refresh(context) : null,
icon: const Icon(Icons.refresh, color: Glass.ink, size: 20),
),
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const PVIcon('close', size: 17, color: Glass.ink),
),
]),
),
Expanded(child: ProductProfileView(profile: model.profile)),
]),
),
),
);
}
}
/// The profile itself, separate from the page so widget tests can render it
/// against synthetic profiles.
class ProductProfileView extends StatelessWidget {
const ProductProfileView({super.key, required this.profile});
final ProductProfile profile;
static const TextStyle _label = TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Glass.ink);
static const TextStyle _value = TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Color(0xCCEAF0FA));
static const TextStyle _eyebrow =
TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA));
@override
Widget build(BuildContext context) {
final ProductProfile p = profile;
if (!p.connected) {
return const Center(child: Text('No product connected', style: _label));
}
final Map<String, List<HudFeature>> groups = <String, List<HudFeature>>{};
for (final HudFeature f in HudFeatures.all) {
groups.putIfAbsent(f.group, () => <HudFeature>[]).add(f);
}
return ListView(padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: <Widget>[
_section('Product'),
_row('Model', p.displayName ?? '—', note: p.model),
_row('Kind', p.kind),
_row('Matrix row', p.matrix == null ? 'none — SDK facts only' : '${p.matrix!.family} · ${p.matrix!.source.name}'),
if (p.matrix != null && !p.matrix!.go4Supported) _row('GO 4', 'not a GO 4 product'),
_section('Resolved facts'),
_fact('Link', p.link.name, 'link'),
_fact('Vision sensors', p.vision.name, 'vision'),
_fact('Batteries', '${p.batteryCount} · ${p.batteryKind.name}', 'batteryCount'),
_fact('Virtual sticks', p.supportsVirtualStick ? (p.virtualStickPrimary ? 'yes · primary' : 'yes') : 'no', 'virtualStick'),
_fact('Intelligent modes', p.modes.isEmpty ? 'none' : p.modes.map((IntelligentMode m) => m.name).join(', '), 'modes'),
for (final CameraProfile c in p.cameras) ...<Widget>[
_section('Camera ${c.index}${c.displayName == null ? '' : ' · ${c.displayName}'}'),
_row('Flags', _flags(<String, bool>{
'zoom': c.hasZoom,
'thermal': c.isThermal,
'ssd': c.ssdSupported,
'aperture': c.adjustableApertureSupported,
'focus': c.adjustableFocalPointSupported,
'metering': c.meteringSupported,
'playback': c.playbackSupported,
'mediaDownload': c.mediaDownloadSupported,
'timeLapse': c.timeLapseSupported,
'multiLens': c.multiLensSupported,
'mechShutter': c.mechanicalShutterSupported,
})),
_row('ISO', _range(c.isos)),
_row('Shutter', _range(c.shutterSpeeds)),
_row('Aperture', _range(c.apertures)),
_row('EV', _range(c.exposureCompensations)),
_row('Modes', c.modes.isEmpty ? '—' : c.modes.join(', ')),
_row('Video', c.videoResolutions.isEmpty ? '—' : '${c.videoResolutions.length} size/fps pairs'),
],
for (final GimbalProfile g in p.gimbals) ...<Widget>[
_section('Gimbal ${g.index}'),
_row('Pitch', g.pitchAdjustable ? (g.pitch?.toString() ?? 'yes') : 'fixed'),
_row('Yaw', g.yawAdjustable ? (g.yaw?.toString() ?? 'yes') : 'fixed'),
_row('Roll', g.rollAdjustable ? (g.roll?.toString() ?? 'yes') : 'fixed'),
_row('+30° extension', g.pitchRangeExtensionSupported ? 'yes' : 'no'),
],
if (p.flightController != null) ...<Widget>[
_section('Flight controller'),
_row('Compass / IMU', '${p.flightController!.compassCount ?? '—'} / ${p.flightController!.imuCount ?? '—'}'),
_row('Flags', _flags(<String, bool>{
'flightAssistant': p.flightController!.flightAssistantSupported == true,
'rtk': p.hasRtk,
'simulator': p.hasSimulator,
'propCalibration': p.flightController!.propellerCalibrationSupported == true,
'virtualFence': p.flightController!.virtualFenceSupported == true,
'onboardSdk': p.flightController!.onboardSdkAvailable == true,
})),
],
if (p.remoteController != null) ...<Widget>[
_section('Remote controller'),
_row('Flags', _flags(<String, bool>{
'masterSlave': p.hasMasterSlaveRc,
'focusController': p.remoteController!.focusControllerSupported == true,
'customButtons': p.remoteController!.customizableButtonSupported == true,
'multiDevicePairing': p.remoteController!.multiDevicePairingSupported == true,
})),
],
_section('HUD features'),
for (final MapEntry<String, List<HudFeature>> g in groups.entries) ...<Widget>[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(g.key, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
),
for (final HudFeature f in g.value) _featureRow(f, f.enabledFor(p)),
],
]);
}
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(top: 18, bottom: 6),
child: Text(title.toUpperCase(), style: _eyebrow),
);
Widget _row(String label, String value, {String? note}) => Container(
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
SizedBox(width: 130, child: Text(label, style: _label)),
Expanded(child: Text(note == null ? value : '$value ($note)', style: _value)),
]),
);
Widget _fact(String label, String value, String key) {
final FactSource src = profile.sources[key] ?? FactSource.unknown;
final Color c = switch (src) {
FactSource.sdk => Glass.sat,
FactSource.matrix => const Color(0xFF8FB4F6),
FactSource.unknown => const Color(0xFFD9852B),
};
return Container(
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
child: Row(children: <Widget>[
SizedBox(width: 130, child: Text(label, style: _label)),
Expanded(child: Text(value, style: _value)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(border: Border.all(color: c), borderRadius: BorderRadius.circular(5)),
child: Text(src.name, style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: c)),
),
]),
);
}
Widget _featureRow(HudFeature f, bool on) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: <Widget>[
Icon(on ? Icons.check_circle : Icons.remove_circle_outline,
size: 15, color: on ? Glass.sat : const Color(0x66EAF0FA)),
const SizedBox(width: 8),
Expanded(child: Text(f.label, style: on ? _label : _label.copyWith(color: const Color(0x80EAF0FA)))),
Text(f.id, style: _value.copyWith(fontSize: 10, color: const Color(0x66EAF0FA))),
]),
);
static String _flags(Map<String, bool> flags) {
final List<String> on = <String>[for (final MapEntry<String, bool> e in flags.entries) if (e.value) e.key];
return on.isEmpty ? 'none' : on.join(', ');
}
static String _range(List<String> values) {
if (values.isEmpty) return '—';
if (values.length <= 3) return values.join(', ');
return '${values.first} … ${values.last} (${values.length})';
}
}
+17 -7
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'fly/debug/product_profile_page.dart';
import 'pv_icons.dart';
/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column
@@ -45,6 +46,12 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
_m.noviceMode ??= false;
}
void _openProfile() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ProductProfilePage(model: _m, dji: _dji),
));
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
@@ -132,13 +139,16 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
1 => ('Flight Control', _controlRows()),
2 => ('Camera', _cameraRows()),
3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])),
_ => ('About', _infoRows(<(String, String)>[
('Model', _m.model ?? '—'),
('Flight Controller SN', _m.flightControllerSerial ?? '—'),
('Firmware', _m.firmwareVersion ?? '—'),
('Controller Firmware', _m.controllerFirmwareVersion ?? '—'),
('MSDK', _m.sdkVersion),
])),
_ => ('About', <Widget>[
..._infoRows(<(String, String)>[
('Model', _m.model ?? '—'),
('Flight Controller SN', _m.flightControllerSerial ?? '—'),
('Firmware', _m.firmwareVersion ?? '—'),
('Controller Firmware', _m.controllerFirmwareVersion ?? '—'),
('MSDK', _m.sdkVersion),
]),
_infoRow('Capability profile', _m.profile.connected ? 'View' : '—', onTap: _openProfile),
]),
};
return Padding(
padding: const EdgeInsets.fromLTRB(22, 20, 22, 20),
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/fly/hud_features.dart';
import 'package:pilotvault_fly/fly/product_matrix.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/ui/fly/debug/product_profile_page.dart';
import 'profile_fixtures.dart';
/// The feature ids a profile switches on.
Set<String> enabled(ProductProfile p) =>
<String>{for (final HudFeature f in HudFeatures.all) if (f.enabledFor(p)) f.id};
/// A stand-in HUD: one gated marker per feature, the way P2's widgets will be.
Widget hud(ProductProfile p) => MaterialApp(
home: ProductProfileScope(
profile: p,
child: Wrap(children: <Widget>[
for (final HudFeature f in HudFeatures.all) Requires(f, child: Text(f.id)),
]),
),
);
void main() {
group('ProductProfile.fromBridge', () {
test('disconnected map yields the disconnected profile', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{'connected': false});
expect(p.connected, isFalse);
expect(enabled(p), isEmpty);
});
test('Spark over Wi-Fi', () {
final ProductProfile p = ProductProfile.fromBridge(sparkWifi);
expect(p.link, LinkType.wifi);
expect(p.sources['link'], FactSource.sdk);
expect(p.vision, VisionSensors.front);
expect(p.sources['vision'], FactSource.matrix);
expect(p.batteryCount, 1);
expect(p.virtualStickPrimary, isTrue);
expect(p.supportsMode(IntelligentMode.waypoint), isFalse);
expect(p.supportsMode(IntelligentMode.quickShots), isTrue);
});
test('SDK link wins over the matrix', () {
// A Mavic Pro is OcuSync in the matrix but can be flown over Wi-Fi.
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'model': 'MAVIC_PRO',
'link': 'wifi',
});
expect(p.link, LinkType.wifi);
expect(p.sources['link'], FactSource.sdk);
});
test('matrix fills the link before the air link enumerates', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'link': null});
expect(p.link, LinkType.lightbridge);
expect(p.sources['link'], FactSource.matrix);
});
test('battery count falls back to the matrix bay count', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...inspire2X5s, 'batteries': <dynamic>[]});
expect(p.batteryCount, 2);
expect(p.sources['batteryCount'], FactSource.matrix);
});
test('no flight assistant caps the vision layout at VPS', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'flightController': <String, dynamic>{'flightAssistantSupported': false},
});
expect(p.vision, VisionSensors.downward);
expect(p.hasObstacleSensing, isFalse);
expect(p.hasVps, isTrue);
});
test('unlisted model keeps SDK facts and marks the rest unknown', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'model': 'UNKNOWN_AIRCRAFT',
'link': null,
});
expect(p.matrix, isNull);
expect(p.link, LinkType.unknown);
expect(p.vision, VisionSensors.unknown);
expect(p.hasCamera, isTrue);
expect(p.modes, isEmpty);
});
test('gimbal without capabilities yet still pitches', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'gimbals': <dynamic>[
<String, dynamic>{'index': 0, 'capabilitiesKnown': false},
],
});
expect(HudFeatures.gimbalPitch.enabledFor(p), isTrue);
expect(HudFeatures.gimbalYaw.enabledFor(p), isFalse);
});
});
group('ProductMatrix', () {
test('every row is keyed by a distinct MSDK model name', () {
final List<String> keys = ProductMatrix.entries.map((ProductMatrixEntry e) => e.model).toList();
expect(keys.toSet().length, keys.length);
});
test('covers the GO 4 families from the plan', () {
for (final String m in <String>[
'PHANTOM_3_PROFESSIONAL', 'PHANTOM_4', 'PHANTOM_4_PRO', 'PHANTOM_4_RTK', //
'MAVIC_PRO', 'MAVIC_AIR', 'MAVIC_2_PRO', 'MAVIC_2_ZOOM', 'MAVIC_2_ENTERPRISE',
'Spark', 'MAVIC_MINI', 'INSPIRE_1', 'INSPIRE_2', 'MATRICE_100', 'MATRICE_200', 'MATRICE_600',
]) {
expect(ProductMatrix.lookup(m), isNotNull, reason: m);
}
});
});
group('HUD features per profile', () {
test('Spark-like Wi-Fi', () {
final Set<String> on = enabled(ProductProfile.fromBridge(sparkWifi));
expect(on, containsAll(<String>['status.wifiSignal', 'battery.single', 'overlay.radar', 'control.virtualSticks']));
expect(on, isNot(contains('status.rcHdSignal')));
expect(on, isNot(contains('camera.aperture')));
expect(on, isNot(contains('battery.dual')));
expect(on, isNot(contains('mode.waypoint')));
});
test('Phantom 4', () {
final Set<String> on = enabled(ProductProfile.fromBridge(phantom4));
expect(on, containsAll(<String>['status.rcHdSignal', 'battery.single', 'gimbal.pitchExtension', 'mode.waypoint', 'mode.tapFly']));
expect(on, isNot(contains('status.wifiSignal')));
expect(on, isNot(contains('camera.ssd')));
expect(on, isNot(contains('gimbal.yaw')));
});
test('Inspire 2 + X5S', () {
final Set<String> on = enabled(ProductProfile.fromBridge(inspire2X5s));
expect(on, containsAll(<String>[
'battery.dual', 'camera.ssd', 'camera.aperture', 'gimbal.yaw', 'status.masterSlave', 'status.rcHdSignal',
]));
expect(on, isNot(contains('battery.single')));
expect(on, isNot(contains('battery.multi')));
expect(on, isNot(contains('camera.zoom')));
});
});
group('Requires', () {
testWidgets('renders only enabled features for each synthetic profile', (WidgetTester tester) async {
for (final Map<String, dynamic> raw in <Map<String, dynamic>>[sparkWifi, phantom4, inspire2X5s]) {
final ProductProfile p = ProductProfile.fromBridge(raw);
await tester.pumpWidget(hud(p));
for (final HudFeature f in HudFeatures.all) {
expect(find.text(f.id), f.enabledFor(p) ? findsOneWidget : findsNothing, reason: '${p.model}: ${f.id}');
}
}
});
testWidgets('gate is open with no product, unless told otherwise', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Column(children: <Widget>[
Requires(HudFeatures.dualBattery, child: const Text('preview')),
Requires(HudFeatures.wifiSignal, whenDisconnected: false, fallback: const Text('HD'), child: const Text('Wi-Fi')),
]),
));
expect(find.text('preview'), findsOneWidget);
expect(find.text('HD'), findsOneWidget);
expect(find.text('Wi-Fi'), findsNothing);
});
});
group('ProductProfileView', () {
testWidgets('shows the profile, fact sources and feature states', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 4000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
for (final Map<String, dynamic> raw in <Map<String, dynamic>>[sparkWifi, phantom4, inspire2X5s]) {
final ProductProfile p = ProductProfile.fromBridge(raw);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: ProductProfileView(profile: p))));
expect(find.textContaining(p.displayName!), findsWidgets);
expect(find.text('HUD FEATURES'), findsOneWidget);
expect(find.text('sdk'), findsWidgets);
expect(tester.takeException(), isNull);
}
});
});
}
+108
View File
@@ -0,0 +1,108 @@
/// Synthetic `productProfile` events, shaped exactly as `ProductProfileBridge.kt`
/// emits them, for the three reference products of plan P0.
library;
Map<String, dynamic> _camera({
String name = 'FC1102',
bool aperture = false,
bool ssd = false,
bool zoom = false,
List<String> apertures = const <String>[],
}) =>
<String, dynamic>{
'index': 0,
'displayName': name,
'isThermal': false,
'ssdSupported': ssd,
'opticalZoomSupported': zoom,
'hybridZoomSupported': false,
'digitalZoomSupported': false,
'adjustableApertureSupported': aperture,
'meteringSupported': true,
'playbackSupported': true,
'mediaDownloadSupported': true,
'modes': <String>['SHOOT_PHOTO', 'RECORD_VIDEO'],
'exposureModes': <String>['PROGRAM', 'MANUAL'],
'isos': <String>['AUTO', '100', '200', '400', '800', '1600'],
'shutterSpeeds': <String>['1/8000', '1/1000', '1/100', '1/30'],
'apertures': apertures,
'exposureCompensations': <String>['-3.0', '0.0', '+3.0'],
'videoResolutions': <String>['RESOLUTION_3840x2160@FRAME_RATE_30_FPS'],
};
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String, dynamic>{
'index': 0,
'capabilitiesKnown': true,
'pitch': <String, dynamic>{'min': -90.0, 'max': pitchMax},
'yaw': yaw ? <String, dynamic>{'min': -320.0, 'max': 320.0} : null,
'roll': null,
'pitchAdjustable': true,
'yawAdjustable': yaw,
'rollAdjustable': false,
'pitchRangeExtensionSupported': pitchMax > 0,
};
Map<String, dynamic> _fc({bool assistant = true, bool sticks = true}) => <String, dynamic>{
'flightAssistantSupported': assistant,
'virtualStickAvailable': sticks,
'rtkSupported': false,
'compassCount': 1,
'imuCount': 1,
'simulatorAvailable': true,
};
/// Spark over Wi-Fi with no RC: no remote controller component, link reported
/// as Wi-Fi, one smart battery.
final Map<String, dynamic> sparkWifi = <String, dynamic>{
'type': 'productProfile',
'connected': true,
'model': 'Spark',
'modelDisplayName': 'Spark',
'productKind': 'aircraft',
'cameras': <dynamic>[_camera(name: 'FC1102')],
'gimbals': <dynamic>[_gimbal()],
'link': 'wifi',
'flightController': _fc(),
'remoteController': null,
'batteries': <dynamic>[
<String, dynamic>{'index': 0, 'isSmart': true, 'cells': 3},
],
};
/// Phantom 4 on Lightbridge, gimbal pitch extendable to +30°.
final Map<String, dynamic> phantom4 = <String, dynamic>{
'type': 'productProfile',
'connected': true,
'model': 'PHANTOM_4',
'modelDisplayName': 'Phantom 4',
'productKind': 'aircraft',
'cameras': <dynamic>[_camera(name: 'FC330')],
'gimbals': <dynamic>[_gimbal(pitchMax: 30)],
'link': 'lightbridge',
'flightController': _fc(),
'remoteController': <String, dynamic>{'masterSlaveSupported': false},
'batteries': <dynamic>[
<String, dynamic>{'index': 0, 'isSmart': true, 'cells': 4},
],
};
/// Inspire 2 with a Zenmuse X5S: dual batteries, SSD, adjustable aperture,
/// 3-axis gimbal with yaw, master/slave RC.
final Map<String, dynamic> inspire2X5s = <String, dynamic>{
'type': 'productProfile',
'connected': true,
'model': 'INSPIRE_2',
'modelDisplayName': 'Inspire 2',
'productKind': 'aircraft',
'cameras': <dynamic>[
_camera(name: 'Zenmuse X5S', aperture: true, ssd: true, apertures: <String>['f/1.7', 'f/2.8', 'f/5.6', 'f/16']),
],
'gimbals': <dynamic>[_gimbal(yaw: true, pitchMax: 30)],
'link': 'lightbridge',
'flightController': _fc(),
'remoteController': <String, dynamic>{'masterSlaveSupported': true},
'batteries': <dynamic>[
<String, dynamic>{'index': 0, 'isSmart': true, 'cells': 6},
<String, dynamic>{'index': 1, 'isSmart': true, 'cells': 6},
],
};