Files
tajniak81andClaude Opus 4.8 183c83c177 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>
2026-07-16 18:36:14 +02:00

621 lines
24 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'dji_service.dart';
import 'flight_model.dart';
import 'login_page.dart';
import 'pb_auth.dart';
import 'theme.dart';
import 'ui/academy_page.dart';
import 'ui/album_page.dart';
import 'ui/flight_control_page.dart';
import 'ui/flight_logs_page.dart';
import 'ui/home_page.dart';
import 'ui/profile_page.dart';
import 'ui/routes_page.dart';
import 'uploader.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
await auth.init();
runApp(const DjiSampleApp());
}
class DjiSampleApp extends StatelessWidget {
const DjiSampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PilotVault',
theme: PV.theme(),
// The app is usable without signing in; login lives behind the user panel.
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final DjiService _dji = DjiService();
final FlightModel _model = FlightModel();
StreamSubscription<Map<String, dynamic>>? _sub;
StreamSubscription<AuthStatus>? _authSub;
StreamSubscription<Position>? _phoneLocSub;
// Streams telemetry to the API Server and receives commands back.
late final ServerUploader _uploader;
StreamSubscription<UploadStatus>? _uploadSub;
final TextEditingController _serverHost =
TextEditingController(text: '10.2.1.101:8080');
@override
void initState() {
super.initState();
_uploader = ServerUploader(
onCommand: _handleServerCommand,
snapshotProvider: _buildStateSnapshot,
);
_uploadSub = _uploader.status.listen((UploadStatus s) {
_model.upload = s;
_model.bump();
});
// Rebuild when the session changes so the user panel reflects sign-in/out.
_authSub = auth.status.listen((_) {
if (mounted) setState(() {});
});
_init();
_startPhoneLocation();
}
/// Streams the phone's own GPS (coarse, low-frequency) and reports it to the
/// server as a telemetry field — a location fallback for the Web App's auto
/// bounding box when the drone has no fix. Best-effort: silently gives up if
/// location services or permission are unavailable.
Future<void> _startPhoneLocation() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) return;
LocationPermission perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
return;
}
_phoneLocSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low, // country-level is all the bbox needs
distanceFilter: 1000, // metres — infrequent updates
),
).listen((Position pos) {
_model.phoneLatitude = pos.latitude;
_model.phoneLongitude = pos.longitude;
_model.bump();
// Report to the server (rides the existing telemetry channel; only the
// phone fields are present, so it never disturbs drone telemetry).
_uploader.onEvent(<String, dynamic>{
'type': 'telemetry',
'phoneLatitude': pos.latitude,
'phoneLongitude': pos.longitude,
});
});
} catch (_) {
// Location plugin/permission unavailable — non-fatal.
}
}
Future<void> _init() async {
_sub = _dji.events().listen(_onEvent, onError: (Object e) {
_snack('Event channel error: $e');
});
try {
final String version = await _dji.getSdkVersion();
_model.sdkVersion = version;
_model.bump();
} catch (_) {
_model.sdkVersion = 'unavailable';
_model.bump();
}
}
void _onEvent(Map<String, dynamic> event) {
if (!mounted) return;
// Forward every event to the API server (throttled internally).
_uploader.onEvent(event);
switch (event['type'] as String?) {
case 'registration':
switch (event['state'] as String?) {
case 'registering':
_model.registration = RegistrationState.registering;
_model.registrationError = null;
break;
case 'success':
_model.registration = RegistrationState.success;
break;
case 'failed':
_model.registration = RegistrationState.failed;
_model.registrationError = event['error'] as String?;
break;
}
_model.bump();
break;
case 'connection':
_model.connected = event['connected'] as bool? ?? false;
_model.model = event['model'] as String?;
_model.firmwareVersion = event['firmware'] as String?;
_model.controllerFirmwareVersion = event['controllerFirmware'] as String?;
_model.flightControllerSerial = event['flightControllerSerial'] 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.flightControllerSerial =
event['flightControllerSerial'] as String? ?? _model.flightControllerSerial;
_model.firmwareVersion = event['firmware'] as String? ?? _model.firmwareVersion;
_model.controllerFirmwareVersion =
event['controllerFirmware'] as String? ?? _model.controllerFirmwareVersion;
_model.bump();
break;
case 'telemetry':
// Ignore stray telemetry that arrives after a disconnect — otherwise it
// repopulates values _clearTelemetry() just wiped, leaving stale readings.
if (!_model.connected) break;
_model.satellites = event['satelliteCount'] as int?;
_model.gpsSignalLevel = event['gpsSignalLevel'] as int?;
_model.isFlying = event['isFlying'] as bool?;
_model.motorsOn = event['areMotorsOn'] as bool?;
_model.flightMode = event['flightMode'] as String?;
_model.altitude = (event['altitude'] as num?)?.toDouble();
_model.latitude = (event['latitude'] as num?)?.toDouble();
_model.longitude = (event['longitude'] as num?)?.toDouble();
_model.homeLatitude = (event['homeLatitude'] as num?)?.toDouble();
_model.homeLongitude = (event['homeLongitude'] as num?)?.toDouble();
_model.homeDistance = (event['homeDistance'] as num?)?.toDouble();
_model.horizontalSpeed = (event['horizontalSpeed'] as num?)?.toDouble();
_model.verticalSpeed = (event['verticalSpeed'] as num?)?.toDouble();
_model.heading = (event['heading'] as num?)?.toDouble();
_model.goHomeHeight = (event['goHomeHeight'] as num?)?.toDouble();
_model.bump();
break;
case 'battery':
// Same guard: a battery packet trailing a disconnect must not revive the
// last percentage (the reported "disconnected but still 42%" bug).
if (!_model.connected) break;
_model.batteryPercent = event['percent'] as int?;
_model.batteryVoltage = (event['voltage'] as num?)?.toDouble();
_model.batteryTemperature = (event['temperature'] as num?)?.toDouble();
_model.bump();
break;
case 'camera':
if (!_model.connected) break;
_model.isRecording = event['isRecording'] as bool? ?? false;
_model.recordSeconds = (event['recordingTimeSeconds'] as num?)?.toInt() ?? _model.recordSeconds;
_model.bump();
break;
case 'exposure':
if (!_model.connected) break;
_model.iso = event['iso'] as String?;
_model.shutter = event['shutter'] as String?;
_model.aperture = event['aperture'] as String?;
_model.ev = event['ev'] as String?;
_model.bump();
break;
case 'gimbal':
if (!_model.connected) break;
_model.gimbalPitch = (event['pitch'] as num?)?.toDouble();
_model.gimbalRoll = (event['roll'] as num?)?.toDouble();
_model.gimbalYaw = (event['yaw'] as num?)?.toDouble();
_model.bump();
break;
case 'djiAccount':
_model.djiAccountState = event['state'] as String? ?? 'unknown';
_model.djiAccountUser = event['user'] as String?;
_model.bump();
break;
case 'mission':
_model.missionState = event['state'] as String? ?? 'idle';
_model.tracking = _model.missionState == 'tracking' || _model.missionState == 'quickshot';
_model.missionRunning = _model.missionState != 'idle';
_model.bump();
break;
case 'mediaList':
final List<dynamic> files = (event['files'] as List<dynamic>?) ?? const <dynamic>[];
_model.media = files
.map((dynamic e) => MediaItem.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
_model.mediaLoading = false;
_model.bump();
break;
case 'mediaDownload':
final int idx = (event['index'] as num?)?.toInt() ?? -1;
final String? path = event['path'] as String?;
if (idx >= 0 && idx < _model.media.length && path != null) {
_model.media[idx].localPath = path;
_model.bump();
}
break;
}
}
void _clearTelemetry() {
_model.satellites = null;
_model.gpsSignalLevel = null;
_model.isFlying = null;
_model.motorsOn = null;
_model.flightMode = null;
_model.altitude = null;
_model.latitude = null;
_model.longitude = null;
_model.homeLatitude = null;
_model.homeLongitude = null;
_model.homeDistance = null;
_model.horizontalSpeed = null;
_model.verticalSpeed = null;
_model.heading = null;
_model.batteryPercent = null;
_model.batteryVoltage = null;
_model.batteryTemperature = null;
_model.isRecording = false;
_model.recordSeconds = 0;
_model.firmwareVersion = null;
_model.controllerFirmwareVersion = null;
_model.flightControllerSerial = null;
}
Future<void> _register() async {
try {
await _dji.registerApp();
} catch (e) {
_snack('registerApp failed: $e');
}
}
Future<void> _connect() async {
try {
final bool started = await _dji.startConnection();
_snack(started ? 'Scanning for product…' : 'Could not start connection');
} catch (e) {
_snack('startConnection failed: $e');
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
// ── API server upload ──────────────────────────────────────────────────────
Future<void> _toggleUpload() async {
if (_model.upload == UploadStatus.disabled) {
await _uploader.enable(_serverHost.text);
} else {
await _uploader.disable();
}
}
/// Builds events describing the app's CURRENT state, sent to the server right
/// after (re)connecting so it always reflects reality.
List<Map<String, dynamic>> _buildStateSnapshot() {
final List<Map<String, dynamic>> events = <Map<String, dynamic>>[
<String, dynamic>{'type': 'registration', 'state': _registrationWire()},
<String, dynamic>{'type': 'connection', 'connected': _model.connected, 'model': _model.model ?? ''},
];
if (_model.batteryPercent != null) {
events.add(<String, dynamic>{'type': 'battery', 'percent': _model.batteryPercent});
}
final Map<String, dynamic> tel = <String, dynamic>{'type': 'telemetry'};
if (_model.satellites != null) tel['satelliteCount'] = _model.satellites;
if (_model.gpsSignalLevel != null) tel['gpsSignalLevel'] = _model.gpsSignalLevel;
if (_model.isFlying != null) tel['isFlying'] = _model.isFlying;
if (_model.motorsOn != null) tel['areMotorsOn'] = _model.motorsOn;
if (_model.flightMode != null) tel['flightMode'] = _model.flightMode;
if (_model.altitude != null) tel['altitude'] = _model.altitude;
if (_model.latitude != null) tel['latitude'] = _model.latitude;
if (_model.longitude != null) tel['longitude'] = _model.longitude;
if (_model.homeLatitude != null) tel['homeLatitude'] = _model.homeLatitude;
if (_model.homeLongitude != null) tel['homeLongitude'] = _model.homeLongitude;
if (_model.homeDistance != null) tel['homeDistance'] = _model.homeDistance;
if (_model.horizontalSpeed != null) tel['horizontalSpeed'] = _model.horizontalSpeed;
if (_model.verticalSpeed != null) tel['verticalSpeed'] = _model.verticalSpeed;
if (_model.heading != null) tel['heading'] = _model.heading;
if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude;
if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude;
if (tel.length > 1) events.add(tel);
return events;
}
String _registrationWire() {
switch (_model.registration) {
case RegistrationState.success:
return 'success';
case RegistrationState.registering:
return 'registering';
case RegistrationState.failed:
return 'failed';
case RegistrationState.idle:
return 'not registered';
}
}
/// Handles commands pushed down from the server.
void _handleServerCommand(String command, Map<String, dynamic> payload) {
switch (command) {
case 'registerApp':
_register();
break;
case 'startConnection':
_connect();
break;
case 'stopConnection':
_dji.stopConnection();
break;
default:
_snack('Unknown server command: $command');
return;
}
_snack('Server command: $command');
}
@override
void dispose() {
_sub?.cancel();
_authSub?.cancel();
_phoneLocSub?.cancel();
_uploadSub?.cancel();
_uploader.dispose();
_serverHost.dispose();
_model.dispose();
super.dispose();
}
/// Opens the login page as a modal popup (from the user panel). Returns to the
/// caller once dismissed; the auth listener refreshes the UI on success.
Future<void> _openLogin() async {
await Navigator.of(context).push(MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (BuildContext ctx) => LoginPage(onSignedIn: () => Navigator.of(ctx).pop()),
));
}
// ── Navigation ───────────────────────────────────────────────────────────
void _goFly() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightControlPage(model: _model, dji: _dji),
));
}
void _openAlbum() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => AlbumPage(model: _model, dji: _dji),
));
}
/// The disconnected-home "Connect aircraft" button: register first if needed
/// (registration auto-starts a product scan on success), else just scan.
Future<void> _connectFlow() async {
if (!auth.isAuthed) {
_snack('Sign in to connect an aircraft');
await _openLogin();
return;
}
if (!_model.registered) {
await _register();
} else {
await _connect();
}
}
void _onTile(String tile) {
switch (tile) {
case 'Album':
_openAlbum();
break;
case 'Academy':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AcademyPage()));
break;
case 'Routes':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const RoutesPage()));
break;
case 'Flight logs':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const FlightLogsPage()));
break;
default:
_snack('$tile — coming soon');
}
}
/// The avatar opens the pilot profile (PilotVault + optional DJI account,
/// stats, library shortcuts). Technical controls live behind its App settings.
void _openProfile() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ProfilePage(
model: _model,
dji: _dji,
onAppSettings: _openSettings,
onSignIn: _openLogin,
),
));
}
// ── UI ───────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return HomeScreen(
model: _model,
onGoFly: _goFly,
onConnect: _connectFlow,
onSettings: _openProfile,
onTile: _onTile,
);
}
/// Aircraft/connection controls that don't appear on the design's launch
/// screen live here: MSDK registration, product connection, and telemetry
/// streaming to the API Server, plus account sign-out.
void _openSettings() {
final PVScheme s = PVScheme.of(context);
showModalBottomSheet<void>(
context: context,
backgroundColor: s.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
return AnimatedBuilder(
animation: _model,
builder: (BuildContext context, _) {
final (Color, String) reg = switch (_model.registration) {
RegistrationState.idle => (s.textTertiary, 'Not registered'),
RegistrationState.registering => (s.warning, 'Registering…'),
RegistrationState.success => (s.success, 'Registered'),
RegistrationState.failed => (s.danger, 'Registration failed'),
};
final (Color, String) up = switch (_model.upload) {
UploadStatus.disabled => (s.textTertiary, 'Off'),
UploadStatus.connecting => (s.warning, 'Connecting…'),
UploadStatus.connected => (s.success, 'Streaming'),
UploadStatus.error => (s.danger, 'Retrying…'),
};
final bool streaming = _model.upload != UploadStatus.disabled;
return Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(color: s.borderStrong, borderRadius: BorderRadius.circular(999)),
),
),
const SizedBox(height: 16),
// Account — signed-in identity + sign-out, or a sign-in entry
// that opens the login page (biometric / face live there).
Row(
children: <Widget>[
Expanded(
child: Text(
auth.isAuthed
? (auth.userEmail.isEmpty ? 'Signed in' : auth.userEmail)
: 'Not signed in',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
),
),
if (auth.isAuthed)
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
auth.signOut();
},
icon: Icon(Icons.logout, size: 16, color: s.textSecondary),
label: Text('Sign out', style: TextStyle(color: s.textSecondary)),
)
else
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_openLogin();
},
icon: const Icon(Icons.login, size: 16),
label: const Text('Sign in'),
),
],
),
// Registration, aircraft and telemetry controls require a
// session — they only appear once the user has signed in.
if (auth.isAuthed) ...<Widget>[
Divider(color: s.border, height: 24),
_settingRow(s, 'Registration', reg.$1, reg.$2, trailing: 'MSDK · ${_model.sdkVersion}'),
if (_model.registrationError != null) ...<Widget>[
const SizedBox(height: 6),
Text(_model.registrationError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.danger)),
],
const SizedBox(height: 10),
FilledButton(
onPressed: _model.registration == RegistrationState.registering ? null : _register,
child: const Text('Register app'),
),
const SizedBox(height: 20),
_settingRow(s, 'Aircraft', _model.connected ? s.success : s.textTertiary,
_model.connected ? 'Connected' : 'No product',
trailing: _model.model ?? '—'),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _model.registered ? _connect : null,
icon: const Icon(Icons.usb, size: 18),
label: const Text('Connect to product'),
),
const SizedBox(height: 20),
_settingRow(s, 'Telemetry stream', up.$1, up.$2),
const SizedBox(height: 10),
TextField(
controller: _serverHost,
enabled: !streaming,
style: PV.body,
decoration: const InputDecoration(labelText: 'API Server host', hintText: '10.2.1.101:8080'),
),
const SizedBox(height: 10),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: streaming ? s.danger : s.accent),
onPressed: _model.upload == UploadStatus.connecting ? null : _toggleUpload,
icon: Icon(streaming ? Icons.stop : Icons.play_arrow, size: 18),
label: Text(streaming ? 'Stop streaming' : 'Start streaming'),
),
] else ...<Widget>[
const SizedBox(height: 14),
Text(
'Sign in to manage registration, aircraft connection and telemetry streaming.',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary),
),
],
],
),
);
},
);
},
);
}
Widget _settingRow(PVScheme s, String title, Color dot, String status, {String? trailing}) {
return Row(
children: <Widget>[
Container(width: 8, height: 8, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
const SizedBox(width: 10),
Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w600, color: s.textPrimary)),
const SizedBox(width: 8),
Text(status, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)),
const Spacer(),
if (trailing != null)
Text(trailing, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
],
);
}
}