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:
tajniak81
2026-07-16 18:36:14 +02:00
co-authored by Claude Opus 4.8
parent 33595c99e8
commit 183c83c177
14 changed files with 297 additions and 139 deletions
+106 -79
View File
@@ -34,56 +34,70 @@ func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
// ---------------------------------------------------------------------------
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 string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controller_firmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operator_number"`
MtomGrams float64 `json:"mtom_grams"`
IsToy bool `json:"is_toy"`
AutologsFlights bool `json:"autologs_flights"`
CClass string `json:"c_class"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
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"`
OperatorNumber string `json:"operator_number"`
MtomGrams float64 `json:"mtom_grams"`
IsToy bool `json:"is_toy"`
AutologsFlights bool `json:"autologs_flights"`
CClass string `json:"c_class"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type droneView struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization string `json:"organization"`
Created string `json:"created"`
ID string `json:"id"`
Name string `json:"name"`
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"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization string `json:"organization"`
Created string `json:"created"`
}
// displayName is what to call the drone in lists, logbook entries and the CSV
// 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,7 +105,8 @@ 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,
Firmware: d.Firmware, ControllerFirmware: d.ControllerFirmware,
FlightControllerSerial: d.FlightControllerSerial,
Firmware: d.Firmware, ControllerFirmware: d.ControllerFirmware,
Registration: d.Registration, OperatorNumber: d.OperatorNumber,
MtomGrams: d.MtomGrams, IsToy: d.IsToy,
AutologsFlights: d.AutologsFlights, CClass: d.CClass,
@@ -419,18 +434,19 @@ func (s *Server) handleListDrones(w http.ResponseWriter, r *http.Request) {
}
type droneInput struct {
Name string `json:"name"` // custom label; optional
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization *string `json:"organization"` // superadmin may target any org
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"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization *string `json:"organization"` // superadmin may target any org
}
func (in droneInput) payload(who *callerIdentity) map[string]any {
@@ -439,18 +455,19 @@ func (in droneInput) payload(who *callerIdentity) map[string]any {
org = strings.TrimSpace(*in.Organization)
}
return map[string]any{
"name": strings.TrimSpace(in.Name),
"model": strings.TrimSpace(in.Model),
"serial": strings.TrimSpace(in.Serial),
"firmware": strings.TrimSpace(in.Firmware),
"controller_firmware": strings.TrimSpace(in.ControllerFirmware),
"registration": strings.TrimSpace(in.Registration),
"operator_number": strings.TrimSpace(in.OperatorNumber),
"mtom_grams": in.MtomGrams,
"is_toy": in.IsToy,
"autologs_flights": in.AutologsFlights,
"c_class": strings.TrimSpace(in.CClass),
"organization": org,
"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),
"operator_number": strings.TrimSpace(in.OperatorNumber),
"mtom_grams": in.MtomGrams,
"is_toy": in.IsToy,
"autologs_flights": in.AutologsFlights,
"c_class": strings.TrimSpace(in.CClass),
"organization": org,
}
}
@@ -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).
@@ -494,20 +512,22 @@ func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
// Everything the pilot curates by hand (custom name, registration, MTOM, class)
// is deliberately absent — the auto path never touches those.
type autoDroneInput struct {
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Model string `json:"model"`
// 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
@@ -586,10 +613,10 @@ func (s *Server) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
// New airframe: record what it reported and leave the curated fields blank
// for the pilot to fill in on the Drones tab.
payload := droneInput{
Model: strings.TrimSpace(in.Model),
Serial: serial,
Firmware: strings.TrimSpace(in.Firmware),
ControllerFirmware: strings.TrimSpace(in.ControllerFirmware),
Model: strings.TrimSpace(in.Model),
FlightControllerSerial: fcSerial,
Firmware: strings.TrimSpace(in.Firmware),
ControllerFirmware: strings.TrimSpace(in.ControllerFirmware),
}.payload(who)
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/drones/records", payload)
if err != nil {