Show drone serial number and firmware in the Fly App

Firmware was already half-wired: the bridge read
BaseProduct.getFirmwarePackageVersion() once, at connect. The SDK
returns null there until it has finished handshaking with the
aircraft, so the About panel almost always showed "-" instead. The
serial was never fetched at all.

Resolve both asynchronously after connect. fetchIdentity() reads the
serial from FlightController.getSerialNumber() and the firmware from
the product package version, falling back to the component-level
getFirmwareVersion() for aircraft that only report the latter. It
re-checks every 2s (max 6 attempts) and emits each value on a new
`identity` event as it lands, so the serial still appears when
firmware never resolves. Values are cached to answer getProductInfo
without re-fetching, and cleared on disconnect.

The SDK delivers lifecycle callbacks on arbitrary threads, so all
identity mutation hops onto the main thread, guarded by a generation
counter that strands retries queued for a product that has since
changed or dropped - otherwise a reconnect could race a stale chain
and report the previous aircraft.

Verified via flutter analyze and an APK build (which type-checks the
new MSDK calls). Runtime timing and the reported values still need a
physical aircraft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-16 13:49:57 +02:00
co-authored by Claude Opus 4.8
parent 42f8054b45
commit ec4b1bcdd3
5 changed files with 136 additions and 6 deletions
@@ -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<String, Any?> {
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<String> {
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<String> {
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,
)
)
}
+3 -3
View File
@@ -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');
+1
View File
@@ -65,6 +65,7 @@ class FlightModel extends ChangeNotifier {
bool connected = false;
String? model;
String? firmwareVersion;
String? serialNumber;
// ── Flight controller telemetry ────────────────────────────────────────────
int? satellites;
+11
View File
@@ -153,9 +153,19 @@ class _HomePageState extends State<HomePage> {
_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<HomePage> {
_model.isRecording = false;
_model.recordSeconds = 0;
_model.firmwareVersion = null;
_model.serialNumber = null;
}
Future<void> _register() async {
+6 -1
View File
@@ -132,7 +132,12 @@ 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 ?? ''), ('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),