diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt index 91bbf40..d21d6a8 100644 --- a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt @@ -37,6 +37,10 @@ class DjiSdkBridge( companion object { private const val METHOD_CHANNEL = "dji_msdk/methods" private const val EVENT_CHANNEL = "dji_msdk/events" + + /** Serial/firmware polling: the SDK reports null until it has talked to the aircraft. */ + private const val IDENTITY_RETRY_MS = 2_000L + private const val IDENTITY_MAX_ATTEMPTS = 6 } private val mainHandler = Handler(Looper.getMainLooper()) @@ -45,6 +49,18 @@ class DjiSdkBridge( private var eventSink: EventChannel.EventSink? = null + /** + * Aircraft identity, resolved asynchronously after connect (see [fetchIdentity]) + * and cached so [connectionMap] can answer `getProductInfo` without re-fetching. + * Volatile: written from the SDK's callback threads, read from the main thread. + */ + @Volatile private var serialNumber: String? = null + + @Volatile private var firmwareVersion: String? = null + + /** Invalidates in-flight identity retries when the product changes or drops. */ + @Volatile private var identityGeneration = 0 + /** Shared context handed to every subsystem helper. */ private val ctx = BridgeCtx( appContext = appContext, @@ -142,14 +158,17 @@ class DjiSdkBridge( override fun onProductConnect(product: BaseProduct?) { emit(connectionMap(product)) bindComponentCallbacks(product) + startIdentityFetch() } override fun onProductChanged(product: BaseProduct?) { emit(connectionMap(product)) bindComponentCallbacks(product) + startIdentityFetch() } override fun onProductDisconnect() { + clearIdentity() emit(mapOf("type" to "connection", "connected" to false, "model" to null)) } @@ -175,12 +194,106 @@ class DjiSdkBridge( private fun connectionMap(product: BaseProduct?): Map { val connected = product != null && product.isConnected val model = product?.model?.displayName - val firmware = product?.firmwarePackageVersion return mapOf( "type" to "connection", "connected" to connected, "model" to model, - "firmware" to firmware, + "firmware" to (product?.firmwarePackageVersion ?: firmwareVersion), + "serial" to serialNumber, + ) + } + + /** + * Cancels any pending retry chain and starts a fresh one for the current product. + * Hops to the main thread so that every write to [identityGeneration] happens + * there — the SDK delivers its lifecycle callbacks on arbitrary threads. + */ + private fun startIdentityFetch() { + mainHandler.post { + identityGeneration++ + fetchIdentity(identityGeneration, attempt = 0) + } + } + + /** Drops the cached identity and strands any retry queued for the old product. */ + private fun clearIdentity() { + mainHandler.post { + identityGeneration++ + serialNumber = null + firmwareVersion = null + } + } + + /** + * Resolves the aircraft's serial number and firmware version. + * + * Neither is readable the instant a product connects — `getFirmwarePackageVersion` + * returns null and the flight controller's callbacks fail until the SDK has + * finished handshaking — so this re-checks every [IDENTITY_RETRY_MS] until both + * are known or [IDENTITY_MAX_ATTEMPTS] is reached. Each resolved value is emitted + * as it arrives, so the UI fills in the serial even if firmware never resolves. + * + * [generation] guards against overlapping chains: a product change or disconnect + * bumps [identityGeneration], stranding any retry still queued for the old product. + * + * Runs on the main thread only; SDK callbacks hop back onto it before mutating. + */ + private fun fetchIdentity(generation: Int, attempt: Int) { + if (generation != identityGeneration) return + + val product = DJISDKManager.getInstance().product + if (product == null || !product.isConnected) return + + product.firmwarePackageVersion?.let { setFirmware(generation, it) } + + val controller = (product as? Aircraft)?.flightController + if (controller != null) { + if (serialNumber == null) { + controller.getSerialNumber(object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(value: String?) { + if (value.isNullOrBlank()) return + mainHandler.post { + if (generation != identityGeneration || serialNumber == value) return@post + serialNumber = value + emitIdentity() + } + } + + override fun onFailure(error: DJIError?) = Unit + }) + } + // Component-level firmware is the fallback when the product-level package + // version stays null (some aircraft only report the former). + if (firmwareVersion == null) { + controller.getFirmwareVersion(object : CommonCallbacks.CompletionCallbackWith { + override fun onSuccess(value: String?) { + if (value.isNullOrBlank()) return + mainHandler.post { setFirmware(generation, value) } + } + + override fun onFailure(error: DJIError?) = Unit + }) + } + } + + if ((serialNumber == null || firmwareVersion == null) && attempt + 1 < IDENTITY_MAX_ATTEMPTS) { + mainHandler.postDelayed({ fetchIdentity(generation, attempt + 1) }, IDENTITY_RETRY_MS) + } + } + + private fun setFirmware(generation: Int, value: String) { + if (generation != identityGeneration || firmwareVersion == value) return + firmwareVersion = value + emitIdentity() + } + + private fun emitIdentity() { + emit( + mapOf( + "type" to "identity", + "serial" to serialNumber, + "firmware" to firmwareVersion, + ) ) } diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index fa49fa5..856f87e 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -9,9 +9,9 @@ import 'package:flutter/services.dart'; /// /// Every method returns cleanly whether or not a product is connected; the /// native side answers with a typed error the UI can surface. The event stream -/// carries maps keyed by `type`: `registration`, `connection`, `telemetry`, -/// `battery`, `camera`, `exposure`, `gimbal`, `mission`, `mediaList`, -/// `mediaDownload`, `djiAccount`, `database`, `init`. +/// carries maps keyed by `type`: `registration`, `connection`, `identity`, +/// `telemetry`, `battery`, `camera`, `exposure`, `gimbal`, `mission`, +/// `mediaList`, `mediaDownload`, `djiAccount`, `database`, `init`. class DjiService { static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); static const EventChannel _events = EventChannel('dji_msdk/events'); diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 78b34b2..7509713 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -65,6 +65,7 @@ class FlightModel extends ChangeNotifier { bool connected = false; String? model; String? firmwareVersion; + String? serialNumber; // ── Flight controller telemetry ──────────────────────────────────────────── int? satellites; diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 8b2790d..df20eda 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -153,9 +153,19 @@ class _HomePageState extends State { _model.connected = event['connected'] as bool? ?? false; _model.model = event['model'] as String?; _model.firmwareVersion = event['firmware'] as String?; + _model.serialNumber = event['serial'] as String?; if (!_model.connected) _clearTelemetry(); _model.bump(); break; + case 'identity': + // Serial and firmware resolve asynchronously after connect, each on its + // own schedule, so a null here means "not resolved yet" — never a reason + // to drop a value the previous identity event already delivered. + if (!_model.connected) break; + _model.serialNumber = event['serial'] as String? ?? _model.serialNumber; + _model.firmwareVersion = event['firmware'] as String? ?? _model.firmwareVersion; + _model.bump(); + break; case 'telemetry': // Ignore stray telemetry that arrives after a disconnect — otherwise it // repopulates values _clearTelemetry() just wiped, leaving stale readings. @@ -258,6 +268,7 @@ class _HomePageState extends State { _model.isRecording = false; _model.recordSeconds = 0; _model.firmwareVersion = null; + _model.serialNumber = null; } Future _register() async { diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart index b347c30..a520981 100644 --- a/Fly App/lib/ui/settings_menu_page.dart +++ b/Fly App/lib/ui/settings_menu_page.dart @@ -132,7 +132,12 @@ 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 ?? '—'), ('Firmware', _m.firmwareVersion ?? '—'), ('MSDK', _m.sdkVersion)])), + _ => ('About', _infoRows(<(String, String)>[ + ('Model', _m.model ?? '—'), + ('Serial Number', _m.serialNumber ?? '—'), + ('Firmware', _m.firmwareVersion ?? '—'), + ('MSDK', _m.sdkVersion), + ])), }; return Padding( padding: const EdgeInsets.fromLTRB(22, 20, 22, 20),