diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt index 0c39309..d9fe2bb 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt @@ -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 > enumOrNull(name: String?): T? { diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt index 1ba429f..f01fe1e 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt @@ -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) diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt new file mode 100644 index 0000000..7b34a3b --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt @@ -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? = 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 { + 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 { + 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?>?, label: (String?) -> String? = { it }): List = + values.orEmpty() + .mapNotNull { it?.name } + .filter { it != "UNKNOWN" } + .mapNotNull(label) + .distinct() + + // ── Gimbal ────────────────────────────────────────────────────────────────── + + private fun gimbals(product: BaseProduct): List { + 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 { + val caps = probe { g.capabilities }.orEmpty() + fun range(key: CapabilityKey): Map? { + 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 probe(block: () -> T): T? = try { + block() + } catch (_: Throwable) { + null + } +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 856f87e..dc3732d 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -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.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> getProductProfile() async { + final dynamic profile = await _methods.invokeMethod('getProductProfile'); + return Map.from(profile as Map); + } + // ── Flight controller commands ────────────────────────────────────────────── Future takeOff() => _methods.invokeMethod('takeOff'); Future land() => _methods.invokeMethod('land'); diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 75cdf4f..4d91cde 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -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 diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart new file mode 100644 index 0000000..fb255e2 --- /dev/null +++ b/Fly App/lib/fly/hud_features.dart @@ -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 modes = [ + for (final IntelligentMode m in IntelligentMode.values) + HudFeature('mode.${m.name}', _modeLabel(m), 'Intelligent modes', (ProductProfile p) => p.supportsMode(m)), + ]; + + static final List all = [ + 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()?.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()); + } +} diff --git a/Fly App/lib/fly/product_matrix.dart b/Fly App/lib/fly/product_matrix.dart new file mode 100644 index 0000000..b7f4308 --- /dev/null +++ b/Fly App/lib/fly/product_matrix.dart @@ -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 {}, + 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 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 _gpsClassic = { + IntelligentMode.waypoint, + IntelligentMode.pointOfInterest, + IntelligentMode.followMe, + IntelligentMode.courseLock, + IntelligentMode.homeLock, +}; + +const Set _visionClassic = { + ..._gpsClassic, + IntelligentMode.activeTrack, + IntelligentMode.tapFly, +}; + +const Set _visionTripod = { + ..._visionClassic, + IntelligentMode.tripod, +}; + +const Set _mavic2 = { + ..._visionTripod, + IntelligentMode.quickShots, + IntelligentMode.hyperlapse, +}; + +const Set _enterprise = { + IntelligentMode.waypoint, + IntelligentMode.pointOfInterest, + IntelligentMode.activeTrack, + IntelligentMode.tapFly, +}; + +/// Spark and Mavic Air: MSDK V4 offers no waypoint or POI missions on them. +const Set _wifiConsumer = { + IntelligentMode.activeTrack, + IntelligentMode.tapFly, + IntelligentMode.quickShots, +}; + +const Set _matrice = { + IntelligentMode.waypoint, + IntelligentMode.pointOfInterest, + IntelligentMode.followMe, +}; + +abstract final class ProductMatrix { + static const List entries = [ + // ── 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.waypoint}), + ProductMatrixEntry(model: 'P_4_MULTISPECTRAL', family: 'Phantom 4 RTK', link: LinkType.ocusync, vision: VisionSensors.frontBack, modes: {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.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 _byModel = { + 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]; +} diff --git a/Fly App/lib/fly/product_profile.dart b/Fly App/lib/fly/product_profile.dart new file mode 100644 index 0000000..56c3c51 --- /dev/null +++ b/Fly App/lib/fly/product_profile.dart @@ -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 [], + this.exposureModes = const [], + this.isos = const [], + this.shutterSpeeds = const [], + this.apertures = const [], + this.exposureCompensations = const [], + this.videoResolutions = const [], + }); + + factory CameraProfile.fromMap(Map m) { + bool b(String k) => m[k] as bool? ?? false; + List l(String k) => ((m[k] as List?) ?? const []).cast(); + 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 modes; + final List exposureModes; + final List isos; + final List shutterSpeeds; + final List apertures; + final List exposureCompensations; + final List 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 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 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 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 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 [], + this.gimbals = const [], + this.batteries = const [], + this.link = LinkType.unknown, + this.vision = VisionSensors.unknown, + this.batteryCount = 0, + this.batteryKind = BatteryKind.unknown, + this.modes = const {}, + this.supportsVirtualStick = false, + this.virtualStickPrimary = false, + this.flightController, + this.remoteController, + this.matrix, + this.sources = const {}, + }); + + /// No product. Every predicate reads false against it. + static const ProductProfile disconnected = ProductProfile(); + + factory ProductProfile.fromBridge(Map m) { + if (m['connected'] != true) return disconnected; + + final String? model = m['model'] as String?; + final ProductMatrixEntry? row = ProductMatrix.lookup(model); + final Map sources = {}; + + List> maps(String k) => ((m[k] as List?) ?? const []) + .map((dynamic e) => Map.from(e as Map)) + .toList(); + Map? map(String k) => m[k] == null ? null : Map.from(m[k] as Map); + + final List cameras = maps('cameras').map(CameraProfile.fromMap).toList(); + final List gimbals = maps('gimbals').map(GimbalProfile.fromMap).toList(); + final List batteries = maps('batteries').map(BatteryProfile.fromMap).toList(); + final Map? fcMap = map('flightController'); + final Map? 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 {}, + 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 cameras; + final List gimbals; + final List batteries; + final LinkType link; + final VisionSensors vision; + final int batteryCount; + final BatteryKind batteryKind; + final Set 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 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); +} diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 054ca67..d6253b1 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -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 { _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 { _model.firmwareVersion = null; _model.controllerFirmwareVersion = null; _model.flightControllerSerial = null; + _model.profile = ProductProfile.disconnected; } Future _register() async { diff --git a/Fly App/lib/ui/flight_control_page.dart b/Fly App/lib/ui/flight_control_page.dart index 75155cf..b58c782 100644 --- a/Fly App/lib/ui/flight_control_page.dart +++ b/Fly App/lib/ui/flight_control_page.dart @@ -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 { 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 { body: AnimatedBuilder( animation: _m, builder: (BuildContext context, _) { - return Stack( - children: [ - 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: [ + 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 { _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: [ 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: [ diff --git a/Fly App/lib/ui/fly/debug/product_profile_page.dart b/Fly App/lib/ui/fly/debug/product_profile_page.dart new file mode 100644 index 0000000..1bdd9ad --- /dev/null +++ b/Fly App/lib/ui/fly/debug/product_profile_page.dart @@ -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 _refresh(BuildContext context) async { + try { + final Map 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: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 10, 6), + child: Row(children: [ + 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> groups = >{}; + for (final HudFeature f in HudFeatures.all) { + groups.putIfAbsent(f.group, () => []).add(f); + } + + return ListView(padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: [ + _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) ...[ + _section('Camera ${c.index}${c.displayName == null ? '' : ' · ${c.displayName}'}'), + _row('Flags', _flags({ + '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) ...[ + _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) ...[ + _section('Flight controller'), + _row('Compass / IMU', '${p.flightController!.compassCount ?? '—'} / ${p.flightController!.imuCount ?? '—'}'), + _row('Flags', _flags({ + '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) ...[ + _section('Remote controller'), + _row('Flags', _flags({ + 'masterSlave': p.hasMasterSlaveRc, + 'focusController': p.remoteController!.focusControllerSupported == true, + 'customButtons': p.remoteController!.customizableButtonSupported == true, + 'multiDevicePairing': p.remoteController!.multiDevicePairingSupported == true, + })), + ], + _section('HUD features'), + for (final MapEntry> g in groups.entries) ...[ + 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: [ + 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: [ + 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: [ + 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 flags) { + final List on = [for (final MapEntry e in flags.entries) if (e.value) e.key]; + return on.isEmpty ? 'none' : on.join(', '); + } + + static String _range(List values) { + if (values.isEmpty) return '—'; + if (values.length <= 3) return values.join(', '); + return '${values.first} … ${values.last} (${values.length})'; + } +} diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart index 35276d3..6f69fbf 100644 --- a/Fly App/lib/ui/settings_menu_page.dart +++ b/Fly App/lib/ui/settings_menu_page.dart @@ -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 { _m.noviceMode ??= false; } + void _openProfile() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ProductProfilePage(model: _m, dji: _dji), + )); + } + void _snack(String msg) { ScaffoldMessenger.of(context) ..clearSnackBars() @@ -132,13 +139,16 @@ class _SettingsMenuPageState extends State { 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', [ + ..._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), diff --git a/Fly App/test/fly/product_profile_test.dart b/Fly App/test/fly/product_profile_test.dart new file mode 100644 index 0000000..e3e8812 --- /dev/null +++ b/Fly App/test/fly/product_profile_test.dart @@ -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 enabled(ProductProfile p) => + {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: [ + 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({'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({ + ...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({...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({...inspire2X5s, 'batteries': []}); + 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({ + ...phantom4, + 'flightController': {'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({ + ...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({ + ...phantom4, + 'gimbals': [ + {'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 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 [ + '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 on = enabled(ProductProfile.fromBridge(sparkWifi)); + expect(on, containsAll(['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 on = enabled(ProductProfile.fromBridge(phantom4)); + expect(on, containsAll(['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 on = enabled(ProductProfile.fromBridge(inspire2X5s)); + expect(on, containsAll([ + '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 raw in >[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: [ + 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 raw in >[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); + } + }); + }); +} diff --git a/Fly App/test/fly/profile_fixtures.dart b/Fly App/test/fly/profile_fixtures.dart new file mode 100644 index 0000000..b1bc612 --- /dev/null +++ b/Fly App/test/fly/profile_fixtures.dart @@ -0,0 +1,108 @@ +/// Synthetic `productProfile` events, shaped exactly as `ProductProfileBridge.kt` +/// emits them, for the three reference products of plan P0. +library; + +Map _camera({ + String name = 'FC1102', + bool aperture = false, + bool ssd = false, + bool zoom = false, + List apertures = const [], +}) => + { + 'index': 0, + 'displayName': name, + 'isThermal': false, + 'ssdSupported': ssd, + 'opticalZoomSupported': zoom, + 'hybridZoomSupported': false, + 'digitalZoomSupported': false, + 'adjustableApertureSupported': aperture, + 'meteringSupported': true, + 'playbackSupported': true, + 'mediaDownloadSupported': true, + 'modes': ['SHOOT_PHOTO', 'RECORD_VIDEO'], + 'exposureModes': ['PROGRAM', 'MANUAL'], + 'isos': ['AUTO', '100', '200', '400', '800', '1600'], + 'shutterSpeeds': ['1/8000', '1/1000', '1/100', '1/30'], + 'apertures': apertures, + 'exposureCompensations': ['-3.0', '0.0', '+3.0'], + 'videoResolutions': ['RESOLUTION_3840x2160@FRAME_RATE_30_FPS'], + }; + +Map _gimbal({bool yaw = false, double pitchMax = 0}) => { + 'index': 0, + 'capabilitiesKnown': true, + 'pitch': {'min': -90.0, 'max': pitchMax}, + 'yaw': yaw ? {'min': -320.0, 'max': 320.0} : null, + 'roll': null, + 'pitchAdjustable': true, + 'yawAdjustable': yaw, + 'rollAdjustable': false, + 'pitchRangeExtensionSupported': pitchMax > 0, + }; + +Map _fc({bool assistant = true, bool sticks = true}) => { + '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 sparkWifi = { + 'type': 'productProfile', + 'connected': true, + 'model': 'Spark', + 'modelDisplayName': 'Spark', + 'productKind': 'aircraft', + 'cameras': [_camera(name: 'FC1102')], + 'gimbals': [_gimbal()], + 'link': 'wifi', + 'flightController': _fc(), + 'remoteController': null, + 'batteries': [ + {'index': 0, 'isSmart': true, 'cells': 3}, + ], +}; + +/// Phantom 4 on Lightbridge, gimbal pitch extendable to +30°. +final Map phantom4 = { + 'type': 'productProfile', + 'connected': true, + 'model': 'PHANTOM_4', + 'modelDisplayName': 'Phantom 4', + 'productKind': 'aircraft', + 'cameras': [_camera(name: 'FC330')], + 'gimbals': [_gimbal(pitchMax: 30)], + 'link': 'lightbridge', + 'flightController': _fc(), + 'remoteController': {'masterSlaveSupported': false}, + 'batteries': [ + {'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 inspire2X5s = { + 'type': 'productProfile', + 'connected': true, + 'model': 'INSPIRE_2', + 'modelDisplayName': 'Inspire 2', + 'productKind': 'aircraft', + 'cameras': [ + _camera(name: 'Zenmuse X5S', aperture: true, ssd: true, apertures: ['f/1.7', 'f/2.8', 'f/5.6', 'f/16']), + ], + 'gimbals': [_gimbal(yaw: true, pitchMax: 30)], + 'link': 'lightbridge', + 'flightController': _fc(), + 'remoteController': {'masterSlaveSupported': true}, + 'batteries': [ + {'index': 0, 'isSmart': true, 'cells': 6}, + {'index': 1, 'isSmart': true, 'cells': 6}, + ], +};