Stop reporting the flight controller's serial as the drone's
getSerialNumber() is a BaseComponent method, so every component answers for
itself — and the bridge reads it off the flight controller. A Mavic Pro reports
08RDE1J00103H1 (what DJI Go labels "Flight Controller SN") where the airframe
sticker, and the registration, say 08QDE3H012032E. We were publishing the former
as the drone's serial, onto records that exist to satisfy BEK 1649 §5.
Same trap as 002e484, where a component's own firmware stood in for the
aircraft's, but with no correct source to switch to: MSDK v4 exposes no
aircraft-level serial at all — BaseProduct offers only the model and the
firmware package version — so the registered serial can only be typed by hand.
So split the two rather than pick one:
serial the airframe's, hand-entered, and the only one that
reaches the logbook and the CSV export
flight_controller_serial what the aircraft reports; auto-filled on connect,
and what POST /api/drones/auto now upserts on
Keying auto-add on the flight controller's serial keeps the fleet recognising a
connected drone without typing — it is stable per airframe — while leaving the
compliance record's serial to the pilot. A flight controller swapped in a repair
now costs a duplicate fleet entry to merge, where before it would have quietly
rewritten what the logbook claimed the drone was.
Note droneInput.payload() is a whole-record write, so any UI editing a drone must
round-trip flightControllerSerial; blanking it forks the drone into a duplicate
on its next connect. Drones.vue carries it through the edit form for that reason.
The migration copies existing serials into flight_controller_serial rather than
moving them: every current value came from auto-add and is therefore a flight
controller's, but a pilot may since have corrected one by hand and this cannot
tell them apart. Copying keeps auto-add matching the airframes it matched before.
Applied to the remote PocketBase, where drones held no records, so the backfill
was a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
33595c99e8
commit
183c83c177
@@ -37,7 +37,13 @@ type droneRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // pilot's custom label; blank on auto-added drones
|
||||
Model string `json:"model"`
|
||||
// Serial is the number on the airframe — what the drone is registered under.
|
||||
// The SDK cannot read it (see FlightControllerSerial), so it is hand-entered.
|
||||
Serial string `json:"serial"`
|
||||
// FlightControllerSerial is the only serial a connected aircraft reports, and
|
||||
// so is what the auto-add path keys on. Stable per airframe, but not the
|
||||
// registered serial and never shown as one.
|
||||
FlightControllerSerial string `json:"flight_controller_serial"`
|
||||
Firmware string `json:"firmware"`
|
||||
ControllerFirmware string `json:"controller_firmware"`
|
||||
Registration string `json:"registration"`
|
||||
@@ -57,6 +63,7 @@ type droneView struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
FlightControllerSerial string `json:"flightControllerSerial"`
|
||||
Firmware string `json:"firmware"`
|
||||
ControllerFirmware string `json:"controllerFirmware"`
|
||||
Registration string `json:"registration"`
|
||||
@@ -73,17 +80,24 @@ type droneView struct {
|
||||
// export. The custom name wins; a drone auto-added on connection has none, so
|
||||
// fall back to what the aircraft reported about itself.
|
||||
func (d droneRecord) displayName() string {
|
||||
// The airframe serial identifies the drone to a human, so it is preferred —
|
||||
// but only the pilot can supply it, and an auto-added entry has nothing but
|
||||
// the flight controller's, which is better than no distinguisher at all.
|
||||
serial := strings.TrimSpace(d.Serial)
|
||||
if serial == "" {
|
||||
serial = strings.TrimSpace(d.FlightControllerSerial)
|
||||
}
|
||||
if n := strings.TrimSpace(d.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if m := strings.TrimSpace(d.Model); m != "" {
|
||||
if s := strings.TrimSpace(d.Serial); s != "" {
|
||||
return m + " · " + s
|
||||
if serial != "" {
|
||||
return m + " · " + serial
|
||||
}
|
||||
return m
|
||||
}
|
||||
if s := strings.TrimSpace(d.Serial); s != "" {
|
||||
return s
|
||||
if serial != "" {
|
||||
return serial
|
||||
}
|
||||
return "Unnamed drone"
|
||||
}
|
||||
@@ -91,6 +105,7 @@ func (d droneRecord) displayName() string {
|
||||
func (d droneRecord) view() droneView {
|
||||
return droneView{
|
||||
ID: d.ID, Name: d.Name, DisplayName: d.displayName(), Model: d.Model, Serial: d.Serial,
|
||||
FlightControllerSerial: d.FlightControllerSerial,
|
||||
Firmware: d.Firmware, ControllerFirmware: d.ControllerFirmware,
|
||||
Registration: d.Registration, OperatorNumber: d.OperatorNumber,
|
||||
MtomGrams: d.MtomGrams, IsToy: d.IsToy,
|
||||
@@ -422,6 +437,7 @@ type droneInput struct {
|
||||
Name string `json:"name"` // custom label; optional
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
FlightControllerSerial string `json:"flightControllerSerial"`
|
||||
Firmware string `json:"firmware"`
|
||||
ControllerFirmware string `json:"controllerFirmware"`
|
||||
Registration string `json:"registration"`
|
||||
@@ -442,6 +458,7 @@ func (in droneInput) payload(who *callerIdentity) map[string]any {
|
||||
"name": strings.TrimSpace(in.Name),
|
||||
"model": strings.TrimSpace(in.Model),
|
||||
"serial": strings.TrimSpace(in.Serial),
|
||||
"flight_controller_serial": strings.TrimSpace(in.FlightControllerSerial),
|
||||
"firmware": strings.TrimSpace(in.Firmware),
|
||||
"controller_firmware": strings.TrimSpace(in.ControllerFirmware),
|
||||
"registration": strings.TrimSpace(in.Registration),
|
||||
@@ -460,7 +477,8 @@ func (in droneInput) payload(who *callerIdentity) map[string]any {
|
||||
func (in droneInput) identifiable() bool {
|
||||
return strings.TrimSpace(in.Name) != "" ||
|
||||
strings.TrimSpace(in.Model) != "" ||
|
||||
strings.TrimSpace(in.Serial) != ""
|
||||
strings.TrimSpace(in.Serial) != "" ||
|
||||
strings.TrimSpace(in.FlightControllerSerial) != ""
|
||||
}
|
||||
|
||||
// POST /api/drones — register a drone (assigned to the caller's org).
|
||||
@@ -495,19 +513,21 @@ func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
|
||||
// is deliberately absent — the auto path never touches those.
|
||||
type autoDroneInput struct {
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
// The airframe serial is absent by design: the SDK cannot read it, so an
|
||||
// aircraft can only report its flight controller's.
|
||||
FlightControllerSerial string `json:"flightControllerSerial"`
|
||||
Firmware string `json:"firmware"`
|
||||
ControllerFirmware string `json:"controllerFirmware"`
|
||||
}
|
||||
|
||||
// findDroneBySerial looks a drone up across *all* orgs, ignoring caller scope:
|
||||
// the serial is unique per airframe, so the caller's own scope is not enough to
|
||||
// know whether the record already exists.
|
||||
func (s *Server) findDroneBySerial(ctx context.Context, serial string) (droneRecord, bool, error) {
|
||||
// findDroneByFCSerial looks a drone up across *all* orgs, ignoring caller scope:
|
||||
// the flight controller's serial is unique per airframe, so the caller's own
|
||||
// scope is not enough to know whether the record already exists.
|
||||
func (s *Server) findDroneByFCSerial(ctx context.Context, fcSerial string) (droneRecord, bool, error) {
|
||||
var list struct {
|
||||
Items []droneRecord `json:"items"`
|
||||
}
|
||||
filter := "serial = " + strconv.Quote(serial)
|
||||
filter := "flight_controller_serial = " + strconv.Quote(fcSerial)
|
||||
if _, err := s.listRecords(ctx, "drones", filter, "created", &list); err != nil {
|
||||
return droneRecord{}, false, err
|
||||
}
|
||||
@@ -518,8 +538,15 @@ func (s *Server) findDroneBySerial(ctx context.Context, serial string) (droneRec
|
||||
}
|
||||
|
||||
// POST /api/drones/auto — upsert the drone the caller just connected, keyed by
|
||||
// serial. Called by the Web App when a device reports a connected aircraft, so
|
||||
// the fleet fills itself in without the pilot typing anything.
|
||||
// the flight controller's serial. Called by the Web App when a device reports a
|
||||
// connected aircraft, so the fleet fills itself in without the pilot typing
|
||||
// anything.
|
||||
//
|
||||
// It keys on the flight controller's serial rather than the airframe's because
|
||||
// that is the only one an aircraft reports (MSDK v4 exposes no aircraft-level
|
||||
// serial). The airframe serial — the registered one — stays blank here for the
|
||||
// pilot to fill in on the Drones tab; guessing it from the flight controller's
|
||||
// would put a wrong number on a compliance record.
|
||||
//
|
||||
// Idempotent by design: it runs on every connection event, so an existing entry
|
||||
// is refreshed (firmware changes as the pilot updates the aircraft) rather than
|
||||
@@ -531,15 +558,15 @@ func (s *Server) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
serial := strings.TrimSpace(in.Serial)
|
||||
if serial == "" {
|
||||
fcSerial := strings.TrimSpace(in.FlightControllerSerial)
|
||||
if fcSerial == "" {
|
||||
// No serial means no stable identity to key on — auto-adding here would
|
||||
// mint a fresh drone on every reconnect.
|
||||
writeError(w, http.StatusBadRequest, "serial is required to auto-add a drone")
|
||||
writeError(w, http.StatusBadRequest, "flightControllerSerial is required to auto-add a drone")
|
||||
return
|
||||
}
|
||||
|
||||
existing, found, err := s.findDroneBySerial(r.Context(), serial)
|
||||
existing, found, err := s.findDroneByFCSerial(r.Context(), fcSerial)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
@@ -587,7 +614,7 @@ func (s *Server) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
|
||||
// for the pilot to fill in on the Drones tab.
|
||||
payload := droneInput{
|
||||
Model: strings.TrimSpace(in.Model),
|
||||
Serial: serial,
|
||||
FlightControllerSerial: fcSerial,
|
||||
Firmware: strings.TrimSpace(in.Firmware),
|
||||
ControllerFirmware: strings.TrimSpace(in.ControllerFirmware),
|
||||
}.payload(who)
|
||||
|
||||
@@ -182,7 +182,7 @@ func (h *Hub) Ingest(deviceID string, raw map[string]any) {
|
||||
s.Connected = c
|
||||
if !c {
|
||||
s.Telemetry = Telemetry{} // drone unlinked: live telemetry is no longer valid
|
||||
s.Serial, s.Firmware, s.ControllerFirmware = "", "", ""
|
||||
s.FlightControllerSerial, s.Firmware, s.ControllerFirmware = "", "", ""
|
||||
}
|
||||
}
|
||||
if m, ok := raw["model"].(string); ok {
|
||||
|
||||
@@ -31,10 +31,15 @@ type DeviceState struct {
|
||||
// ("success", "failed", …) — not the aircraft's FAA/CAA registration number,
|
||||
// which lives on the drone's logbook record.
|
||||
Registration string `json:"registration"`
|
||||
// Identity of the connected aircraft, as it reports itself. Serial and the
|
||||
// Identity of the connected aircraft, as it reports itself. The serial and the
|
||||
// two firmware versions resolve asynchronously after connect, each on its own
|
||||
// schedule, so these fill in over several events rather than all at once.
|
||||
Serial string `json:"serial"`
|
||||
//
|
||||
// FlightControllerSerial is the flight controller's own serial, not the number
|
||||
// on the airframe: MSDK v4 exposes no aircraft-level serial, so the serial a
|
||||
// drone is *registered* under is hand-entered on its logbook record instead.
|
||||
// It is nonetheless stable per airframe, which is what the fleet auto-add keys on.
|
||||
FlightControllerSerial string `json:"flightControllerSerial"`
|
||||
Firmware string `json:"firmware"`
|
||||
ControllerFirmware string `json:"controllerFirmware"`
|
||||
Telemetry Telemetry `json:"telemetry"`
|
||||
@@ -98,8 +103,8 @@ func toInt(v any) (int, bool) {
|
||||
// answers serial in seconds but firmware can take far longer — so an absent
|
||||
// value never clears one an earlier event already delivered.
|
||||
func applyIdentity(s *DeviceState, raw map[string]any) {
|
||||
if v, ok := raw["serial"].(string); ok && v != "" {
|
||||
s.Serial = v
|
||||
if v, ok := raw["flightControllerSerial"].(string); ok && v != "" {
|
||||
s.FlightControllerSerial = v
|
||||
}
|
||||
if v, ok := raw["firmware"].(string); ok && v != "" {
|
||||
s.Firmware = v
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Splits the drone's serial in two, because the app had been conflating them.
|
||||
//
|
||||
// `getSerialNumber` in MSDK v4 is a `BaseComponent` method: every component
|
||||
// answers for itself, and the Fly App reads it off the *flight controller*. So
|
||||
// what a connected aircraft reports — and what auto-add has been storing in
|
||||
// `serial` — is the flight controller's number, not the one on the airframe's
|
||||
// sticker that the drone is registered under. On a Mavic Pro the aircraft says
|
||||
// 08RDE1J00103H1 where the sticker reads 08QDE3H012032E. There is no fix on the
|
||||
// SDK side: MSDK v4 exposes no aircraft-level serial at all, so the registered
|
||||
// serial can only ever be typed in by the pilot. (Same trap as the firmware
|
||||
// versions in 1720300900 — a component reporting its own value, taken for the
|
||||
// aircraft's — but with no correct source to switch to.)
|
||||
//
|
||||
// After this:
|
||||
// - serial the airframe sticker's number — hand-entered,
|
||||
// and the one that belongs on a compliance record
|
||||
// - flight_controller_serial what the aircraft reports; auto-filled, and the
|
||||
// key POST /api/drones/auto upserts on
|
||||
//
|
||||
// Existing values are *copied* into `flight_controller_serial`, not moved: every
|
||||
// non-empty `serial` today came from auto-add and is therefore a flight
|
||||
// controller's, but a pilot may since have corrected one by hand, and this
|
||||
// cannot tell the two apart. Copying keeps auto-add matching the same airframes
|
||||
// it matched before (a cleared key would fork every drone into a duplicate on
|
||||
// its next connect) and loses nothing; the Drones tab shows both numbers, so a
|
||||
// `serial` still holding a flight controller's is visible and correctable.
|
||||
//
|
||||
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
|
||||
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: the field is
|
||||
// added only if absent and the backfill only writes records whose
|
||||
// `flight_controller_serial` is still empty, so re-running is a no-op.
|
||||
//
|
||||
// Depends on 1720300900_add_drone_identity.js (idx_drones_serial).
|
||||
migrate(
|
||||
(app) => {
|
||||
const drones = app.findCollectionByNameOrId('drones')
|
||||
|
||||
if (!drones.fields.find((f) => f.name === 'flight_controller_serial')) {
|
||||
drones.fields.add(new Field({ name: 'flight_controller_serial', type: 'text', max: 120 }))
|
||||
}
|
||||
|
||||
// One fleet entry per airframe, enforced on both numbers: auto-add keys on
|
||||
// the flight controller's, so a duplicate there would fork a drone's history
|
||||
// across two records — the reason idx_drones_serial existed in the first
|
||||
// place. The airframe serial stays unique too, now on its own terms.
|
||||
const idx =
|
||||
'CREATE UNIQUE INDEX `idx_drones_fc_serial` ON `drones` (`flight_controller_serial`)' +
|
||||
" WHERE `flight_controller_serial` != ''"
|
||||
if (!drones.indexes.find((i) => i.includes('idx_drones_fc_serial'))) drones.indexes.push(idx)
|
||||
|
||||
app.save(drones)
|
||||
|
||||
// Backfill: today's `serial` values are what auto-add stored, i.e. flight
|
||||
// controllers'. Copy them across so connected drones keep matching their
|
||||
// existing fleet entry.
|
||||
for (const rec of app.findAllRecords('drones')) {
|
||||
const serial = (rec.getString('serial') || '').trim()
|
||||
if (!serial || (rec.getString('flight_controller_serial') || '').trim()) continue
|
||||
rec.set('flight_controller_serial', serial)
|
||||
app.save(rec)
|
||||
}
|
||||
},
|
||||
(app) => {
|
||||
const drones = app.findCollectionByNameOrId('drones')
|
||||
const f = drones.fields.find((x) => x.name === 'flight_controller_serial')
|
||||
if (f) drones.fields.removeById(f.id)
|
||||
drones.indexes = drones.indexes.filter((i) => !i.includes('idx_drones_fc_serial'))
|
||||
app.save(drones)
|
||||
},
|
||||
)
|
||||
+29
-13
@@ -56,11 +56,24 @@ 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.
|
||||
* The *flight controller's* serial number — not the aircraft's.
|
||||
*
|
||||
* `getSerialNumber` is a `BaseComponent` method, so every component answers for
|
||||
* itself, and this one is read off the flight controller: on a Mavic Pro it
|
||||
* returns 08RDE1J00103H1 where the airframe sticker says 08QDE3H012032E (the
|
||||
* same number DJI Go shows as "Flight Controller SN"). MSDK v4 exposes no
|
||||
* aircraft-level serial at all — `BaseProduct` offers only the model and the
|
||||
* firmware package version — so the airframe serial the pilot registers with
|
||||
* cannot be read over the SDK and is typed by hand in the Web App instead.
|
||||
*
|
||||
* Still worth publishing: it is stable per airframe (short of swapping the
|
||||
* flight controller), which is what the fleet's auto-add keys on.
|
||||
*
|
||||
* 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 flightControllerSerial: String? = null
|
||||
|
||||
@Volatile private var firmwareVersion: String? = null
|
||||
|
||||
@@ -215,7 +228,7 @@ class DjiSdkBridge(
|
||||
"model" to model,
|
||||
"firmware" to (product?.firmwarePackageVersion ?: firmwareVersion),
|
||||
"controllerFirmware" to controllerFirmwareVersion,
|
||||
"serial" to serialNumber,
|
||||
"flightControllerSerial" to flightControllerSerial,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -243,7 +256,7 @@ class DjiSdkBridge(
|
||||
* worst case is dropping a just-resolved value that the retry chain re-fetches.
|
||||
*/
|
||||
private fun resetIdentityCache() {
|
||||
serialNumber = null
|
||||
flightControllerSerial = null
|
||||
firmwareVersion = null
|
||||
controllerFirmwareVersion = null
|
||||
}
|
||||
@@ -252,14 +265,15 @@ class DjiSdkBridge(
|
||||
private fun clearIdentity() {
|
||||
mainHandler.post {
|
||||
identityGeneration++
|
||||
serialNumber = null
|
||||
flightControllerSerial = null
|
||||
firmwareVersion = null
|
||||
controllerFirmwareVersion = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the aircraft's serial number and firmware version.
|
||||
* Resolves the flight controller's serial number and the aircraft's firmware
|
||||
* version. (The airframe serial is not among them — see [flightControllerSerial].)
|
||||
*
|
||||
* Neither is readable the instant a product connects — `getFirmwarePackageVersion`
|
||||
* returns null and the flight controller's callbacks fail until the SDK has
|
||||
@@ -284,13 +298,15 @@ class DjiSdkBridge(
|
||||
|
||||
val controller = (product as? Aircraft)?.flightController
|
||||
if (controller != null) {
|
||||
if (serialNumber == null) {
|
||||
if (flightControllerSerial == null) {
|
||||
// Answers for the flight controller itself, not the airframe — see
|
||||
// [flightControllerSerial] for why there is no aircraft-level source.
|
||||
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
|
||||
if (generation != identityGeneration || flightControllerSerial == value) return@post
|
||||
flightControllerSerial = value
|
||||
emitIdentity()
|
||||
}
|
||||
}
|
||||
@@ -325,7 +341,7 @@ class DjiSdkBridge(
|
||||
)
|
||||
}
|
||||
|
||||
val pending = serialNumber == null || firmwareVersion == null || controllerFirmwareVersion == null
|
||||
val pending = flightControllerSerial == null || firmwareVersion == null || controllerFirmwareVersion == null
|
||||
if (pending && attempt + 1 < IDENTITY_MAX_ATTEMPTS) {
|
||||
mainHandler.postDelayed({ fetchIdentity(generation, attempt + 1) }, IDENTITY_RETRY_MS)
|
||||
}
|
||||
@@ -342,7 +358,7 @@ class DjiSdkBridge(
|
||||
emit(
|
||||
mapOf(
|
||||
"type" to "identity",
|
||||
"serial" to serialNumber,
|
||||
"flightControllerSerial" to flightControllerSerial,
|
||||
"firmware" to firmwareVersion,
|
||||
"controllerFirmware" to controllerFirmwareVersion,
|
||||
)
|
||||
|
||||
@@ -71,7 +71,11 @@ class FlightModel extends ChangeNotifier {
|
||||
String? firmwareVersion;
|
||||
/// The remote controller's own firmware — distinct from the aircraft's above.
|
||||
String? controllerFirmwareVersion;
|
||||
String? serialNumber;
|
||||
|
||||
/// The *flight controller's* serial, which is the only serial MSDK v4 exposes —
|
||||
/// the airframe serial on the sticker (the one a drone is registered under) is
|
||||
/// unreadable over the SDK and is entered by hand in the Web App's fleet.
|
||||
String? flightControllerSerial;
|
||||
|
||||
// ── Flight controller telemetry ────────────────────────────────────────────
|
||||
int? satellites;
|
||||
|
||||
@@ -154,7 +154,7 @@ class _HomePageState extends State<HomePage> {
|
||||
_model.model = event['model'] as String?;
|
||||
_model.firmwareVersion = event['firmware'] as String?;
|
||||
_model.controllerFirmwareVersion = event['controllerFirmware'] as String?;
|
||||
_model.serialNumber = event['serial'] as String?;
|
||||
_model.flightControllerSerial = event['flightControllerSerial'] as String?;
|
||||
if (!_model.connected) _clearTelemetry();
|
||||
_model.bump();
|
||||
break;
|
||||
@@ -163,7 +163,8 @@ class _HomePageState extends State<HomePage> {
|
||||
// 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.flightControllerSerial =
|
||||
event['flightControllerSerial'] as String? ?? _model.flightControllerSerial;
|
||||
_model.firmwareVersion = event['firmware'] as String? ?? _model.firmwareVersion;
|
||||
_model.controllerFirmwareVersion =
|
||||
event['controllerFirmware'] as String? ?? _model.controllerFirmwareVersion;
|
||||
@@ -272,7 +273,7 @@ class _HomePageState extends State<HomePage> {
|
||||
_model.recordSeconds = 0;
|
||||
_model.firmwareVersion = null;
|
||||
_model.controllerFirmwareVersion = null;
|
||||
_model.serialNumber = null;
|
||||
_model.flightControllerSerial = null;
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
|
||||
@@ -134,7 +134,7 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
|
||||
3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])),
|
||||
_ => ('About', _infoRows(<(String, String)>[
|
||||
('Model', _m.model ?? '—'),
|
||||
('Serial Number', _m.serialNumber ?? '—'),
|
||||
('Flight Controller SN', _m.flightControllerSerial ?? '—'),
|
||||
('Firmware', _m.firmwareVersion ?? '—'),
|
||||
('Controller Firmware', _m.controllerFirmwareVersion ?? '—'),
|
||||
('MSDK', _m.sdkVersion),
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -35,8 +35,8 @@
|
||||
})()
|
||||
</script>
|
||||
<title>PilotVault — Control Panel</title>
|
||||
<script type="module" crossorigin src="./assets/index-DrzdlcUJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DgA6j1AP.css">
|
||||
<script type="module" crossorigin src="./assets/index-DwyUIzSZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Bw3iqrF-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -346,9 +346,10 @@ export async function createDrone(drone) {
|
||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
||||
}
|
||||
|
||||
// Upsert the drone a connected device just reported, keyed by serial. Safe to
|
||||
// call on every connection event: the server refreshes an existing entry rather
|
||||
// than duplicating it, and answers { created, updated }.
|
||||
// Upsert the drone a connected device just reported, keyed by the flight
|
||||
// controller's serial (the only one an aircraft reports). Safe to call on every
|
||||
// connection event: the server refreshes an existing entry rather than
|
||||
// duplicating it, and answers { created, updated }.
|
||||
export async function autoAddDrone(identity) {
|
||||
try {
|
||||
const r = await fetch('/bff/drones/auto', {
|
||||
|
||||
@@ -311,10 +311,15 @@ const fleet = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
// Serials of aircraft connected right now — lets the Drones section flag which
|
||||
// fleet entry is the drone in front of the pilot.
|
||||
const connectedSerials = computed(() =>
|
||||
ids.value.map((id) => (devices[id].connected ? devices[id].serial : '')).filter(Boolean),
|
||||
// Flight-controller serials of aircraft connected right now — lets the Drones
|
||||
// section flag which fleet entry is the drone in front of the pilot. Keyed on
|
||||
// the flight controller's serial because that is the only one an aircraft
|
||||
// reports; the airframe serial on a fleet entry is hand-entered and no connected
|
||||
// device ever knows it.
|
||||
const connectedFcSerials = computed(() =>
|
||||
ids.value
|
||||
.map((id) => (devices[id].connected ? devices[id].flightControllerSerial : ''))
|
||||
.filter(Boolean),
|
||||
)
|
||||
const onlineCount = computed(() => ids.value.filter((id) => devices[id].online).length)
|
||||
const flyingCount = computed(() =>
|
||||
@@ -391,9 +396,10 @@ const orgLabel = computed(
|
||||
/* ---------- fleet auto-registration ---------- */
|
||||
|
||||
// Connecting a drone in the Fly App should be enough to get it into the pilot's
|
||||
// fleet — nobody wants to retype a serial off an airframe. Every device update
|
||||
// carrying a connected aircraft's serial is offered to the server, which upserts
|
||||
// on serial (see POST /api/drones/auto).
|
||||
// fleet — nobody wants to retype an airframe's identity. Every device update
|
||||
// carrying a connected aircraft's flight-controller serial is offered to the
|
||||
// server, which upserts on it (see POST /api/drones/auto). The airframe serial
|
||||
// is not part of this: the SDK cannot read it, so the pilot enters it by hand.
|
||||
//
|
||||
// upsert() runs on every telemetry frame, so the identity tuple is remembered
|
||||
// and only a *change* is sent: without that this would fire a request per frame.
|
||||
@@ -404,14 +410,19 @@ const sentIdentities = new Set()
|
||||
const dronesView = ref(null)
|
||||
|
||||
async function autoRegister(d) {
|
||||
if (!d.connected || !d.serial) return
|
||||
if (!d.connected || !d.flightControllerSerial) return
|
||||
const identity = {
|
||||
serial: d.serial,
|
||||
flightControllerSerial: d.flightControllerSerial,
|
||||
model: d.model || '',
|
||||
firmware: d.firmware || '',
|
||||
controllerFirmware: d.controllerFirmware || '',
|
||||
}
|
||||
const key = [identity.serial, identity.model, identity.firmware, identity.controllerFirmware].join('|')
|
||||
const key = [
|
||||
identity.flightControllerSerial,
|
||||
identity.model,
|
||||
identity.firmware,
|
||||
identity.controllerFirmware,
|
||||
].join('|')
|
||||
if (sentIdentities.has(key)) return
|
||||
sentIdentities.add(key)
|
||||
|
||||
@@ -432,10 +443,10 @@ async function autoRegister(d) {
|
||||
// A drone deleted from the fleet must be able to come back: without this, its
|
||||
// identity tuple stays in sentIdentities and every later event short-circuits,
|
||||
// so a drone deleted while connected would not reappear until a page reload.
|
||||
function forgetIdentity(serial) {
|
||||
if (!serial) return
|
||||
function forgetIdentity(fcSerial) {
|
||||
if (!fcSerial) return
|
||||
for (const key of sentIdentities) {
|
||||
if (key.startsWith(`${serial}|`)) sentIdentities.delete(key)
|
||||
if (key.startsWith(`${fcSerial}|`)) sentIdentities.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1046,7 +1057,7 @@ onBeforeUnmount(() => {
|
||||
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||||
|
||||
<!-- ---------- Drones ---------- -->
|
||||
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-serials="connectedSerials"
|
||||
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-fc-serials="connectedFcSerials"
|
||||
@deleted="forgetIdentity" />
|
||||
|
||||
<!-- ---------- Documents ---------- -->
|
||||
|
||||
@@ -6,9 +6,10 @@ import { getDrones, createDrone, updateDrone, deleteDrone } from '../api.js'
|
||||
// Org/role scoping is enforced server-side, so this component needs no identity
|
||||
// of its own — only which aircraft are connected right now.
|
||||
const props = defineProps({
|
||||
// Serials of the aircraft currently connected to a device, so the fleet can
|
||||
// show which entry is the drone in front of the pilot right now.
|
||||
connectedSerials: { type: Array, default: () => [] },
|
||||
// Flight-controller serials of the aircraft currently connected to a device, so
|
||||
// the fleet can show which entry is the drone in front of the pilot right now.
|
||||
// Not the airframe serials: a connected aircraft cannot report one.
|
||||
connectedFcSerials: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
// Deleting a drone has to reach the auto-registration bookkeeping in Dashboard,
|
||||
@@ -41,9 +42,9 @@ async function loadAll() {
|
||||
onMounted(loadAll)
|
||||
defineExpose({ reload: loadAll })
|
||||
|
||||
const connected = computed(() => new Set(props.connectedSerials.filter(Boolean)))
|
||||
const connected = computed(() => new Set(props.connectedFcSerials.filter(Boolean)))
|
||||
function isConnected(d) {
|
||||
return !!d.serial && connected.value.has(d.serial)
|
||||
return !!d.flightControllerSerial && connected.value.has(d.flightControllerSerial)
|
||||
}
|
||||
|
||||
/* ---------------- drone form ---------------- */
|
||||
@@ -51,7 +52,8 @@ function isConnected(d) {
|
||||
const C_CLASSES = ['', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']
|
||||
function blankDrone() {
|
||||
return {
|
||||
name: '', model: '', serial: '', firmware: '', controllerFirmware: '',
|
||||
name: '', model: '', serial: '', flightControllerSerial: '',
|
||||
firmware: '', controllerFirmware: '',
|
||||
registration: '', operatorNumber: '', mtomGrams: '', isToy: false,
|
||||
autologsFlights: false, cClass: '',
|
||||
}
|
||||
@@ -71,6 +73,10 @@ function newDrone() {
|
||||
function editDrone(d) {
|
||||
Object.assign(form, {
|
||||
name: d.name || '', model: d.model || '', serial: d.serial || '',
|
||||
// Carried through untouched: the save is a whole-record write, so dropping
|
||||
// this would blank the key the aircraft is recognised by on its next connect
|
||||
// — and the fleet would grow a duplicate entry for the same drone.
|
||||
flightControllerSerial: d.flightControllerSerial || '',
|
||||
firmware: d.firmware || '', controllerFirmware: d.controllerFirmware || '',
|
||||
registration: d.registration || '', operatorNumber: d.operatorNumber || '',
|
||||
mtomGrams: d.mtomGrams || '', isToy: !!d.isToy,
|
||||
@@ -86,7 +92,7 @@ function cancel() {
|
||||
}
|
||||
async function save() {
|
||||
msg.value = ''
|
||||
if (!form.name.trim() && !form.model.trim() && !form.serial.trim()) {
|
||||
if (!form.name.trim() && !form.model.trim() && !form.serial.trim() && !form.flightControllerSerial.trim()) {
|
||||
msg.value = 'Give the drone a custom name, model or serial.'
|
||||
return
|
||||
}
|
||||
@@ -107,7 +113,7 @@ async function removeDrone(d) {
|
||||
const res = await deleteDrone(d.id)
|
||||
confirmId.value = ''
|
||||
if (res.ok) {
|
||||
emit('deleted', d.serial)
|
||||
emit('deleted', d.flightControllerSerial)
|
||||
await loadAll()
|
||||
} else msg.value = res.body?.error || 'Could not delete the drone.'
|
||||
}
|
||||
@@ -171,12 +177,17 @@ const stats = computed(() => ({
|
||||
<label class="block"><span class="eyebrow mb-1 block">Model</span>
|
||||
<input v-model="form.model" class="field" placeholder="DJI Mavic 3 Enterprise" /></label>
|
||||
<label class="block"><span class="eyebrow mb-1 block">Serial number</span>
|
||||
<input v-model="form.serial" class="field" /></label>
|
||||
<input v-model="form.serial" class="field" placeholder="08QDE3H012032E" />
|
||||
<span class="mt-1 block text-xs text-ink-muted">From the sticker on the airframe.</span></label>
|
||||
|
||||
<label class="block"><span class="eyebrow mb-1 block">Flight controller SN</span>
|
||||
<input v-model="form.flightControllerSerial" class="field" placeholder="08RDE1J00103H1" />
|
||||
<span class="mt-1 block text-xs text-ink-muted">Auto-filled on connect.</span></label>
|
||||
<label class="block"><span class="eyebrow mb-1 block">Drone firmware</span>
|
||||
<input v-model="form.firmware" class="field" placeholder="03.02.35.05" /></label>
|
||||
<label class="block"><span class="eyebrow mb-1 block">Controller firmware</span>
|
||||
<input v-model="form.controllerFirmware" class="field" placeholder="01.03.0800" /></label>
|
||||
|
||||
<label class="block"><span class="eyebrow mb-1 block">Registration (FAA/CAA)</span>
|
||||
<input v-model="form.registration" class="field" placeholder="FA3X7K9PLM" /></label>
|
||||
|
||||
@@ -191,8 +202,9 @@ const stats = computed(() => ({
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-ink-muted">
|
||||
Model, serial and both firmware versions fill themselves in when the drone connects — anything
|
||||
you type here is kept as-is.
|
||||
Model, flight controller SN and both firmware versions fill themselves in when the drone
|
||||
connects — anything you type here is kept as-is. The serial number is the one you register the
|
||||
drone under, and only you can supply it: the SDK cannot read it off the aircraft.
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-6">
|
||||
@@ -249,7 +261,16 @@ const stats = computed(() => ({
|
||||
<div v-if="d.name && d.model" class="text-xs text-ink-muted">{{ d.model }}</div>
|
||||
<div v-else-if="!d.name" class="text-xs text-ink-muted">no custom name yet</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.serial || '—' }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">
|
||||
{{ d.serial || '—' }}
|
||||
<!-- Second line, not a column of its own: the flight controller's SN
|
||||
is how the app recognises the drone, but the airframe's is the
|
||||
one that matters on paper, so it must not read as an equal. -->
|
||||
<div v-if="d.flightControllerSerial" class="text-[11px] text-ink-muted"
|
||||
title="Flight controller SN — reported by the aircraft, not its registered serial">
|
||||
FC {{ d.flightControllerSerial }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.firmware || '—' }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.controllerFirmware || '—' }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.registration || '—' }}</td>
|
||||
|
||||
Reference in New Issue
Block a user