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:
co-authored by
Claude Opus 4.8
parent
42f8054b45
commit
ec4b1bcdd3
+115
-2
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -65,6 +65,7 @@ class FlightModel extends ChangeNotifier {
|
||||
bool connected = false;
|
||||
String? model;
|
||||
String? firmwareVersion;
|
||||
String? serialNumber;
|
||||
|
||||
// ── Flight controller telemetry ────────────────────────────────────────────
|
||||
int? satellites;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user