diff --git a/Fly Agent App/.gitignore b/Fly Agent App/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/Fly Agent App/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/Fly Agent App/.metadata b/Fly Agent App/.metadata new file mode 100644 index 0000000..aa8e03d --- /dev/null +++ b/Fly Agent App/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Fly Agent App/README.md b/Fly Agent App/README.md new file mode 100644 index 0000000..bbbcf02 --- /dev/null +++ b/Fly Agent App/README.md @@ -0,0 +1,64 @@ +# PilotVault Fly Agent + +Android companion app that runs **next to DJI Fly**. DJI Fly flies the aircraft; +the Agent listens to the aircraft's **Remote ID** broadcast, streams the paired +aircraft's position to PilotVault, and files the flight in the logbook. It uses +no DJI SDK and never sends a command, so it works with every DJI Fly aircraft +(Mavic 4 Pro, Air 3S, Mini 5 Pro, …) as long as Remote ID is broadcasting. + +Plan and research: `REA/DJI Fly/PilotVault_FlyAgentApp_Plan.md` (outside this repo). +Built so far: phases **A0–A3** (login, Remote ID receiver, live uplink, flight +session → logbook). Not yet: RTMP video (A4), flight-record import (A5), media +metadata (A6), missions to DJI Fly (A7). + +## Build + +```bash +flutter build apk --debug +``` + +`build/app/outputs/flutter-apk/app-debug.apk`, package `com.pilotvault.flyagent`. + +- `permission_handler` is pinned to 12.x: 13.x needs compileSdk 37. +- `kotlin.incremental=false` in `android/gradle.properties`: the project and the + pub cache are on different drives, which breaks Kotlin's incremental caches. + +## How it fits + +| Piece | Where | +|---|---| +| Remote ID scanners (BLE legacy + BT5, Wi-Fi NaN, Wi-Fi Beacon) in a foreground service | `android/.../flyagent/*.kt` | +| OpenDroneID decoder (ASTM F3411 / EN 4709-002) | `lib/rid/odid_decoder.dart` | +| Frames → aircraft (merged by serial across transports), pairing | `lib/rid/rid_aircraft.dart`, `rid_service.dart` | +| Live uplink to the hub, `/ws/device?id=agent-` | `lib/uplink/uplink.dart` | +| Session state machine (armed → active → landed), auto start/stop | `lib/session/flight_session.dart` | +| Filing to `POST /api/flights`, offline queue | `lib/session/session_controller.dart` | + +The scanners only strip transport framing; all decoding is in Dart and unit +tested against byte-built messages (`test/`). + +**API Server contract.** No server changes were needed: + +- Live map: the hub already ingests `telemetry` events (`latitude`, `longitude`, + `altitude` = height above take-off, `velocityX/Y/Z` = north/east/down, + `isFlying`, `phoneLatitude/Longitude`). The Agent adds `source: "remote-id"`, + `ridSerial`, `pilotLatitude/Longitude`, `uaClass`, which ride along in the raw + event. It never sends `flightControllerSerial`, so the Web App's fleet + auto-add does not mint a drone from a Remote ID serial. +- Logbook: entries are filed with `loggingPath: "manual"` and every § 5 manual + field filled from the session (Remote ID is not the aircraft's own flight + recorder, so "automatic" would be a false claim). `logging_path` only allows + `automatic | manual`, so the plan's `"companion"` value would need a migration. +- Pairing lives on the phone (SharedPreferences), not on the drone record; a + `remote_id_serial` drone field is a later server change. + +**Privacy.** Every aircraft in range is decoded, but only the paired one is +uploaded or logged. Other operators' broadcasts stay in memory on the phone. + +## Limits worth knowing + +- Wi-Fi Beacon Remote ID needs Android 11+ and Android throttles Wi-Fi scans to + 4 per 2 minutes (turn off "Wi-Fi scan throttling" in Developer options). + An aircraft that only broadcasts over Beacon updates every ~30 s. +- Not yet checked on a real aircraft: which transport each DJI Fly aircraft uses + (research R1), and NaN on the SHT-AL09 tablet (Android 9: no Beacon IEs). diff --git a/Fly Agent App/analysis_options.yaml b/Fly Agent App/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/Fly Agent App/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/Fly Agent App/android/.gitignore b/Fly Agent App/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/Fly Agent App/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/Fly Agent App/android/app/build.gradle.kts b/Fly Agent App/android/app/build.gradle.kts new file mode 100644 index 0000000..fa8d521 --- /dev/null +++ b/Fly Agent App/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.pilotvault.flyagent" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.pilotvault.flyagent" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/Fly Agent App/android/app/src/debug/AndroidManifest.xml b/Fly Agent App/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Fly Agent App/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Fly Agent App/android/app/src/main/AndroidManifest.xml b/Fly Agent App/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..760f3d9 --- /dev/null +++ b/Fly Agent App/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/BleRidScanner.kt b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/BleRidScanner.kt new file mode 100644 index 0000000..a266335 --- /dev/null +++ b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/BleRidScanner.kt @@ -0,0 +1,110 @@ +package com.pilotvault.flyagent + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Build +import android.os.ParcelUuid + +/** + * Remote ID over Bluetooth LE (ASTM F3411 / ASD-STAN EN 4709-002). + * + * The broadcast is service data under the 16-bit UUID 0xFFFA whose first byte + * is the application code 0x0D, then a message counter, then either one 25-byte + * message (BT4 legacy advertising) or a message pack (BT5 extended / Long Range). + */ +class BleRidScanner(private val context: Context) { + companion object { + const val TRANSPORT_LEGACY = "ble" + const val TRANSPORT_EXTENDED = "ble5" + private val SERVICE_UUID: ParcelUuid = ParcelUuid.fromString("0000fffa-0000-1000-8000-00805f9b34fb") + private const val APP_CODE: Byte = 0x0D + } + + private var running = false + + private val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) = handle(result) + + override fun onBatchScanResults(results: MutableList) = results.forEach(::handle) + + override fun onScanFailed(errorCode: Int) { + running = false + RidHub.status(TRANSPORT_LEGACY, "failed", "scan error $errorCode") + } + } + + fun capabilities(): Map { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + val extended = adapter != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + adapter.isLeExtendedAdvertisingSupported + val coded = adapter != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + adapter.isLeCodedPhySupported + return mapOf("ble" to (adapter != null), "bleExtended" to extended, "bleCoded" to coded) + } + + @SuppressLint("MissingPermission") + fun start() { + if (running) return + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + if (adapter == null) { + RidHub.status(TRANSPORT_LEGACY, "unavailable", "no Bluetooth adapter") + return + } + if (!adapter.isEnabled) { + RidHub.status(TRANSPORT_LEGACY, "unavailable", "Bluetooth is off") + return + } + val scanner = adapter.bluetoothLeScanner ?: run { + RidHub.status(TRANSPORT_LEGACY, "unavailable", "no LE scanner") + return + } + // A filter is what lets Android keep scanning with the screen off. + val filter = ScanFilter.Builder() + .setServiceData(SERVICE_UUID, byteArrayOf(APP_CODE), byteArrayOf(0xFF.toByte())) + .build() + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && adapter.isLeExtendedAdvertisingSupported) { + // Also receive BT5 extended advertising (and Coded PHY where the radio has it). + settings.setLegacy(false).setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED) + } + try { + scanner.startScan(listOf(filter), settings.build(), callback) + running = true + RidHub.status(TRANSPORT_LEGACY, "scanning") + } catch (e: SecurityException) { + RidHub.status(TRANSPORT_LEGACY, "denied", e.message ?: "Bluetooth scan permission missing") + } catch (e: IllegalStateException) { + RidHub.status(TRANSPORT_LEGACY, "unavailable", e.message ?: "Bluetooth is off") + } + } + + @SuppressLint("MissingPermission") + fun stop() { + if (!running) return + running = false + try { + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager) + ?.adapter?.bluetoothLeScanner?.stopScan(callback) + } catch (_: Exception) { + // Bluetooth was switched off underneath us; nothing left to stop. + } + RidHub.status(TRANSPORT_LEGACY, "stopped") + } + + private fun handle(result: ScanResult) { + val data = result.scanRecord?.getServiceData(SERVICE_UUID) ?: return + // [app code][counter][message or pack] + if (data.size < 3 || data[0] != APP_CODE) return + val transport = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !result.isLegacy) { + TRANSPORT_EXTENDED + } else { + TRANSPORT_LEGACY + } + RidHub.frame(transport, result.device.address, result.rssi, data.copyOfRange(2, data.size)) + } +} diff --git a/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/MainActivity.kt b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/MainActivity.kt new file mode 100644 index 0000000..2ce1586 --- /dev/null +++ b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/MainActivity.kt @@ -0,0 +1,71 @@ +package com.pilotvault.flyagent + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +/** + * Bridges the Remote ID receiver to Dart: + * * `pilotvault/rid` (methods): capabilities / start / stop / isRunning, + * battery-optimisation exemption, launching DJI Fly. + * * `pilotvault/rid/events`: raw frames and scanner status from [RidHub]. + */ +class MainActivity : FlutterActivity() { + companion object { + private const val DJI_FLY_PACKAGE = "dji.go.v5" + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val messenger = flutterEngine.dartExecutor.binaryMessenger + EventChannel(messenger, "pilotvault/rid/events").setStreamHandler(RidHub) + MethodChannel(messenger, "pilotvault/rid").setMethodCallHandler { call, result -> + when (call.method) { + "capabilities" -> result.success( + BleRidScanner(this).capabilities() + WifiRidScanner(this).capabilities() + ) + "start" -> { + RidScanService.start(this) + result.success(true) + } + "stop" -> { + RidScanService.stop(this) + result.success(true) + } + "isRunning" -> result.success(RidScanService.running) + "isIgnoringBatteryOptimizations" -> result.success(ignoringBatteryOptimizations()) + "requestIgnoreBatteryOptimizations" -> { + requestIgnoreBatteryOptimizations() + result.success(null) + } + "openDjiFly" -> result.success(openDjiFly()) + else -> result.notImplemented() + } + } + } + + private fun ignoringBatteryOptimizations(): Boolean { + val pm = getSystemService(POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(packageName) + } + + @SuppressLint("BatteryLife") + private fun requestIgnoreBatteryOptimizations() { + if (ignoringBatteryOptimizations()) return + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName")) + ) + } + + private fun openDjiFly(): Boolean { + val intent = packageManager.getLaunchIntentForPackage(DJI_FLY_PACKAGE) ?: return false + startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return true + } +} diff --git a/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidHub.kt b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidHub.kt new file mode 100644 index 0000000..48db6e3 --- /dev/null +++ b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidHub.kt @@ -0,0 +1,50 @@ +package com.pilotvault.flyagent + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.EventChannel + +/** + * Hands raw Remote ID frames from the scanners to Dart. + * + * The scanners live in [RidScanService] and outlive the Flutter activity's + * event channel listener, so they post here and this drops frames while no + * one is listening. Decoding happens in Dart (`lib/rid/odid_decoder.dart`): + * the scanners only strip the transport framing down to the first OpenDroneID + * byte, so the decoder sees the same bytes whichever transport carried them. + */ +object RidHub : EventChannel.StreamHandler { + private val main = Handler(Looper.getMainLooper()) + @Volatile private var sink: EventChannel.EventSink? = null + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + sink = events + } + + override fun onCancel(arguments: Any?) { + sink = null + } + + /** One received broadcast. [payload] starts at an ODID message or message pack. */ + fun frame(transport: String, source: String, rssi: Int, payload: ByteArray) { + emit( + mapOf( + "kind" to "frame", + "transport" to transport, + "source" to source, + "rssi" to rssi, + "ts" to System.currentTimeMillis(), + "payload" to payload, + ) + ) + } + + /** A scanner started, stopped or failed; shown on the receiver's debug screen. */ + fun status(transport: String, state: String, detail: String = "") { + emit(mapOf("kind" to "status", "transport" to transport, "state" to state, "detail" to detail)) + } + + private fun emit(event: Map) { + main.post { sink?.success(event) } + } +} diff --git a/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidScanService.kt b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidScanService.kt new file mode 100644 index 0000000..d53b82a --- /dev/null +++ b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidScanService.kt @@ -0,0 +1,98 @@ +package com.pilotvault.flyagent + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder + +/** + * Foreground service that owns the Remote ID scanners, so they keep running + * (and the process stays alive for the Dart uplink) while DJI Fly is in front. + */ +class RidScanService : Service() { + companion object { + private const val CHANNEL_ID = "rid_scan" + private const val NOTIFICATION_ID = 1 + @Volatile var running = false + private set + + fun start(context: Context) { + val intent = Intent(context, RidScanService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, RidScanService::class.java)) + } + } + + private lateinit var ble: BleRidScanner + private lateinit var wifi: WifiRidScanner + + override fun onCreate() { + super.onCreate() + ble = BleRidScanner(this) + wifi = WifiRidScanner(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + if (!running) { + running = true + ble.start() + wifi.start() + } + return START_STICKY + } + + override fun onDestroy() { + running = false + ble.stop() + wifi.stop() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun buildNotification(): Notification { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Remote ID receiver", NotificationManager.IMPORTANCE_LOW) + ) + } + val open = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + return builder + .setContentTitle("PilotVault Fly Agent") + .setContentText("Listening for Remote ID") + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentIntent(open) + .setOngoing(true) + .build() + } +} diff --git a/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/WifiRidScanner.kt b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/WifiRidScanner.kt new file mode 100644 index 0000000..beca4be --- /dev/null +++ b/Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/WifiRidScanner.kt @@ -0,0 +1,197 @@ +package com.pilotvault.flyagent + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.wifi.WifiManager +import android.net.wifi.aware.AttachCallback +import android.net.wifi.aware.DiscoverySessionCallback +import android.net.wifi.aware.PeerHandle +import android.net.wifi.aware.SubscribeConfig +import android.net.wifi.aware.SubscribeDiscoverySession +import android.net.wifi.aware.WifiAwareManager +import android.net.wifi.aware.WifiAwareSession +import android.os.Build +import android.os.Handler +import android.os.Looper + +/** + * Remote ID over Wi-Fi: NaN (Neighbour Awareness Networking) and Beacon. + * + * NaN: a subscribe session for the service "org.opendroneid.remoteid"; the + * service-specific info is [counter][message pack]. + * + * Beacon: a vendor-specific information element (id 221) with the ASD-STAN OUI + * FA:0B:BC and OUI type 0x0D, then [counter][message pack]. Information elements + * are only exposed from Android 11, and Android throttles foreground Wi-Fi scans + * to four per two minutes unless the user turns throttling off in developer + * options, so Beacon gives sparse updates. Results from other apps' scans are + * picked up too. + */ +class WifiRidScanner(private val context: Context) { + companion object { + const val TRANSPORT_NAN = "nan" + const val TRANSPORT_BEACON = "beacon" + private const val NAN_SERVICE = "org.opendroneid.remoteid" + private const val IE_VENDOR_SPECIFIC = 221 + private val BEACON_OUI = byteArrayOf(0xFA.toByte(), 0x0B, 0xBC.toByte(), 0x0D) + private const val BEACON_SCAN_INTERVAL_MS = 30_000L + } + + private val main = Handler(Looper.getMainLooper()) + private val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + private var awareSession: WifiAwareSession? = null + private var subscribe: SubscribeDiscoverySession? = null + private var beaconRunning = false + + fun capabilities(): Map = mapOf( + "nan" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE)), + "beacon" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R), + ) + + fun start() { + startNan() + startBeacon() + } + + fun stop() { + stopNan() + stopBeacon() + } + + // ── NaN ──────────────────────────────────────────────────────────────── + + @SuppressLint("MissingPermission") + private fun startNan() { + if (awareSession != null) return + if (capabilities()["nan"] != true) { + RidHub.status(TRANSPORT_NAN, "unavailable", "this phone has no Wi-Fi Aware") + return + } + val manager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? WifiAwareManager + if (manager == null || !manager.isAvailable) { + RidHub.status(TRANSPORT_NAN, "unavailable", "Wi-Fi Aware is off (Wi-Fi or location off?)") + return + } + try { + manager.attach(object : AttachCallback() { + override fun onAttached(session: WifiAwareSession) { + awareSession = session + val config = SubscribeConfig.Builder() + .setServiceName(NAN_SERVICE) + .setSubscribeType(SubscribeConfig.SUBSCRIBE_TYPE_PASSIVE) + .build() + session.subscribe(config, object : DiscoverySessionCallback() { + override fun onSubscribeStarted(session: SubscribeDiscoverySession) { + subscribe = session + RidHub.status(TRANSPORT_NAN, "scanning") + } + + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray?, + matchFilter: MutableList?, + ) { + val info = serviceSpecificInfo ?: return + if (info.size < 2) return + // NaN hides the peer's MAC; the handle is stable for the session. + RidHub.frame(TRANSPORT_NAN, "nan-${peerHandle.hashCode()}", 0, info.copyOfRange(1, info.size)) + } + + override fun onSessionTerminated() { + subscribe = null + RidHub.status(TRANSPORT_NAN, "stopped", "session terminated") + } + }, main) + } + + override fun onAttachFailed() { + RidHub.status(TRANSPORT_NAN, "failed", "attach failed") + } + }, main) + } catch (e: SecurityException) { + RidHub.status(TRANSPORT_NAN, "denied", e.message ?: "nearby Wi-Fi permission missing") + } + } + + private fun stopNan() { + subscribe?.close() + subscribe = null + if (awareSession != null) { + awareSession?.close() + awareSession = null + RidHub.status(TRANSPORT_NAN, "stopped") + } + } + + // ── Beacon ───────────────────────────────────────────────────────────── + + private val scanReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) = readScanResults() + } + + private val scanTick = object : Runnable { + override fun run() { + if (!beaconRunning) return + @Suppress("DEPRECATION") + try { + wifi.startScan() + } catch (_: SecurityException) { + } + main.postDelayed(this, BEACON_SCAN_INTERVAL_MS) + } + } + + private fun startBeacon() { + if (beaconRunning) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + RidHub.status(TRANSPORT_BEACON, "unavailable", "needs Android 11") + return + } + if (!wifi.isWifiEnabled && !wifi.isScanAlwaysAvailable) { + RidHub.status(TRANSPORT_BEACON, "unavailable", "Wi-Fi is off") + return + } + context.registerReceiver(scanReceiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)) + beaconRunning = true + main.post(scanTick) + RidHub.status(TRANSPORT_BEACON, "scanning", "every ${BEACON_SCAN_INTERVAL_MS / 1000} s (Android throttles Wi-Fi scans)") + } + + private fun stopBeacon() { + if (!beaconRunning) return + beaconRunning = false + main.removeCallbacks(scanTick) + try { + context.unregisterReceiver(scanReceiver) + } catch (_: IllegalArgumentException) { + } + RidHub.status(TRANSPORT_BEACON, "stopped") + } + + @SuppressLint("MissingPermission") + private fun readScanResults() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val results = try { + wifi.scanResults + } catch (e: SecurityException) { + RidHub.status(TRANSPORT_BEACON, "denied", e.message ?: "location permission missing") + return + } + for (r in results) { + for (ie in r.informationElements) { + if (ie.id != IE_VENDOR_SPECIFIC) continue + val buf = ie.bytes + val bytes = ByteArray(buf.remaining()).also { buf.duplicate().get(it) } + // [OUI x3][OUI type][counter][message pack] + if (bytes.size < BEACON_OUI.size + 2) continue + if (!bytes.copyOfRange(0, BEACON_OUI.size).contentEquals(BEACON_OUI)) continue + RidHub.frame(TRANSPORT_BEACON, r.BSSID ?: "", r.level, bytes.copyOfRange(BEACON_OUI.size + 1, bytes.size)) + } + } + } +} diff --git a/Fly Agent App/android/app/src/main/res/drawable-v21/launch_background.xml b/Fly Agent App/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/Fly Agent App/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Fly Agent App/android/app/src/main/res/drawable/launch_background.xml b/Fly Agent App/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/Fly Agent App/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Fly Agent App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Fly Agent App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/Fly Agent App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Fly Agent App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Fly Agent App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/Fly Agent App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Fly Agent App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Fly Agent App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/Fly Agent App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Fly Agent App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Fly Agent App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/Fly Agent App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Fly Agent App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Fly Agent App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/Fly Agent App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Fly Agent App/android/app/src/main/res/values-night/styles.xml b/Fly Agent App/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/Fly Agent App/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Fly Agent App/android/app/src/main/res/values/styles.xml b/Fly Agent App/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/Fly Agent App/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Fly Agent App/android/app/src/profile/AndroidManifest.xml b/Fly Agent App/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Fly Agent App/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Fly Agent App/android/build.gradle.kts b/Fly Agent App/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/Fly Agent App/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/Fly Agent App/android/gradle.properties b/Fly Agent App/android/gradle.properties new file mode 100644 index 0000000..986f5f6 --- /dev/null +++ b/Fly Agent App/android/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false + +# The project (E:) and the pub cache (C:) sit on different drives, which breaks +# Kotlin incremental caches ("Could not close incremental caches"). +kotlin.incremental=false diff --git a/Fly Agent App/android/gradle/wrapper/gradle-wrapper.properties b/Fly Agent App/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/Fly Agent App/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/Fly Agent App/android/settings.gradle.kts b/Fly Agent App/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/Fly Agent App/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/Fly Agent App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf b/Fly Agent App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf new file mode 100644 index 0000000..a1b2e6c Binary files /dev/null and b/Fly Agent App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf differ diff --git a/Fly Agent App/assets/fonts/SpaceMono-Bold.ttf b/Fly Agent App/assets/fonts/SpaceMono-Bold.ttf new file mode 100644 index 0000000..2c4f268 Binary files /dev/null and b/Fly Agent App/assets/fonts/SpaceMono-Bold.ttf differ diff --git a/Fly Agent App/assets/fonts/SpaceMono-Regular.ttf b/Fly Agent App/assets/fonts/SpaceMono-Regular.ttf new file mode 100644 index 0000000..1cfa365 Binary files /dev/null and b/Fly Agent App/assets/fonts/SpaceMono-Regular.ttf differ diff --git a/Fly Agent App/lib/api/api.dart b/Fly Agent App/lib/api/api.dart new file mode 100644 index 0000000..a3ee518 --- /dev/null +++ b/Fly Agent App/lib/api/api.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Raised for any non-2xx answer; [message] is what the server said. +class ApiException implements Exception { + const ApiException(this.message, {this.status = 0}); + final String message; + final int status; + + /// No HTTP answer at all (offline, server down): worth queueing and retrying. + bool get isNetwork => status == 0; + + @override + String toString() => message; +} + +class Me { + const Me({required this.id, required this.email, required this.role, required this.orgName}); + final String id; + final String email; + final String role; + final String orgName; +} + +class Drone { + const Drone({required this.id, required this.displayName, required this.model, required this.serial}); + final String id; + final String displayName; + final String model; + final String serial; + + factory Drone.fromJson(Map j) => Drone( + id: (j['id'] ?? '').toString(), + displayName: (j['displayName'] ?? '').toString(), + model: (j['model'] ?? '').toString(), + serial: (j['serial'] ?? '').toString(), + ); +} + +class Flight { + const Flight({ + required this.id, + required this.operationDate, + required this.startTime, + required this.endTime, + required this.droneName, + required this.maxAltitudeAgl, + required this.purpose, + required this.redFlags, + }); + final String id; + final String operationDate; + final String startTime; + final String endTime; + final String droneName; + final double maxAltitudeAgl; + final String purpose; + final List redFlags; + + factory Flight.fromJson(Map j) => Flight( + id: (j['id'] ?? '').toString(), + operationDate: (j['operationDate'] ?? '').toString(), + startTime: (j['startTime'] ?? '').toString(), + endTime: (j['endTime'] ?? '').toString(), + droneName: (j['droneName'] ?? '').toString(), + maxAltitudeAgl: (j['maxAltitudeAgl'] as num?)?.toDouble() ?? 0, + purpose: (j['purpose'] ?? '').toString(), + redFlags: ((j['compliance'] as Map?)?['redFlags'] as List?)?.map((Object? e) => e.toString()).toList() ?? + const [], + ); +} + +/// Talks to the PilotVault **API Server** (never PocketBase directly), the +/// same gateway the Fly App and Web App use. Holds the session token. +class Api extends ChangeNotifier { + static const String defaultServer = 'http://10.2.1.101:8080'; + + SharedPreferences? _prefs; + String _token = ''; + String _email = ''; + Me? me; + + bool get isAuthed => _token.isNotEmpty; + String get email => _email; + String get serverUrl => _prefs?.getString('api_url') ?? defaultServer; + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + _token = _prefs!.getString('api_token') ?? ''; + _email = _prefs!.getString('api_email') ?? ''; + if (isAuthed) unawaited(refreshMe()); + } + + Future signIn({required String server, required String email, required String password}) async { + final String base = normalizeServer(server); + final http.Response resp = await _send( + () => http.post( + Uri.parse('$base/api/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'email': email.trim(), 'password': password}), + ), + ); + if (resp.statusCode == 400) throw const ApiException('Invalid email or password.', status: 400); + final Map body = _decode(resp); + final String token = (body['token'] ?? '').toString(); + if (token.isEmpty) throw const ApiException('Unexpected response from the API server.', status: 500); + _token = token; + _email = ((body['record'] as Map?)?['email'] ?? email.trim()).toString(); + await _prefs?.setString('api_url', base); + await _prefs?.setString('api_token', _token); + await _prefs?.setString('api_email', _email); + await refreshMe(); + notifyListeners(); + } + + Future signOut() async { + _token = ''; + _email = ''; + me = null; + await _prefs?.remove('api_token'); + await _prefs?.remove('api_email'); + notifyListeners(); + } + + Future refreshMe() async { + try { + final Map j = await get('/api/me'); + me = Me( + id: (j['id'] ?? '').toString(), + email: (j['email'] ?? '').toString(), + role: (j['role'] ?? '').toString(), + orgName: (j['organizationName'] ?? '').toString(), + ); + notifyListeners(); + } on ApiException catch (e) { + // An expired token signs the user out; being offline does not. + if (e.status == 401) await signOut(); + } + } + + Future> drones() async { + final Map j = await get('/api/drones'); + return ((j['drones'] as List?) ?? const []) + .map((Object? e) => Drone.fromJson((e as Map).cast())) + .toList(); + } + + Future> flights() async { + final Map j = await get('/api/flights'); + return ((j['flights'] as List?) ?? const []) + .map((Object? e) => Flight.fromJson((e as Map).cast())) + .toList(); + } + + Future createDrone({required String name, required String model, required String serial}) async { + final Map j = + await post('/api/drones', {'name': name, 'model': model, 'serial': serial}); + return Drone.fromJson((j['drone'] as Map).cast()); + } + + /// One-line weather for the briefing and the logbook entry, or null when the + /// OpenWeather integration is off for this user. + Future weatherSummary(double lat, double lon) async { + final Map j = + await get('/api/integrations/openweather/current?lat=${lat.toStringAsFixed(5)}&lon=${lon.toStringAsFixed(5)}'); + final Map? w = (j['weather'] as Map?)?.cast(); + if (j['unavailable'] == true || w == null) return null; + final bool metric = (j['units'] ?? 'metric') != 'imperial'; + final List parts = [ + if (w['temp'] != null) '${(w['temp'] as num).round()}°${metric ? 'C' : 'F'}', + if (w['windSpeed'] != null) + 'wind ${(w['windSpeed'] as num).toStringAsFixed(1)} ${metric ? 'm/s' : 'mph'}' + '${w['windDeg'] != null ? ' from ${w['windDeg']}°' : ''}', + if ((w['description'] ?? '').toString().isNotEmpty) w['description'].toString(), + if (w['clouds'] != null) 'clouds ${w['clouds']}%', + ]; + return parts.isEmpty ? null : parts.join(', '); + } + + /// Files a logbook entry; returns the new flight id. + Future createFlight(Map flight) async { + final Map j = await post('/api/flights', flight); + return ((j['flight'] as Map?)?['id'] ?? '').toString(); + } + + // ── transport ────────────────────────────────────────────────────────── + + Map get _headers => { + 'Authorization': _token, + 'Content-Type': 'application/json', + }; + + Future> get(String path) async => + _decode(await _send(() => http.get(Uri.parse('$serverUrl$path'), headers: _headers))); + + Future> post(String path, Map body) async => _decode( + await _send(() => http.post(Uri.parse('$serverUrl$path'), headers: _headers, body: jsonEncode(body))), + ); + + Future _send(Future Function() request) async { + try { + return await request().timeout(const Duration(seconds: 15)); + } on TimeoutException { + throw const ApiException('The API server did not answer.'); + } catch (e) { + throw ApiException('Cannot reach the API server ($e).'); + } + } + + Map _decode(http.Response resp) { + Map body = {}; + if (resp.body.isNotEmpty) { + try { + body = (jsonDecode(resp.body) as Map).cast(); + } catch (_) {} + } + if (resp.statusCode >= 200 && resp.statusCode < 300) return body; + final String msg = (body['error'] ?? body['message'] ?? 'Request failed (${resp.statusCode})').toString(); + throw ApiException(msg, status: resp.statusCode); + } + + static String normalizeServer(String url) { + String u = url.trim(); + if (u.isEmpty) return defaultServer; + if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u'; + while (u.endsWith('/')) { + u = u.substring(0, u.length - 1); + } + return u; + } +} diff --git a/Fly Agent App/lib/main.dart b/Fly Agent App/lib/main.dart new file mode 100644 index 0000000..a24300d --- /dev/null +++ b/Fly Agent App/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'api/api.dart'; +import 'pilot_location.dart'; +import 'rid/rid_service.dart'; +import 'services.dart'; +import 'session/session_controller.dart'; +import 'theme.dart'; +import 'ui/login_page.dart'; +import 'ui/shell.dart'; +import 'uplink/uplink.dart'; + +/// PilotVault Fly Agent: runs next to DJI Fly and never controls the aircraft. +/// It listens to the aircraft's Remote ID broadcast, streams the paired +/// aircraft's position to PilotVault, and files the flight in the logbook. +/// See REA/DJI Fly/PilotVault_FlyAgentApp_Plan.md for the phase plan. +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final Api api = Api(); + final RidService rid = RidService(); + final PilotLocation pilot = PilotLocation(); + final Uplink uplink = Uplink(rid: rid, pilot: pilot); + final SessionController sessions = SessionController(api: api, rid: rid); + await api.init(); + await rid.init(); + await sessions.init(); + runApp(Services( + api: api, + rid: rid, + pilot: pilot, + uplink: uplink, + sessions: sessions, + child: const AgentApp(), + )); +} + +class AgentApp extends StatelessWidget { + const AgentApp({super.key}); + + @override + Widget build(BuildContext context) { + final Api api = Services.of(context).api; + return MaterialApp( + title: 'PV Fly Agent', + debugShowCheckedModeBanner: false, + theme: PV.theme(), + home: ListenableBuilder( + listenable: api, + builder: (BuildContext context, _) => api.isAuthed ? const AgentShell() : const LoginPage(), + ), + ); + } +} diff --git a/Fly Agent App/lib/pilot_location.dart b/Fly Agent App/lib/pilot_location.dart new file mode 100644 index 0000000..c170052 --- /dev/null +++ b/Fly Agent App/lib/pilot_location.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; + +/// The phone's own GPS fix: where the pilot stands. Used for the briefing +/// weather, the distance to the aircraft, and as the pilot position on the +/// live uplink (the Web App also falls back on it when the drone has no fix). +class PilotLocation extends ChangeNotifier { + Position? position; + String? problem; + StreamSubscription? _sub; + + Future start() async { + if (_sub != null) return; + try { + if (!await Geolocator.isLocationServiceEnabled()) { + problem = 'Location is off'; + notifyListeners(); + return; + } + LocationPermission p = await Geolocator.checkPermission(); + if (p == LocationPermission.denied) p = await Geolocator.requestPermission(); + if (p == LocationPermission.denied || p == LocationPermission.deniedForever) { + problem = 'Location permission refused'; + notifyListeners(); + return; + } + problem = null; + _sub = Geolocator.getPositionStream( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 2), + ).listen((Position pos) { + position = pos; + notifyListeners(); + }, onError: (Object e) { + problem = e.toString(); + notifyListeners(); + }); + } catch (e) { + problem = e.toString(); + notifyListeners(); + } + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} diff --git a/Fly Agent App/lib/rid/odid_decoder.dart b/Fly Agent App/lib/rid/odid_decoder.dart new file mode 100644 index 0000000..9a0284d --- /dev/null +++ b/Fly Agent App/lib/rid/odid_decoder.dart @@ -0,0 +1,284 @@ +import 'dart:typed_data'; + +/// Decoder for OpenDroneID broadcast messages (ASTM F3411-22a, which +/// ASD-STAN EN 4709-002 adopts for the EU). +/// +/// Every message is 25 bytes: a header byte (message type in the high nibble, +/// protocol version in the low nibble) and 24 bytes of body. The Kotlin +/// scanners strip the transport framing, so [decodeOdid] always receives +/// either one message or a message pack (type 0xF) carrying several. +/// +/// Fields an aircraft reports as "unknown" decode to null rather than to the +/// sentinel value, so callers never plot a fix at 0°/0° or a height of -1000 m. + +const int odidMessageSize = 25; + +enum UaStatus { undeclared, ground, airborne, emergency, remoteIdFailure } + +enum HeightReference { takeoff, ground } + +sealed class OdidMessage { + const OdidMessage(); +} + +class OdidBasicId extends OdidMessage { + const OdidBasicId({required this.idType, required this.uaType, required this.uasId}); + + /// 1 serial (ANSI/CTA-2063-A), 2 CAA registration, 3 UTM UUID, 4 session id. + final int idType; + + /// 0 none, 1 aeroplane, 2 multirotor, … (F3411 table 4). + final int uaType; + final String uasId; + + bool get isSerial => idType == 1; +} + +class OdidLocation extends OdidMessage { + const OdidLocation({ + required this.status, + required this.heightReference, + this.track, + this.speedHorizontal, + this.speedVertical, + this.latitude, + this.longitude, + this.altitudePressure, + this.altitudeGeodetic, + this.height, + this.horizontalAccuracy, + this.verticalAccuracy, + this.secondsIntoHour, + }); + + final UaStatus status; + final HeightReference heightReference; + + /// Direction of travel, degrees clockwise from true north. + final double? track; + + /// Metres per second. + final double? speedHorizontal; + + /// Metres per second, up positive. + final double? speedVertical; + final double? latitude; + final double? longitude; + + /// Metres, WGS-84 / barometric. + final double? altitudePressure; + final double? altitudeGeodetic; + + /// Metres above [heightReference]. + final double? height; + + /// Upper bound in metres, from the accuracy category. + final double? horizontalAccuracy; + final double? verticalAccuracy; + + /// Tenths of a second resolution, counted from the top of the UTC hour. + final double? secondsIntoHour; + + bool get hasFix => latitude != null && longitude != null; +} + +class OdidSelfId extends OdidMessage { + const OdidSelfId({required this.descriptionType, required this.description}); + final int descriptionType; + final String description; +} + +class OdidSystem extends OdidMessage { + const OdidSystem({ + required this.operatorLocationType, + required this.classificationType, + this.operatorLatitude, + this.operatorLongitude, + this.operatorAltitude, + required this.areaCount, + required this.areaRadius, + this.areaCeiling, + this.areaFloor, + this.euCategory, + this.euClass, + this.timestamp, + }); + + /// 0 take-off position, 1 live GNSS, 2 fixed. + final int operatorLocationType; + + /// 0 undeclared, 1 EU. + final int classificationType; + final double? operatorLatitude; + final double? operatorLongitude; + final double? operatorAltitude; + final int areaCount; + final double areaRadius; + final double? areaCeiling; + final double? areaFloor; + + /// "open" / "specific" / "certified" when the classification is EU. + final String? euCategory; + + /// "C0".."C6" when the classification is EU. + final String? euClass; + final DateTime? timestamp; +} + +class OdidOperatorId extends OdidMessage { + const OdidOperatorId({required this.idType, required this.operatorId}); + final int idType; + final String operatorId; +} + +/// Authentication pages are acknowledged but not verified. +class OdidAuth extends OdidMessage { + const OdidAuth(); +} + +/// Decodes one message or a message pack. Malformed input yields what could be +/// read (possibly nothing) rather than throwing: a damaged broadcast must never +/// stop the receiver. +List decodeOdid(Uint8List payload) { + if (payload.isEmpty) return const []; + final int type = payload[0] >> 4; + if (type != 0xF) { + final OdidMessage? m = _decodeOne(payload, 0); + return m == null ? const [] : [m]; + } + // Pack: [header][message size][count][count × message size] + if (payload.length < 3) return const []; + final int size = payload[1]; + final int count = payload[2]; + if (size != odidMessageSize) return const []; + final List out = []; + for (int i = 0; i < count; i++) { + final int offset = 3 + i * size; + if (offset + size > payload.length) break; + final OdidMessage? m = _decodeOne(payload, offset); + if (m != null) out.add(m); + } + return out; +} + +OdidMessage? _decodeOne(Uint8List b, int o) { + if (o + odidMessageSize > b.length) return null; + final ByteData d = ByteData.sublistView(b, o, o + odidMessageSize); + switch (b[o] >> 4) { + case 0: + return OdidBasicId( + idType: b[o + 1] >> 4, + uaType: b[o + 1] & 0x0F, + uasId: _ascii(b, o + 2, 20), + ); + case 1: + return _location(d); + case 2: + return const OdidAuth(); + case 3: + return OdidSelfId(descriptionType: b[o + 1], description: _ascii(b, o + 2, 23)); + case 4: + return _system(d); + case 5: + return OdidOperatorId(idType: b[o + 1], operatorId: _ascii(b, o + 2, 20)); + } + return null; +} + +OdidLocation _location(ByteData d) { + final int flags = d.getUint8(1); + final int statusRaw = flags >> 4; + final bool eastSegment = flags & 0x02 != 0; + final bool speedMultiplied = flags & 0x01 != 0; + + final int dirRaw = d.getUint8(2) + (eastSegment ? 180 : 0); + final int speedRaw = d.getUint8(3); + final int vSpeedRaw = d.getInt8(4); + final int tsRaw = d.getUint16(21, Endian.little); + final ({double? latitude, double? longitude}) fix = _latLon(d, 5, 9); + + return OdidLocation( + status: statusRaw < UaStatus.values.length ? UaStatus.values[statusRaw] : UaStatus.undeclared, + heightReference: flags & 0x04 != 0 ? HeightReference.ground : HeightReference.takeoff, + track: dirRaw > 360 ? null : dirRaw.toDouble(), + speedHorizontal: speedRaw == 255 + ? null + : speedMultiplied + ? speedRaw * 0.75 + 255 * 0.25 + : speedRaw * 0.25, + speedVertical: vSpeedRaw == 63 ? null : vSpeedRaw * 0.5, + latitude: fix.latitude, + longitude: fix.longitude, + altitudePressure: _altitude(d, 13), + altitudeGeodetic: _altitude(d, 15), + height: _altitude(d, 17), + verticalAccuracy: _verticalAccuracy[d.getUint8(19) >> 4], + horizontalAccuracy: _horizontalAccuracy[d.getUint8(19) & 0x0F], + secondsIntoHour: tsRaw == 0xFFFF || tsRaw > 36000 ? null : tsRaw / 10.0, + ); +} + +OdidSystem _system(ByteData d) { + final int flags = d.getUint8(1); + final int classification = (flags >> 2) & 0x07; + final int catClass = d.getUint8(17); + final ({double? latitude, double? longitude}) op = _latLon(d, 2, 6); + final int ts = d.getUint32(20, Endian.little); + String? category; + String? cls; + if (classification == 1) { + category = switch (catClass >> 4) { 1 => 'open', 2 => 'specific', 3 => 'certified', _ => null }; + final int c = catClass & 0x0F; + cls = c >= 1 && c <= 7 ? 'C${c - 1}' : null; + } + return OdidSystem( + operatorLocationType: flags & 0x03, + classificationType: classification, + operatorLatitude: op.latitude, + operatorLongitude: op.longitude, + areaCount: d.getUint16(10, Endian.little), + areaRadius: d.getUint8(12) * 10.0, + areaCeiling: _altitude(d, 13), + areaFloor: _altitude(d, 15), + euCategory: category, + euClass: cls, + operatorAltitude: _altitude(d, 18), + // Seconds since 2019-01-01T00:00:00Z; 0 = unknown. + timestamp: ts == 0 ? null : DateTime.utc(2019).add(Duration(seconds: ts)), + ); +} + +({double? latitude, double? longitude}) _latLon(ByteData d, int latAt, int lonAt) { + final int latRaw = d.getInt32(latAt, Endian.little); + final int lonRaw = d.getInt32(lonAt, Endian.little); + final double lat = latRaw * 1e-7; + final double lon = lonRaw * 1e-7; + if ((latRaw == 0 && lonRaw == 0) || lat.abs() > 90 || lon.abs() > 180) { + return (latitude: null, longitude: null); + } + return (latitude: lat, longitude: lon); +} + +/// Altitudes are uint16 in 0.5 m steps offset by -1000 m; 0 (= -1000 m) is unknown. +double? _altitude(ByteData d, int at) { + final int raw = d.getUint16(at, Endian.little); + return raw == 0 ? null : raw * 0.5 - 1000; +} + +String _ascii(Uint8List b, int start, int length) { + final StringBuffer s = StringBuffer(); + for (int i = start; i < start + length && i < b.length; i++) { + final int c = b[i]; + if (c == 0) break; + if (c >= 0x20 && c < 0x7F) s.writeCharCode(c); + } + return s.toString().trim(); +} + +// Accuracy categories → upper bound in metres (F3411 tables 9 and 10). +const List _horizontalAccuracy = [ + null, 18520, 7408, 3704, 1852, 926, 555.6, 185.2, 92.6, 30, 10, 3, 1, null, null, null, +]; +const List _verticalAccuracy = [ + null, 150, 45, 25, 10, 3, 1, null, null, null, null, null, null, null, null, null, +]; diff --git a/Fly Agent App/lib/rid/rid_aircraft.dart b/Fly Agent App/lib/rid/rid_aircraft.dart new file mode 100644 index 0000000..81ab36d --- /dev/null +++ b/Fly Agent App/lib/rid/rid_aircraft.dart @@ -0,0 +1,234 @@ +import 'odid_decoder.dart'; + +/// Everything heard from one aircraft, merged across message types and +/// transports. +/// +/// BLE legacy advertising carries one message per advert, so a Location frame +/// arrives without the serial; frames are first grouped by the radio source +/// address and re-keyed by serial once a Basic ID names it (see [RidRegistry]). +class RidAircraft { + RidAircraft(this.key); + + /// Serial when known, else `src:`. + String key; + + String? serial; + int? idType; + int? uaType; + + // Location / vector + UaStatus status = UaStatus.undeclared; + HeightReference heightReference = HeightReference.takeoff; + double? latitude; + double? longitude; + double? altitudeGeodetic; + double? altitudePressure; + double? height; + double? speedHorizontal; + double? speedVertical; + double? track; + double? horizontalAccuracy; + + // System / operator + double? operatorLatitude; + double? operatorLongitude; + double? operatorAltitude; + String? euCategory; + String? euClass; + String? operatorId; + String? selfId; + + // Reception + final Set transports = {}; + final Set sources = {}; + int? rssi; + DateTime lastSeen = DateTime.fromMillisecondsSinceEpoch(0); + DateTime? lastLocationAt; + int locationCount = 0; + + /// Location updates per second over the last few fixes (smoothed). + double locationRate = 0; + + bool get hasFix => latitude != null && longitude != null; + + bool get isAirborne => + status == UaStatus.airborne || + status == UaStatus.emergency || + // Some aircraft leave the status undeclared; fall back on motion. + (status == UaStatus.undeclared && (height ?? 0) > 1.0 && (speedHorizontal ?? 0) > 0.5); + + String get label => serial ?? key; + + String get uaTypeLabel => switch (uaType) { + 1 => 'Aeroplane', + 2 => 'Multirotor', + 3 => 'Gyroplane', + 4 => 'Hybrid lift', + 5 => 'Ornithopter', + 6 => 'Glider', + 7 => 'Kite', + 8 => 'Free balloon', + 9 => 'Captive balloon', + 10 => 'Airship', + 11 => 'Parachute', + 12 => 'Rocket', + 13 => 'Tethered', + 14 => 'Ground obstacle', + _ => 'Unknown type', + }; + + void apply(OdidMessage m, DateTime at) { + switch (m) { + case OdidBasicId(): + // A CAA registration or session id can ride alongside the serial; the + // serial is the stable identity, so it wins when both are broadcast. + if (m.uasId.isNotEmpty && (serial == null || m.isSerial)) { + serial = m.uasId; + idType = m.idType; + } + uaType = m.uaType; + case OdidLocation(): + status = m.status; + heightReference = m.heightReference; + if (m.hasFix) { + latitude = m.latitude; + longitude = m.longitude; + _countLocation(at); + } + altitudeGeodetic = m.altitudeGeodetic ?? altitudeGeodetic; + altitudePressure = m.altitudePressure ?? altitudePressure; + height = m.height ?? height; + speedHorizontal = m.speedHorizontal; + speedVertical = m.speedVertical; + track = m.track; + horizontalAccuracy = m.horizontalAccuracy; + case OdidSystem(): + operatorLatitude = m.operatorLatitude ?? operatorLatitude; + operatorLongitude = m.operatorLongitude ?? operatorLongitude; + operatorAltitude = m.operatorAltitude ?? operatorAltitude; + euCategory = m.euCategory ?? euCategory; + euClass = m.euClass ?? euClass; + case OdidOperatorId(): + if (m.operatorId.isNotEmpty) operatorId = m.operatorId; + case OdidSelfId(): + if (m.description.isNotEmpty) selfId = m.description; + case OdidAuth(): + break; + } + } + + void _countLocation(DateTime at) { + final DateTime? prev = lastLocationAt; + lastLocationAt = at; + locationCount++; + if (prev == null) return; + final int ms = at.difference(prev).inMilliseconds; + // Several transports can deliver the same fix within milliseconds; those + // are duplicates, not a faster rate. + if (ms < 50) return; + final double instant = 1000 / ms; + locationRate = locationRate == 0 ? instant : locationRate * 0.8 + instant * 0.2; + } + + /// Folds an entry that turned out to be the same aircraft into this one. + void absorb(RidAircraft other) { + transports.addAll(other.transports); + sources.addAll(other.sources); + if (other.lastSeen.isAfter(lastSeen)) { + lastSeen = other.lastSeen; + rssi = other.rssi; + } + final DateTime? theirs = other.lastLocationAt; + if (theirs != null && (lastLocationAt == null || theirs.isAfter(lastLocationAt!))) { + status = other.status; + heightReference = other.heightReference; + latitude = other.latitude; + longitude = other.longitude; + altitudeGeodetic = other.altitudeGeodetic; + altitudePressure = other.altitudePressure; + height = other.height; + speedHorizontal = other.speedHorizontal; + speedVertical = other.speedVertical; + track = other.track; + horizontalAccuracy = other.horizontalAccuracy; + lastLocationAt = theirs; + } + locationCount += other.locationCount; + operatorLatitude ??= other.operatorLatitude; + operatorLongitude ??= other.operatorLongitude; + operatorAltitude ??= other.operatorAltitude; + euCategory ??= other.euCategory; + euClass ??= other.euClass; + operatorId ??= other.operatorId; + selfId ??= other.selfId; + uaType ??= other.uaType; + } +} + +/// Groups decoded frames into aircraft. Pure logic (no channels), so it is +/// unit-tested directly. +class RidRegistry { + final Map _byKey = {}; + final Map _keyBySource = {}; + + Iterable get aircraft => _byKey.values; + + RidAircraft? bySerial(String serial) => _byKey[serial]; + + /// Applies one received frame and returns the aircraft it belongs to. + RidAircraft ingest({ + required String transport, + required String source, + required int rssi, + required DateTime at, + required List messages, + }) { + final String sourceKey = '$transport/$source'; + String key = _keyBySource[sourceKey] ?? 'src:$sourceKey'; + RidAircraft a = _byKey[key] ??= RidAircraft(key); + + // A Basic ID ties this radio source to a serial: re-key and merge. + for (final OdidMessage m in messages) { + if (m is OdidBasicId && m.uasId.isNotEmpty && (m.isSerial || a.serial == null)) { + if (key != m.uasId) { + _byKey.remove(key); + final RidAircraft? existing = _byKey[m.uasId]; + if (existing != null) { + existing.absorb(a); + a = existing; + } else { + a.key = m.uasId; + _byKey[m.uasId] = a; + } + for (final String s in a.sources) { + _keyBySource[s] = m.uasId; + } + key = m.uasId; + } + break; + } + } + + _keyBySource[sourceKey] = key; + a.sources.add(sourceKey); + a.transports.add(transport); + if (rssi != 0) a.rssi = rssi; + a.lastSeen = at; + for (final OdidMessage m in messages) { + a.apply(m, at); + } + return a; + } + + /// Drops aircraft not heard for [maxAge], keeping [keep] (the paired one). + void prune(DateTime now, Duration maxAge, {String? keep}) { + final List stale = _byKey.entries + .where((MapEntry e) => e.key != keep && now.difference(e.value.lastSeen) > maxAge) + .map((MapEntry e) => e.key) + .toList(); + for (final String k in stale) { + _byKey.remove(k); + _keyBySource.removeWhere((String _, String v) => v == k); + } + } +} diff --git a/Fly Agent App/lib/rid/rid_service.dart b/Fly Agent App/lib/rid/rid_service.dart new file mode 100644 index 0000000..7ac32a9 --- /dev/null +++ b/Fly Agent App/lib/rid/rid_service.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'odid_decoder.dart'; +import 'rid_aircraft.dart'; + +/// State of one scanner as reported by the native side. +class TransportStatus { + const TransportStatus(this.state, this.detail); + final String state; // scanning | stopped | unavailable | denied | failed + final String detail; +} + +/// The paired aircraft: its broadcast serial and the PilotVault drone it is. +class RidPairing { + const RidPairing({required this.serial, required this.droneId, required this.droneName}); + final String serial; + final String droneId; + final String droneName; + + Map toJson() => {'serial': serial, 'droneId': droneId, 'droneName': droneName}; + + static RidPairing? fromJson(Object? j) { + if (j is! Map) return null; + final String serial = (j['serial'] ?? '').toString(); + if (serial.isEmpty) return null; + return RidPairing( + serial: serial, + droneId: (j['droneId'] ?? '').toString(), + droneName: (j['droneName'] ?? '').toString(), + ); + } +} + +/// Remote ID receiver: drives the native scanners, decodes frames and keeps +/// the list of nearby aircraft plus the paired one. +/// +/// Other operators' broadcasts are public but are still personal data, so they +/// only ever live in memory on this phone; nothing but the paired aircraft is +/// handed to the uplink or the flight session. +class RidService extends ChangeNotifier { + RidService() { + _events.receiveBroadcastStream().listen(_onEvent, onError: (Object _) {}); + _pruneTimer = Timer.periodic(const Duration(seconds: 5), (_) => _prune()); + } + + static const MethodChannel _methods = MethodChannel('pilotvault/rid'); + static const EventChannel _events = EventChannel('pilotvault/rid/events'); + + /// Aircraft not heard for this long leave the nearby list. + static const Duration staleAfter = Duration(minutes: 2); + + final RidRegistry _registry = RidRegistry(); + final Map transports = {}; + Map capabilities = {}; + bool running = false; + int framesReceived = 0; + Timer? _pruneTimer; + DateTime _lastNotify = DateTime.fromMillisecondsSinceEpoch(0); + bool _notifyQueued = false; + + RidPairing? _pairing; + RidPairing? get pairing => _pairing; + + final StreamController _pairedUpdates = StreamController.broadcast(); + + /// Every frame that updated the paired aircraft's position. + Stream get pairedLocations => _pairedUpdates.stream; + + RidAircraft? get paired { + final String? s = _pairing?.serial; + return s == null ? null : _registry.bySerial(s); + } + + List get aircraft { + final List list = _registry.aircraft.toList() + ..sort((RidAircraft a, RidAircraft b) => b.lastSeen.compareTo(a.lastSeen)); + return list; + } + + Future init() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? raw = prefs.getString('rid_pairing'); + if (raw != null) { + try { + _pairing = RidPairing.fromJson(jsonDecode(raw)); + } catch (_) {} + } + try { + capabilities = ((await _methods.invokeMethod>('capabilities')) ?? {}) + .map((Object? k, Object? v) => MapEntry(k.toString(), v == true)); + running = await _methods.invokeMethod('isRunning') ?? false; + } on MissingPluginException { + // Widget tests and non-Android hosts have no native side. + } + notifyListeners(); + } + + /// Requests the runtime grants the scanners need. Returns the ones refused. + Future> requestPermissions() async { + final Map result = await [ + Permission.locationWhenInUse, + Permission.bluetoothScan, + Permission.nearbyWifiDevices, + Permission.notification, + ].request(); + return result.entries + .where((MapEntry e) => + e.value.isDenied || e.value.isPermanentlyDenied) + .map((MapEntry e) => _permissionLabel(e.key)) + .toList(); + } + + Future start() async { + await requestPermissions(); + await _methods.invokeMethod('start'); + running = true; + notifyListeners(); + } + + Future stop() async { + await _methods.invokeMethod('stop'); + running = false; + transports.clear(); + notifyListeners(); + } + + Future isIgnoringBatteryOptimizations() async => + await _methods.invokeMethod('isIgnoringBatteryOptimizations') ?? false; + + Future requestIgnoreBatteryOptimizations() => _methods.invokeMethod('requestIgnoreBatteryOptimizations'); + + /// Brings DJI Fly to the front. False when it is not installed. + Future openDjiFly() async => await _methods.invokeMethod('openDjiFly') ?? false; + + Future pair(RidPairing p) async { + _pairing = p; + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString('rid_pairing', jsonEncode(p.toJson())); + notifyListeners(); + } + + Future unpair() async { + _pairing = null; + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove('rid_pairing'); + notifyListeners(); + } + + void _onEvent(dynamic event) { + if (event is! Map) return; + switch (event['kind']) { + case 'status': + transports[event['transport'].toString()] = + TransportStatus(event['state'].toString(), (event['detail'] ?? '').toString()); + _notifySoon(); + case 'frame': + final Object? payload = event['payload']; + if (payload is! Uint8List) return; + framesReceived++; + final List messages = decodeOdid(payload); + if (messages.isEmpty) return; + final DateTime at = DateTime.fromMillisecondsSinceEpoch((event['ts'] as num?)?.toInt() ?? 0); + final RidAircraft a = _registry.ingest( + transport: event['transport'].toString(), + source: event['source'].toString(), + rssi: (event['rssi'] as num?)?.toInt() ?? 0, + at: at, + messages: messages, + ); + if (a.serial != null && a.serial == _pairing?.serial && messages.any((OdidMessage m) => m is OdidLocation)) { + _pairedUpdates.add(a); + } + _notifySoon(); + } + } + + /// Several transports can deliver tens of frames a second; the UI only needs + /// a few repaints. + void _notifySoon() { + if (_notifyQueued) return; + final Duration since = DateTime.now().difference(_lastNotify); + const Duration interval = Duration(milliseconds: 250); + if (since >= interval) { + _lastNotify = DateTime.now(); + notifyListeners(); + return; + } + _notifyQueued = true; + Timer(interval - since, () { + _notifyQueued = false; + _lastNotify = DateTime.now(); + notifyListeners(); + }); + } + + void _prune() { + _registry.prune(DateTime.now(), staleAfter, keep: _pairing?.serial); + notifyListeners(); + } + + /// Test hook: feed a frame as if it came from the native side. + @visibleForTesting + void debugIngest(Map event) => _onEvent(event); + + @override + void dispose() { + _pruneTimer?.cancel(); + _pairedUpdates.close(); + super.dispose(); + } + + static String _permissionLabel(Permission p) { + if (p == Permission.bluetoothScan) return 'Nearby devices (Bluetooth)'; + if (p == Permission.nearbyWifiDevices) return 'Nearby Wi-Fi devices'; + if (p == Permission.notification) return 'Notifications'; + return 'Location'; + } +} diff --git a/Fly Agent App/lib/services.dart b/Fly Agent App/lib/services.dart new file mode 100644 index 0000000..569f573 --- /dev/null +++ b/Fly Agent App/lib/services.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; + +import 'api/api.dart'; +import 'pilot_location.dart'; +import 'rid/rid_service.dart'; +import 'session/session_controller.dart'; +import 'uplink/uplink.dart'; + +/// The app's long-lived services, created once in `main()` and reached from +/// any widget with `Services.of(context)`. +class Services extends InheritedWidget { + const Services({ + super.key, + required this.api, + required this.rid, + required this.pilot, + required this.uplink, + required this.sessions, + required super.child, + }); + + final Api api; + final RidService rid; + final PilotLocation pilot; + final Uplink uplink; + final SessionController sessions; + + static Services of(BuildContext context) => context.dependOnInheritedWidgetOfExactType()!; + + @override + bool updateShouldNotify(Services oldWidget) => false; +} diff --git a/Fly Agent App/lib/session/flight_session.dart b/Fly Agent App/lib/session/flight_session.dart new file mode 100644 index 0000000..0831cd3 --- /dev/null +++ b/Fly Agent App/lib/session/flight_session.dart @@ -0,0 +1,322 @@ +import 'dart:convert'; +import 'dart:math' as math; + +/// One flight from briefing to logbook entry. +/// +/// idle → armed (briefing done, waiting for take-off) +/// → active (aircraft airborne, track recording) +/// → landed (review before filing; can resume) +/// → idle (filed, queued, or discarded) +/// +/// Take-off and landing are detected from the paired aircraft's Remote ID, and +/// the pilot can always start or stop by hand. All of it is plain data so the +/// open session survives the app being killed mid-flight. +enum SessionPhase { idle, armed, active, landed } + +class TrackPoint { + const TrackPoint(this.at, this.lat, this.lon, this.height); + final DateTime at; + final double lat; + final double lon; + + /// Metres above take-off; null when the broadcast had none. + final double? height; + + List toJson() => [at.millisecondsSinceEpoch, lat, lon, height]; + + static TrackPoint fromJson(List j) => TrackPoint( + DateTime.fromMillisecondsSinceEpoch((j[0] as num).toInt()), + (j[1] as num).toDouble(), + (j[2] as num).toDouble(), + (j[3] as num?)?.toDouble(), + ); +} + +class SessionAlert { + const SessionAlert(this.at, this.code, this.message); + final DateTime at; + final String code; + final String message; + + Map toJson() => {'at': at.millisecondsSinceEpoch, 'code': code, 'message': message}; + + static SessionAlert fromJson(Map j) => + SessionAlert(DateTime.fromMillisecondsSinceEpoch((j['at'] as num).toInt()), '${j['code']}', '${j['message']}'); +} + +/// What one Remote ID fix says, reduced to what the session needs. +class FixSample { + const FixSample({ + required this.at, + required this.airborne, + this.lat, + this.lon, + this.height, + this.speed, + }); + final DateTime at; + final bool airborne; + final double? lat; + final double? lon; + final double? height; + final double? speed; +} + +class FlightSession { + FlightSession(); + + /// EU Open category ceiling; above it the session raises an alert. + static const double heightLimit = 120; + + /// How long the aircraft must look landed before the session stops itself. + static const Duration landedHold = Duration(seconds: 15); + + /// Remote ID silence that ends an active flight (aircraft switched off, or + /// out of range: either way the pilot reviews it). + static const Duration silenceStop = Duration(seconds: 60); + + SessionPhase phase = SessionPhase.idle; + + // Briefing + String droneId = ''; + String droneName = ''; + String ridSerial = ''; + String purpose = 'commercial'; + String category = 'open'; + String areaRoute = ''; + String weather = ''; + String notes = ''; + String incidents = ''; + + // Flight + DateTime? armedAt; + DateTime? startedAt; + DateTime? endedAt; + bool autoStarted = false; + bool autoStopped = false; + final List track = []; + final List alerts = []; + double maxHeight = 0; + double distance = 0; + DateTime? lastFixAt; + DateTime? _landedSince; + + /// Set by the pilot on review when Remote ID had no height (or a wrong one). + double? maxHeightOverride; + + double get effectiveMaxHeight => maxHeightOverride ?? maxHeight; + + Duration elapsed(DateTime now) { + final DateTime? s = startedAt; + if (s == null) return Duration.zero; + return (endedAt ?? now).difference(s); + } + + void arm(DateTime now) { + phase = SessionPhase.armed; + armedAt = now; + } + + void start(DateTime now, {bool auto = false}) { + phase = SessionPhase.active; + startedAt ??= now; + endedAt = null; + autoStarted = auto; + _landedSince = null; + } + + void stop(DateTime at, {bool auto = false}) { + phase = SessionPhase.landed; + endedAt = at; + autoStopped = auto; + } + + /// Back to recording after a stop that was premature (a hover close to the + /// ground, a Remote ID gap). + void resume() { + phase = SessionPhase.active; + endedAt = null; + _landedSince = null; + } + + /// Feeds one fix of the paired aircraft. Returns true when the phase changed. + bool onFix(FixSample f) { + lastFixAt = f.at; + switch (phase) { + case SessionPhase.idle: + case SessionPhase.landed: + return false; + case SessionPhase.armed: + if (!f.airborne) return false; + start(f.at, auto: true); + _record(f); + return true; + case SessionPhase.active: + _record(f); + if (f.airborne) { + _landedSince = null; + return false; + } + _landedSince ??= f.at; + if (f.at.difference(_landedSince!) >= landedHold) { + // The flight ended when it touched down, not when the hold expired. + stop(_landedSince!, auto: true); + return true; + } + return false; + } + } + + /// Called periodically: ends an active flight whose Remote ID went silent. + bool onTick(DateTime now) { + if (phase != SessionPhase.active) return false; + final DateTime? last = lastFixAt ?? startedAt; + if (last == null || now.difference(last) < silenceStop) return false; + _alertOnce(now, 'rid_lost', 'Remote ID lost for ${silenceStop.inSeconds} s; flight stopped at last contact'); + stop(last, auto: true); + return true; + } + + void _record(FixSample f) { + final double? h = f.height; + if (h != null && h > maxHeight) maxHeight = h; + if (h != null && h > heightLimit) { + _alertOnce(f.at, 'height', 'Above ${heightLimit.round()} m (${h.round()} m)'); + } + if (f.lat == null || f.lon == null) return; + final TrackPoint p = TrackPoint(f.at, f.lat!, f.lon!, h); + if (track.isNotEmpty) { + final TrackPoint prev = track.last; + final double d = haversine(prev.lat, prev.lon, p.lat, p.lon); + // Ignore sub-metre jitter so a hover doesn't add distance. + if (d < 1 && p.at.difference(prev.at) < const Duration(seconds: 10)) return; + distance += d; + } + track.add(p); + } + + void _alertOnce(DateTime at, String code, String message) { + if (alerts.any((SessionAlert a) => a.code == code)) return; + alerts.add(SessionAlert(at, code, message)); + } + + /// The `POST /api/flights` body (API Server `flightInput`). + /// + /// Logged on the **manual** path: Remote ID gives us times, track and height, + /// but this is not the aircraft's own flight data recorder, so the § 5 manual + /// fields are all filled in here rather than claiming an automatic log. + Map toFlightInput() { + final DateTime s = (startedAt ?? armedAt ?? DateTime.now()).toLocal(); + final DateTime e = (endedAt ?? DateTime.now()).toLocal(); + final List alertLines = alerts.map((SessionAlert a) => '${_hhmm(a.at.toLocal())} ${a.message}').toList(); + final String incidentText = [ + if (incidents.trim().isNotEmpty) incidents.trim(), + ...alertLines, + ].join('\n'); + final String source = 'Logged by PilotVault Fly Agent from Remote ID' + '${ridSerial.isNotEmpty ? ' (serial $ridSerial)' : ''}' + '${track.isNotEmpty ? ', ${track.length} fixes, ${(distance / 1000).toStringAsFixed(2)} km' : ''}.'; + return { + 'operationDate': _date(s), + 'startTime': _hhmm(s), + 'endTime': _hhmm(e), + 'drone': droneId, + 'areaRoute': areaRoute.trim().isNotEmpty ? areaRoute.trim() : _autoArea(), + if (track.length >= 2) 'routeGeojson': routeGeoJson(), + 'maxAltitudeAgl': double.parse(effectiveMaxHeight.toStringAsFixed(1)), + 'category': category, + 'purpose': purpose, + 'loggingPath': 'manual', + 'weather': weather, + 'incidents': incidentText, + 'notes': [if (notes.trim().isNotEmpty) notes.trim(), source].join('\n'), + }; + } + + Map routeGeoJson() => { + 'type': 'LineString', + 'coordinates': track + .map((TrackPoint p) => [ + double.parse(p.lon.toStringAsFixed(7)), + double.parse(p.lat.toStringAsFixed(7)), + if (p.height != null) double.parse(p.height!.toStringAsFixed(1)), + ]) + .toList(), + }; + + String _autoArea() { + if (track.isEmpty) return ''; + final TrackPoint p = track.first; + return 'Around ${p.lat.toStringAsFixed(5)}, ${p.lon.toStringAsFixed(5)} (Remote ID track)'; + } + + // ── persistence ──────────────────────────────────────────────────────── + + String encode() => jsonEncode({ + 'phase': phase.name, + 'droneId': droneId, + 'droneName': droneName, + 'ridSerial': ridSerial, + 'purpose': purpose, + 'category': category, + 'areaRoute': areaRoute, + 'weather': weather, + 'notes': notes, + 'incidents': incidents, + 'armedAt': armedAt?.millisecondsSinceEpoch, + 'startedAt': startedAt?.millisecondsSinceEpoch, + 'endedAt': endedAt?.millisecondsSinceEpoch, + 'lastFixAt': lastFixAt?.millisecondsSinceEpoch, + 'autoStarted': autoStarted, + 'autoStopped': autoStopped, + 'maxHeight': maxHeight, + 'maxHeightOverride': maxHeightOverride, + 'distance': distance, + 'track': track.map((TrackPoint p) => p.toJson()).toList(), + 'alerts': alerts.map((SessionAlert a) => a.toJson()).toList(), + }); + + static FlightSession decode(String raw) { + final Map j = (jsonDecode(raw) as Map).cast(); + DateTime? t(String k) => j[k] == null ? null : DateTime.fromMillisecondsSinceEpoch((j[k] as num).toInt()); + final FlightSession s = FlightSession() + ..phase = SessionPhase.values.firstWhere((SessionPhase p) => p.name == j['phase'], orElse: () => SessionPhase.idle) + ..droneId = '${j['droneId'] ?? ''}' + ..droneName = '${j['droneName'] ?? ''}' + ..ridSerial = '${j['ridSerial'] ?? ''}' + ..purpose = '${j['purpose'] ?? 'commercial'}' + ..category = '${j['category'] ?? 'open'}' + ..areaRoute = '${j['areaRoute'] ?? ''}' + ..weather = '${j['weather'] ?? ''}' + ..notes = '${j['notes'] ?? ''}' + ..incidents = '${j['incidents'] ?? ''}' + ..armedAt = t('armedAt') + ..startedAt = t('startedAt') + ..endedAt = t('endedAt') + ..lastFixAt = t('lastFixAt') + ..autoStarted = j['autoStarted'] == true + ..autoStopped = j['autoStopped'] == true + ..maxHeight = (j['maxHeight'] as num?)?.toDouble() ?? 0 + ..maxHeightOverride = (j['maxHeightOverride'] as num?)?.toDouble() + ..distance = (j['distance'] as num?)?.toDouble() ?? 0; + s.track.addAll(((j['track'] as List?) ?? const []).map((Object? e) => TrackPoint.fromJson(e! as List))); + s.alerts.addAll(((j['alerts'] as List?) ?? const []) + .map((Object? e) => SessionAlert.fromJson((e! as Map).cast()))); + return s; + } +} + +String _two(int n) => n.toString().padLeft(2, '0'); +String _date(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}'; +String _hhmm(DateTime d) => '${_two(d.hour)}:${_two(d.minute)}'; + +/// Great-circle distance in metres. +double haversine(double lat1, double lon1, double lat2, double lon2) { + const double r = 6371000; + final double p1 = lat1 * math.pi / 180; + final double p2 = lat2 * math.pi / 180; + final double dp = (lat2 - lat1) * math.pi / 180; + final double dl = (lon2 - lon1) * math.pi / 180; + final double a = math.sin(dp / 2) * math.sin(dp / 2) + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) * math.sin(dl / 2); + return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a)); +} diff --git a/Fly Agent App/lib/session/session_controller.dart b/Fly Agent App/lib/session/session_controller.dart new file mode 100644 index 0000000..180bbb7 --- /dev/null +++ b/Fly Agent App/lib/session/session_controller.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../api/api.dart'; +import '../rid/rid_aircraft.dart'; +import '../rid/rid_service.dart'; +import 'flight_session.dart'; + +/// A logbook entry that could not be filed (offline): kept until it uploads. +class PendingFlight { + const PendingFlight(this.id, this.body, this.error); + final String id; + final Map body; + final String error; + + String get summary => '${body['operationDate']} ${body['startTime']}–${body['endTime']}'; +} + +/// Owns the current [FlightSession]: feeds it the paired aircraft's fixes, +/// persists it, and files it to the logbook (queueing when offline). +class SessionController extends ChangeNotifier { + SessionController({required this.api, required this.rid}); + + final Api api; + final RidService rid; + + FlightSession session = FlightSession(); + final List pending = []; + StreamSubscription? _sub; + Timer? _tick; + DateTime _lastSave = DateTime.fromMillisecondsSinceEpoch(0); + SharedPreferences? _prefs; + bool filing = false; + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + final String? raw = _prefs!.getString('session'); + if (raw != null) { + try { + session = FlightSession.decode(raw); + } catch (_) { + session = FlightSession(); + } + } + final String? q = _prefs!.getString('pending_flights'); + if (q != null) { + try { + for (final Object? e in jsonDecode(q) as List) { + final Map m = (e! as Map).cast(); + pending.add(PendingFlight('${m['id']}', (m['body'] as Map).cast(), '${m['error'] ?? ''}')); + } + } catch (_) {} + } + _sub = rid.pairedLocations.listen(_onFix); + _tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (session.onTick(DateTime.now())) _save(force: true); + // Elapsed time on screen. + if (session.phase == SessionPhase.active || session.phase == SessionPhase.armed) notifyListeners(); + }); + notifyListeners(); + } + + void _onFix(RidAircraft a) { + if (session.ridSerial.isNotEmpty && a.serial != session.ridSerial) return; + final bool changed = session.onFix(FixSample( + at: a.lastLocationAt ?? DateTime.now(), + airborne: a.isAirborne, + lat: a.latitude, + lon: a.longitude, + height: a.height, + speed: a.speedHorizontal, + )); + _save(force: changed); + if (changed) notifyListeners(); + } + + /// Briefing done: wait for take-off. + void arm({ + required String droneId, + required String droneName, + required String purpose, + required String category, + required String areaRoute, + required String weather, + }) { + session = FlightSession() + ..droneId = droneId + ..droneName = droneName + ..ridSerial = rid.pairing?.serial ?? '' + ..purpose = purpose + ..category = category + ..areaRoute = areaRoute + ..weather = weather; + session.arm(DateTime.now()); + _save(force: true); + notifyListeners(); + } + + void startNow() { + session.start(DateTime.now()); + _save(force: true); + notifyListeners(); + } + + void stopNow() { + session.stop(DateTime.now()); + _save(force: true); + notifyListeners(); + } + + void resume() { + session.resume(); + _save(force: true); + notifyListeners(); + } + + void discard() { + session = FlightSession(); + _save(force: true); + notifyListeners(); + } + + /// Saves review edits made on the landed screen. + void edited() { + _save(force: true); + notifyListeners(); + } + + /// Files the landed session. Returns null on success, else a message; a + /// network failure queues the entry instead of losing it. + Future file() async { + final Map body = session.toFlightInput(); + filing = true; + notifyListeners(); + try { + await api.createFlight(body); + session = FlightSession(); + return null; + } on ApiException catch (e) { + if (!e.isNetwork) return e.message; // the pilot has to fix the entry + pending.add(PendingFlight(DateTime.now().millisecondsSinceEpoch.toString(), body, e.message)); + _savePending(); + session = FlightSession(); + return 'Offline: the entry is queued and will be filed when the server is reachable.'; + } finally { + filing = false; + _save(force: true); + notifyListeners(); + } + } + + /// Retries queued entries. Returns how many were filed. + Future retryPending() async { + int ok = 0; + for (final PendingFlight p in List.of(pending)) { + try { + await api.createFlight(p.body); + pending.remove(p); + ok++; + } on ApiException catch (e) { + final int i = pending.indexOf(p); + if (i >= 0) pending[i] = PendingFlight(p.id, p.body, e.message); + if (e.isNetwork) break; + } + } + _savePending(); + notifyListeners(); + return ok; + } + + void dropPending(PendingFlight p) { + pending.remove(p); + _savePending(); + notifyListeners(); + } + + void _save({bool force = false}) { + final DateTime now = DateTime.now(); + // A fix a second would otherwise rewrite the whole track every second. + if (!force && now.difference(_lastSave) < const Duration(seconds: 10)) return; + _lastSave = now; + if (session.phase == SessionPhase.idle) { + _prefs?.remove('session'); + } else { + _prefs?.setString('session', session.encode()); + } + } + + void _savePending() { + _prefs?.setString( + 'pending_flights', + jsonEncode(pending + .map((PendingFlight p) => {'id': p.id, 'body': p.body, 'error': p.error}) + .toList()), + ); + } + + @override + void dispose() { + _save(force: true); + _sub?.cancel(); + _tick?.cancel(); + super.dispose(); + } +} diff --git a/Fly Agent App/lib/theme.dart b/Fly Agent App/lib/theme.dart new file mode 100644 index 0000000..f33e02b --- /dev/null +++ b/Fly Agent App/lib/theme.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +/// PilotVault mobile tokens — the same values as the Fly App's `PV` (Signal +/// Blue over Vault Navy, Space Grotesk for chrome, Space Mono for data). +class PV { + PV._(); + + static const String fontSans = 'Space Grotesk'; + static const String fontMono = 'Space Mono'; + + static const Color navy = Color(0xFF0F1E3D); + static const Color accent = Color(0xFF3D7BF0); + + static const Color ready = Color(0xFF1F8A5B); + static const Color caution = Color(0xFFD9852B); + static const Color warning = Color(0xFFD64545); + + static const Color surface0 = Color(0xFFEEF0F3); + static const Color surface1 = Color(0xFFFFFFFF); + static const Color surface2 = Color(0xFFF6F7F9); + + static const Color ink = navy; + static const Color inkSecondary = Color(0xFF5A6B85); + static const Color inkMuted = Color(0xFF97A1B0); + static const Color line = Color(0xFFDCE0E7); + + static const double radius = 14; + + static const TextStyle telemetry = TextStyle( + fontFamily: fontMono, + fontSize: 26, + fontWeight: FontWeight.w700, + height: 1.0, + color: ink, + fontFeatures: [FontFeature.tabularFigures()], + ); + static const TextStyle label = TextStyle( + fontFamily: fontMono, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + color: inkMuted, + ); + static const TextStyle mono = TextStyle( + fontFamily: fontMono, + fontSize: 13, + fontWeight: FontWeight.w700, + color: ink, + fontFeatures: [FontFeature.tabularFigures()], + ); + static const TextStyle caption = TextStyle(fontFamily: fontSans, fontSize: 12, color: inkSecondary); + + static ThemeData theme() { + final ColorScheme scheme = ColorScheme.fromSeed( + seedColor: accent, + primary: accent, + surface: surface1, + error: warning, + ); + return ThemeData( + colorScheme: scheme, + fontFamily: fontSans, + scaffoldBackgroundColor: surface0, + appBarTheme: const AppBarTheme( + backgroundColor: surface1, + foregroundColor: ink, + elevation: 0, + scrolledUnderElevation: 0.5, + titleTextStyle: TextStyle(fontFamily: fontSans, fontSize: 18, fontWeight: FontWeight.w600, color: ink), + ), + cardTheme: CardThemeData( + color: surface1, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + side: const BorderSide(color: line), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surface2, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: line)), + enabledBorder: + OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: line)), + ), + navigationBarTheme: const NavigationBarThemeData(backgroundColor: surface1, indicatorColor: Color(0x263D7BF0)), + ); + } +} + +/// Mono ALL-CAPS section label. +class SectionLabel extends StatelessWidget { + const SectionLabel(this.text, {super.key}); + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Text(text.toUpperCase(), style: PV.label), + ); +} + +class StatusDot extends StatelessWidget { + const StatusDot(this.color, {super.key, this.size = 8}); + final Color color; + final double size; + + @override + Widget build(BuildContext context) => Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); +} diff --git a/Fly Agent App/lib/ui/briefing_page.dart b/Fly Agent App/lib/ui/briefing_page.dart new file mode 100644 index 0000000..93a5792 --- /dev/null +++ b/Fly Agent App/lib/ui/briefing_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../api/api.dart'; +import '../services.dart'; +import '../theme.dart'; +import 'format.dart'; + +/// Pre-flight: which drone, why, where, the weather, and PilotVault's own +/// checklist (separate from DJI Fly's). Arming hands over to take-off detection. +class BriefingPage extends StatefulWidget { + const BriefingPage({super.key}); + + @override + State createState() => _BriefingPageState(); +} + +class _BriefingPageState extends State { + static const List checklist = [ + 'Remote ID is broadcasting (aircraft shows under Nearby)', + 'Operator registration number is displayed on the aircraft', + 'Airspace and local restrictions checked for this area', + 'Batteries charged and seated, propellers undamaged', + 'Take-off area is clear of uninvolved people', + ]; + + Future>? _drones; + String? _droneId; + String _purpose = 'commercial'; + String _category = 'open'; + final TextEditingController _area = TextEditingController(); + String? _weather; + String? _weatherNote; + bool _weatherLoading = false; + final Set _checked = {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_drones != null) return; + final Services s = Services.of(context); + _droneId = s.rid.pairing?.droneId; + _drones = s.api.drones(); + _loadWeather(); + } + + @override + void dispose() { + _area.dispose(); + super.dispose(); + } + + Future _loadWeather() async { + final Services s = Services.of(context); + final Position? p = s.pilot.position; + if (p == null) { + setState(() => _weatherNote = 'Waiting for the phone\'s GPS to fetch the weather.'); + return; + } + setState(() => _weatherLoading = true); + try { + final String? w = await s.api.weatherSummary(p.latitude, p.longitude); + if (!mounted) return; + setState(() { + _weather = w; + _weatherNote = w == null ? 'OpenWeather is not enabled for your account.' : null; + }); + } on ApiException catch (e) { + if (mounted) setState(() => _weatherNote = e.message); + } finally { + if (mounted) setState(() => _weatherLoading = false); + } + } + + void _arm(List drones) { + final Drone d = drones.firstWhere((Drone x) => x.id == _droneId); + Services.of(context).sessions.arm( + droneId: d.id, + droneName: d.displayName, + purpose: _purpose, + category: _category, + areaRoute: _area.text, + weather: _weather ?? '', + ); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Pre-flight briefing')), + body: FutureBuilder>( + future: _drones, + builder: (BuildContext context, AsyncSnapshot> snap) { + if (snap.connectionState != ConnectionState.done) return const Center(child: CircularProgressIndicator()); + if (snap.hasError) { + return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text('Could not load drones: ${snap.error}'))); + } + final List drones = snap.data!; + if (_droneId != null && !drones.any((Drone d) => d.id == _droneId)) _droneId = null; + final bool ready = _droneId != null && _checked.length == checklist.length; + final bool pairedMismatch = s.rid.pairing != null && _droneId != null && _droneId != s.rid.pairing!.droneId; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionLabel('Drone'), + DropdownButtonFormField( + initialValue: _droneId, + isExpanded: true, + hint: const Text('Choose a drone'), + items: >[ + for (final Drone d in drones) + DropdownMenuItem(value: d.id, child: Text(d.displayName, overflow: TextOverflow.ellipsis)), + ], + onChanged: (String? v) => setState(() => _droneId = v), + ), + if (s.rid.pairing == null) + const Padding( + padding: EdgeInsets.only(top: 6), + child: Text('No aircraft paired: the session can only be started and stopped by hand, ' + 'with no track. Pair one under Nearby.', style: PV.caption), + ) + else if (pairedMismatch) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('The paired Remote ID (${s.rid.pairing!.droneName}) is a different drone; ' + 'its track will be logged against this one.', style: const TextStyle(color: PV.caution, fontSize: 12)), + ), + const SectionLabel('Operation'), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _purpose, + decoration: const InputDecoration(labelText: 'Purpose'), + items: purposeLabels.entries + .map((MapEntry e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (String? v) => setState(() => _purpose = v ?? _purpose), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: _category, + decoration: const InputDecoration(labelText: 'Category'), + items: categoryLabels.entries + .map((MapEntry e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (String? v) => setState(() => _category = v ?? _category), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _area, + decoration: const InputDecoration( + labelText: 'Area or route', + helperText: 'Leave blank to describe it from the Remote ID track', + ), + ), + const SectionLabel('Weather'), + Card( + child: ListTile( + leading: const Icon(Icons.air), + title: Text(_weather ?? (_weatherLoading ? 'Loading…' : 'No weather')), + subtitle: _weatherNote != null ? Text(_weatherNote!) : null, + trailing: IconButton(icon: const Icon(Icons.refresh), onPressed: _loadWeather), + ), + ), + const SectionLabel('Checklist'), + Card( + child: Column( + children: [ + for (int i = 0; i < checklist.length; i++) + CheckboxListTile( + value: _checked.contains(i), + title: Text(checklist[i], style: const TextStyle(fontSize: 14)), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (bool? v) => setState(() => v == true ? _checked.add(i) : _checked.remove(i)), + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: const Icon(Icons.flight_takeoff), + label: const Text('Ready: wait for take-off'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)), + onPressed: ready ? () => _arm(drones) : null, + ), + const SizedBox(height: 24), + ], + ); + }, + ), + ); + } +} diff --git a/Fly Agent App/lib/ui/flight_tab.dart b/Fly Agent App/lib/ui/flight_tab.dart new file mode 100644 index 0000000..041d772 --- /dev/null +++ b/Fly Agent App/lib/ui/flight_tab.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../rid/rid_aircraft.dart'; +import '../services.dart'; +import '../session/flight_session.dart'; +import '../session/session_controller.dart'; +import '../theme.dart'; +import 'briefing_page.dart'; +import 'format.dart'; +import 'track_map.dart'; + +/// The flight session: idle → armed → active → landed review → filed. +class FlightTab extends StatelessWidget { + const FlightTab({super.key}); + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + return ListenableBuilder( + listenable: Listenable.merge([s.sessions, s.rid, s.pilot]), + builder: (BuildContext context, _) { + final FlightSession f = s.sessions.session; + return switch (f.phase) { + SessionPhase.idle => const _IdleView(), + SessionPhase.armed || SessionPhase.active => const _LiveView(), + SessionPhase.landed => const _ReviewView(), + }; + }, + ); + } +} + +class _IdleView extends StatelessWidget { + const _IdleView(); + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + final RidAircraft? a = s.rid.paired; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _PairedCard(aircraft: a), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.checklist), + label: const Text('Plan a flight'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)), + onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const BriefingPage())), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + icon: const Icon(Icons.open_in_new), + label: const Text('Open DJI Fly'), + style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + onPressed: () => _openDjiFly(context), + ), + if (s.sessions.pending.isNotEmpty) ...[ + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.cloud_upload_outlined, color: PV.caution), + title: Text('${s.sessions.pending.length} flight(s) waiting to be filed'), + subtitle: const Text('See Logbook to retry'), + ), + ), + ], + const SizedBox(height: 24), + const Text( + 'How it works: plan the flight here, then fly with DJI Fly. The Agent starts the session when your ' + 'paired aircraft takes off, follows it through Remote ID, and stops when it lands. You review the ' + 'entry and file it to your logbook.', + style: PV.caption, + ), + ], + ); + } +} + +class _PairedCard extends StatelessWidget { + const _PairedCard({required this.aircraft}); + final RidAircraft? aircraft; + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + final RidAircraft? a = aircraft; + final String title; + final String sub; + final Color dot; + if (s.rid.pairing == null) { + title = 'No aircraft paired'; + sub = 'Open Nearby, power on your aircraft and tap it to pair.'; + dot = PV.inkMuted; + } else if (a == null) { + title = s.rid.pairing!.droneName; + sub = 'Not heard yet · ${s.rid.pairing!.serial}'; + dot = PV.caution; + } else { + title = s.rid.pairing!.droneName; + final bool fresh = DateTime.now().difference(a.lastSeen) < const Duration(seconds: 10); + sub = '${a.status.name} · ${a.transports.map(transportLabel).join(', ')} · ${fmtAgo(a.lastSeen)}'; + dot = fresh ? PV.ready : PV.caution; + } + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + StatusDot(dot, size: 12), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + const SizedBox(height: 2), + Text(sub, style: PV.caption), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _LiveView extends StatelessWidget { + const _LiveView(); + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + final SessionController c = s.sessions; + final FlightSession f = c.session; + final RidAircraft? a = s.rid.paired; + final Position? me = s.pilot.position; + final bool active = f.phase == SessionPhase.active; + final double? distFromPilot = me != null && a != null && a.hasFix + ? Geolocator.distanceBetween(me.latitude, me.longitude, a.latitude!, a.longitude!) + : null; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + color: active ? PV.navy : PV.surface1, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(active ? 'IN FLIGHT' : 'WAITING FOR TAKE-OFF', + style: PV.label.copyWith(color: active ? const Color(0xFF7FE0B0) : PV.inkMuted)), + const SizedBox(height: 8), + Text( + active ? fmtDuration(f.elapsed(DateTime.now())) : f.droneName, + style: PV.telemetry.copyWith(color: active ? Colors.white : PV.ink, fontSize: active ? 40 : 22), + ), + const SizedBox(height: 6), + Text( + active + ? '${f.droneName} · since ${fmtClock(f.startedAt!)}${f.autoStarted ? ' (auto)' : ''}' + : 'Starts by itself when the paired aircraft is airborne.', + style: PV.caption.copyWith(color: active ? const Color(0xFFB8C4D9) : PV.inkSecondary), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + _Stat('HEIGHT', fmtMeters(a?.height)), + _Stat('DISTANCE', fmtDistance(distFromPilot)), + _Stat('SPEED', fmtSpeed(a?.speedHorizontal)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _Stat('MAX HEIGHT', fmtMeters(f.maxHeight)), + _Stat('FLOWN', fmtDistance(f.distance)), + _Stat('RID', a == null ? 'none' : fmtAgo(a.lastSeen)), + ], + ), + if (f.alerts.isNotEmpty) ...[ + const SectionLabel('Alerts'), + for (final SessionAlert al in f.alerts) + Card( + color: const Color(0xFFFCEDED), + child: ListTile( + leading: const Icon(Icons.warning_amber, color: PV.warning), + title: Text(al.message), + subtitle: Text(fmtClock(al.at)), + ), + ), + ], + const SizedBox(height: 12), + TrackMap( + track: f.track, + aircraft: a != null && a.hasFix ? LatLng(a.latitude!, a.longitude!) : null, + pilot: me != null ? LatLng(me.latitude, me.longitude) : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.open_in_new), + label: const Text('DJI Fly'), + style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + onPressed: () => _openDjiFly(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: active + ? FilledButton.icon( + icon: const Icon(Icons.flight_land), + label: const Text('Landed'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48), backgroundColor: PV.navy), + onPressed: c.stopNow, + ) + : FilledButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Start now'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)), + onPressed: c.startNow, + ), + ), + ], + ), + if (!active) + TextButton( + onPressed: () => _confirm(context, 'Cancel this session?', 'Nothing has been recorded yet.', c.discard), + child: const Text('Cancel session'), + ), + ], + ); + } +} + +class _ReviewView extends StatefulWidget { + const _ReviewView(); + + @override + State<_ReviewView> createState() => _ReviewViewState(); +} + +class _ReviewViewState extends State<_ReviewView> { + late final TextEditingController _area; + late final TextEditingController _notes; + late final TextEditingController _incidents; + late final TextEditingController _maxHeight; + bool _init = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_init) return; + _init = true; + final FlightSession f = Services.of(context).sessions.session; + _area = TextEditingController(text: f.areaRoute); + _notes = TextEditingController(text: f.notes); + _incidents = TextEditingController(text: f.incidents); + _maxHeight = TextEditingController(text: f.effectiveMaxHeight > 0 ? f.effectiveMaxHeight.toStringAsFixed(1) : ''); + } + + @override + void dispose() { + _area.dispose(); + _notes.dispose(); + _incidents.dispose(); + _maxHeight.dispose(); + super.dispose(); + } + + void _pushEdits() { + final SessionController c = Services.of(context).sessions; + final FlightSession f = c.session; + f.areaRoute = _area.text; + f.notes = _notes.text; + f.incidents = _incidents.text; + final double? h = double.tryParse(_maxHeight.text.replaceAll(',', '.')); + f.maxHeightOverride = h != null && (h - f.maxHeight).abs() > 0.05 ? h : null; + c.edited(); + } + + Future _file() async { + _pushEdits(); + final FlightSession f = Services.of(context).sessions.session; + if (f.effectiveMaxHeight <= 0) { + _snack('Enter the maximum height: the logbook requires it.'); + return; + } + if (f.areaRoute.trim().isEmpty && f.track.isEmpty) { + _snack('Describe the area flown: there is no Remote ID track to describe it from.'); + return; + } + final String? err = await Services.of(context).sessions.file(); + if (!mounted) return; + _snack(err ?? 'Filed to your logbook.'); + } + + void _snack(String m) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); + + @override + Widget build(BuildContext context) { + final SessionController c = Services.of(context).sessions; + final FlightSession f = c.session; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('REVIEW FLIGHT', style: PV.label), + const SizedBox(height: 6), + Text(f.droneName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)), + const SizedBox(height: 4), + Text( + '${f.startedAt != null ? fmtClock(f.startedAt!) : '—'} – ${f.endedAt != null ? fmtClock(f.endedAt!) : '—'}' + ' · ${fmtDuration(f.elapsed(DateTime.now()))}' + '${f.autoStopped ? ' · stopped automatically' : ''}', + style: PV.caption, + ), + const SizedBox(height: 4), + Text( + '${purposeLabels[f.purpose] ?? f.purpose} · ${categoryLabels[f.category] ?? f.category}' + ' · flown ${fmtDistance(f.distance)} · ${f.track.length} fixes', + style: PV.caption, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + TrackMap(track: f.track), + const SectionLabel('Logbook entry'), + TextField( + controller: _maxHeight, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Max height above take-off (m)', + helperText: f.maxHeight > 0 ? 'Remote ID: ${f.maxHeight.toStringAsFixed(1)} m' : 'No height from Remote ID', + ), + onEditingComplete: _pushEdits, + ), + const SizedBox(height: 12), + TextField( + controller: _area, + decoration: const InputDecoration(labelText: 'Area or route', helperText: 'Blank: described from the track'), + onEditingComplete: _pushEdits, + ), + const SizedBox(height: 12), + TextField( + controller: _incidents, + maxLines: 2, + decoration: InputDecoration( + labelText: 'Incidents', + helperText: f.alerts.isEmpty ? null : '${f.alerts.length} alert(s) are added automatically', + ), + onEditingComplete: _pushEdits, + ), + const SizedBox(height: 12), + TextField( + controller: _notes, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Notes'), + onEditingComplete: _pushEdits, + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: c.filing + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_alt), + label: const Text('File to logbook'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)), + onPressed: c.filing ? null : _file, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () { + _pushEdits(); + c.resume(); + }, + child: const Text('Still flying: resume'), + ), + ), + Expanded( + child: TextButton( + style: TextButton.styleFrom(foregroundColor: PV.warning), + onPressed: () => _confirm(context, 'Discard this flight?', 'The track and times are deleted and nothing is filed.', c.discard), + child: const Text('Discard'), + ), + ), + ], + ), + ], + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat(this.label, this.value); + final String label; + final String value; + + @override + Widget build(BuildContext context) => Expanded( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 3), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: PV.label.copyWith(fontSize: 10)), + const SizedBox(height: 6), + FittedBox(fit: BoxFit.scaleDown, child: Text(value, style: PV.telemetry.copyWith(fontSize: 18))), + ], + ), + ), + ), + ); +} + +Future _openDjiFly(BuildContext context) async { + final bool ok = await Services.of(context).rid.openDjiFly(); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('DJI Fly is not installed on this phone.'))); + } +} + +Future _confirm(BuildContext context, String title, String body, VoidCallback onYes) async { + final bool? ok = await showDialog( + context: context, + builder: (BuildContext c) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Keep')), + FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('Yes')), + ], + ), + ); + if (ok == true) onYes(); +} diff --git a/Fly Agent App/lib/ui/format.dart b/Fly Agent App/lib/ui/format.dart new file mode 100644 index 0000000..47719fa --- /dev/null +++ b/Fly Agent App/lib/ui/format.dart @@ -0,0 +1,54 @@ +// Display formatting shared by the screens. Unknown values render as "—" +// rather than 0, so a missing Remote ID field never reads as a real reading. + +String fmtMeters(double? m, {int digits = 0}) => m == null ? '—' : '${m.toStringAsFixed(digits)} m'; + +String fmtDistance(double? m) { + if (m == null) return '—'; + if (m < 1000) return '${m.round()} m'; + return '${(m / 1000).toStringAsFixed(2)} km'; +} + +String fmtSpeed(double? ms) => ms == null ? '—' : '${ms.toStringAsFixed(1)} m/s'; + +String fmtDuration(Duration d) { + final int h = d.inHours; + final String m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final String s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; +} + +String fmtAgo(DateTime t, [DateTime? now]) { + final Duration d = (now ?? DateTime.now()).difference(t); + if (d.inSeconds < 2) return 'now'; + if (d.inSeconds < 60) return '${d.inSeconds} s ago'; + if (d.inMinutes < 60) return '${d.inMinutes} min ago'; + return '${d.inHours} h ago'; +} + +String fmtClock(DateTime t) { + final DateTime l = t.toLocal(); + return '${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}'; +} + +const Map purposeLabels = { + 'commercial': 'Commercial', + 'hobby': 'Hobby', + 'research': 'Research', + 'public': 'Public service', + 'club_area': 'Club area', +}; + +const Map categoryLabels = { + 'open': 'Open', + 'specific': 'Specific', + 'certified': 'Certified', +}; + +String transportLabel(String t) => switch (t) { + 'ble' => 'Bluetooth', + 'ble5' => 'Bluetooth 5 LR', + 'nan' => 'Wi-Fi NaN', + 'beacon' => 'Wi-Fi Beacon', + _ => t, + }; diff --git a/Fly Agent App/lib/ui/logbook_tab.dart b/Fly Agent App/lib/ui/logbook_tab.dart new file mode 100644 index 0000000..40ee5e4 --- /dev/null +++ b/Fly Agent App/lib/ui/logbook_tab.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../api/api.dart'; +import '../services.dart'; +import '../session/session_controller.dart'; +import '../theme.dart'; +import 'format.dart'; + +/// Recent logbook entries from PilotVault, plus entries still queued on the +/// phone because they were filed offline. +class LogbookTab extends StatefulWidget { + const LogbookTab({super.key}); + + @override + State createState() => _LogbookTabState(); +} + +class _LogbookTabState extends State { + Future>? _flights; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _flights ??= Services.of(context).api.flights(); + } + + Future _refresh() async { + final SessionController c = Services.of(context).sessions; + if (c.pending.isNotEmpty) await c.retryPending(); + if (!mounted) return; + final Future> f = Services.of(context).api.flights(); + setState(() => _flights = f); + await f.catchError((Object _) => []); + } + + @override + Widget build(BuildContext context) { + final SessionController c = Services.of(context).sessions; + return RefreshIndicator( + onRefresh: _refresh, + child: ListenableBuilder( + listenable: c, + builder: (BuildContext context, _) => ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (c.pending.isNotEmpty) ...[ + Row( + children: [ + const Expanded(child: SectionLabel('Waiting to be filed')), + TextButton( + onPressed: () async { + final int n = await c.retryPending(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Filed $n entr${n == 1 ? 'y' : 'ies'}.'))); + } + if (n > 0) _refresh(); + }, + child: const Text('Retry'), + ), + ], + ), + for (final PendingFlight p in c.pending) + Card( + child: ListTile( + leading: const Icon(Icons.cloud_off, color: PV.caution), + title: Text(p.summary), + subtitle: Text(p.error, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Drop', + onPressed: () => c.dropPending(p), + ), + ), + ), + ], + const SectionLabel('Recent flights'), + FutureBuilder>( + future: _flights, + builder: (BuildContext context, AsyncSnapshot> snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())); + } + if (snap.hasError) return Text('Could not load the logbook: ${snap.error}', style: PV.caption); + final List flights = snap.data!; + if (flights.isEmpty) return const Text('No flights logged yet.', style: PV.caption); + return Column( + children: [ + for (final Flight f in flights.take(50)) ...[ + Card( + child: ListTile( + title: Text(f.droneName.isNotEmpty ? f.droneName : 'Unknown drone'), + subtitle: Text( + '${f.operationDate} · ${f.startTime}–${f.endTime} · max ${f.maxAltitudeAgl.toStringAsFixed(0)} m' + '${f.purpose.isNotEmpty ? ' · ${purposeLabels[f.purpose] ?? f.purpose}' : ''}', + ), + trailing: f.redFlags.isEmpty + ? const Icon(Icons.check_circle_outline, color: PV.ready) + : Tooltip( + message: f.redFlags.join('\n'), + child: const Icon(Icons.flag_outlined, color: PV.caution), + ), + ), + ), + const SizedBox(height: 6), + ], + ], + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/Fly Agent App/lib/ui/login_page.dart b/Fly Agent App/lib/ui/login_page.dart new file mode 100644 index 0000000..172adc7 --- /dev/null +++ b/Fly Agent App/lib/ui/login_page.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import '../api/api.dart'; +import '../services.dart'; +import '../theme.dart'; + +/// Sign-in against the API Server. The server address is editable here since +/// the Agent is often the first PilotVault app on a phone. +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final TextEditingController _server = TextEditingController(); + final TextEditingController _email = TextEditingController(); + final TextEditingController _password = TextEditingController(); + bool _busy = false; + bool _prefilled = false; + String? _error; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_prefilled) return; + _prefilled = true; + final Api api = Services.of(context).api; + _server.text = api.serverUrl; + _email.text = api.email; + } + + @override + void dispose() { + _server.dispose(); + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _submit() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await Services.of(context).api.signIn(server: _server.text, email: _email.text, password: _password.text); + } on ApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.radar, size: 48, color: PV.accent), + const SizedBox(height: 12), + const Text('PilotVault Fly Agent', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: PV.ink)), + const SizedBox(height: 6), + const Text( + 'Runs next to DJI Fly. Logs your flight from the aircraft\'s Remote ID and files it in your logbook.', + textAlign: TextAlign.center, + style: PV.caption, + ), + const SizedBox(height: 28), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + TextField( + controller: _server, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration(labelText: 'API server', helperText: 'e.g. http://10.2.1.101:8080'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: const TextStyle(color: PV.warning)), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _busy ? null : _submit, + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)), + child: _busy + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sign in'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/Fly Agent App/lib/ui/nearby_tab.dart b/Fly Agent App/lib/ui/nearby_tab.dart new file mode 100644 index 0000000..45af6a3 --- /dev/null +++ b/Fly Agent App/lib/ui/nearby_tab.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../api/api.dart'; +import '../rid/rid_aircraft.dart'; +import '../rid/rid_service.dart'; +import '../services.dart'; +import '../theme.dart'; +import 'format.dart'; + +/// The Remote ID receiver: scanner state per transport, and every aircraft +/// currently broadcasting nearby. Tapping one pairs it with a PilotVault drone. +class NearbyTab extends StatelessWidget { + const NearbyTab({super.key}); + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + return ListenableBuilder( + listenable: Listenable.merge([s.rid, s.pilot]), + builder: (BuildContext context, _) { + final RidService rid = s.rid; + final Position? me = s.pilot.position; + final List list = rid.aircraft; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _ReceiverCard(rid: rid), + const SectionLabel('Aircraft in range'), + if (list.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + 'Nothing heard yet. Power on the aircraft; Remote ID starts broadcasting once it has a GPS fix. ' + 'Aircraft that only use Wi-Fi Beacon can take up to 30 s to appear.', + style: PV.caption, + ), + ), + ), + for (final RidAircraft a in list) ...[ + _AircraftTile(aircraft: a, me: me, paired: a.serial != null && a.serial == rid.pairing?.serial), + const SizedBox(height: 8), + ], + const SizedBox(height: 8), + const Text( + 'Other operators\' broadcasts are shown only on this phone and never uploaded.', + style: PV.caption, + textAlign: TextAlign.center, + ), + ], + ); + }, + ); + } +} + +class _ReceiverCard extends StatelessWidget { + const _ReceiverCard({required this.rid}); + final RidService rid; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + StatusDot(rid.running ? PV.ready : PV.inkMuted, size: 10), + const SizedBox(width: 8), + Expanded( + child: Text(rid.running ? 'Receiver on' : 'Receiver off', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + ), + Switch( + value: rid.running, + onChanged: (bool on) async { + try { + on ? await rid.start() : await rid.stop(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Receiver: $e'))); + } + } + }, + ), + ], + ), + const SizedBox(height: 8), + for (final String transport in const ['ble', 'nan', 'beacon']) _transportRow(transport), + const SizedBox(height: 6), + Text('${rid.framesReceived} frames received', style: PV.caption), + ], + ), + ), + ); + } + + Widget _transportRow(String transport) { + final TransportStatus? st = rid.transports[transport]; + final bool capable = switch (transport) { + 'ble' => rid.capabilities['ble'] ?? true, + 'nan' => rid.capabilities['nan'] ?? false, + _ => rid.capabilities['beacon'] ?? false, + }; + final String state = !capable ? 'not supported' : (st?.state ?? (rid.running ? 'starting' : 'off')); + final Color c = switch (state) { + 'scanning' => PV.ready, + 'failed' || 'denied' => PV.warning, + 'unavailable' => PV.caution, + _ => PV.inkMuted, + }; + final String extra = transport == 'ble' && (rid.capabilities['bleCoded'] ?? false) ? ' + Long Range' : ''; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: const EdgeInsets.only(top: 5), child: StatusDot(c)), + const SizedBox(width: 8), + SizedBox(width: 110, child: Text('${transportLabel(transport)}$extra', style: const TextStyle(fontSize: 13))), + Expanded( + child: Text( + (st?.detail.isNotEmpty ?? false) ? '$state · ${st!.detail}' : state, + style: PV.caption, + ), + ), + ], + ), + ); + } +} + +class _AircraftTile extends StatelessWidget { + const _AircraftTile({required this.aircraft, required this.me, required this.paired}); + final RidAircraft aircraft; + final Position? me; + final bool paired; + + @override + Widget build(BuildContext context) { + final RidAircraft a = aircraft; + final double? dist = me != null && a.hasFix + ? Geolocator.distanceBetween(me!.latitude, me!.longitude, a.latitude!, a.longitude!) + : null; + return Card( + shape: paired + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(PV.radius), side: const BorderSide(color: PV.accent, width: 1.5)) + : null, + child: InkWell( + borderRadius: BorderRadius.circular(PV.radius), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + // Services sits above MaterialApp, so the sheet's route sees it too. + builder: (_) => _PairSheet(aircraft: a), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(paired ? Icons.link : Icons.flight, size: 18, color: paired ? PV.accent : PV.inkSecondary), + const SizedBox(width: 8), + Expanded(child: Text(a.label, style: PV.mono, overflow: TextOverflow.ellipsis)), + if (paired) const Text('PAIRED', style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: PV.accent)), + ], + ), + const SizedBox(height: 4), + Text( + [ + if (paired && Services.of(context).rid.pairing!.droneName.isNotEmpty) + Services.of(context).rid.pairing!.droneName, + a.uaTypeLabel, + if (a.euClass != null) a.euClass!, + a.status.name, + ].join(' · '), + style: PV.caption, + ), + const SizedBox(height: 10), + Wrap( + spacing: 16, + runSpacing: 6, + children: [ + _kv('HEIGHT', fmtMeters(a.height)), + _kv('SPEED', fmtSpeed(a.speedHorizontal)), + _kv('DIST', fmtDistance(dist)), + _kv('RATE', a.locationRate > 0 ? '${a.locationRate.toStringAsFixed(1)} Hz' : '—'), + _kv('RSSI', a.rssi == null ? '—' : '${a.rssi} dBm'), + ], + ), + const SizedBox(height: 8), + Text( + '${a.transports.map(transportLabel).join(', ')} · ${fmtAgo(a.lastSeen)}', + style: PV.caption, + ), + ], + ), + ), + ), + ); + } + + Widget _kv(String k, String v) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(k, style: PV.label), Text(v, style: PV.mono)], + ); +} + +/// Pairs a broadcast serial with a PilotVault drone (existing or new). +class _PairSheet extends StatefulWidget { + const _PairSheet({required this.aircraft}); + final RidAircraft aircraft; + + @override + State<_PairSheet> createState() => _PairSheetState(); +} + +class _PairSheetState extends State<_PairSheet> { + Future>? _drones; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _drones ??= Services.of(context).api.drones(); + } + + Future _pair(Drone d) async { + await Services.of(context).rid.pair(RidPairing( + serial: widget.aircraft.serial!, + droneId: d.id, + droneName: d.displayName, + )); + if (mounted) Navigator.of(context).pop(); + } + + Future _createAndPair() async { + final TextEditingController name = TextEditingController(); + final TextEditingController model = TextEditingController(); + final bool? ok = await showDialog( + context: context, + builder: (BuildContext c) => AlertDialog( + title: const Text('New drone'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: name, decoration: const InputDecoration(labelText: 'Name (optional)')), + const SizedBox(height: 8), + TextField(controller: model, decoration: const InputDecoration(labelText: 'Model, e.g. DJI Mavic 4 Pro')), + const SizedBox(height: 8), + Text('Serial ${widget.aircraft.serial} from its Remote ID broadcast.', style: PV.caption), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('Create')), + ], + ), + ); + if (ok != true || !mounted) return; + try { + // The Remote ID serial (ANSI/CTA-2063-A) is the airframe's own serial, + // so it goes into the registered-serial field. + final Drone d = await Services.of(context).api.createDrone( + name: name.text.trim(), + model: model.text.trim(), + serial: widget.aircraft.serial!, + ); + await _pair(d); + } on ApiException catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); + } + } + + @override + Widget build(BuildContext context) { + final RidAircraft a = widget.aircraft; + final RidService rid = Services.of(context).rid; + final bool isPaired = a.serial != null && rid.pairing?.serial == a.serial; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(a.label, style: PV.mono.copyWith(fontSize: 16)), + const SizedBox(height: 4), + Text( + [ + if (a.operatorId != null) 'Operator ${a.operatorId}', + if (a.euCategory != null) 'category ${a.euCategory}', + if (a.selfId != null) a.selfId!, + ].join(' · '), + style: PV.caption, + ), + const SizedBox(height: 16), + if (a.serial == null) + const Text('This aircraft has not broadcast its serial yet; wait for a Basic ID message to pair it.', + style: PV.caption) + else if (isPaired) ...[ + Text('Paired with ${rid.pairing!.droneName}.'), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Unpair'), + onPressed: () async { + await rid.unpair(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ] else ...[ + const Text('Pair this aircraft with one of your drones. Only the paired aircraft is logged and ' + 'streamed to PilotVault.'), + const SizedBox(height: 12), + FutureBuilder>( + future: _drones, + builder: (BuildContext context, AsyncSnapshot> snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) return Text('Could not load drones: ${snap.error}', style: PV.caption); + final List drones = snap.data!; + // A drone whose registered serial matches the broadcast goes first. + drones.sort((Drone x, Drone y) => (y.serial == a.serial ? 1 : 0) - (x.serial == a.serial ? 1 : 0)); + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView( + shrinkWrap: true, + children: [ + for (final Drone d in drones) + ListTile( + leading: Icon(d.serial == a.serial ? Icons.verified : Icons.flight_outlined), + title: Text(d.displayName), + subtitle: d.serial.isNotEmpty ? Text(d.serial) : null, + onTap: () => _pair(d), + ), + ListTile( + leading: const Icon(Icons.add), + title: const Text('New drone with this serial'), + onTap: _createAndPair, + ), + ], + ), + ); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/Fly Agent App/lib/ui/settings_page.dart b/Fly Agent App/lib/ui/settings_page.dart new file mode 100644 index 0000000..3cb0a76 --- /dev/null +++ b/Fly Agent App/lib/ui/settings_page.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../api/api.dart'; +import '../services.dart'; +import '../theme.dart'; +import '../uplink/uplink.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + bool? _batteryExempt; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkBattery(); + } + + Future _checkBattery() async { + try { + final bool v = await Services.of(context).rid.isIgnoringBatteryOptimizations(); + if (mounted) setState(() => _batteryExempt = v); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final Services s = Services.of(context); + final Api api = s.api; + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListenableBuilder( + listenable: Listenable.merge([api, s.uplink, s.rid]), + builder: (BuildContext context, _) => ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionLabel('Account'), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.person_outline), + title: Text(api.me?.email ?? api.email), + subtitle: Text([ + if (api.me?.role.isNotEmpty ?? false) api.me!.role, + if (api.me?.orgName.isNotEmpty ?? false) api.me!.orgName, + ].join(' · ')), + ), + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('API server'), + subtitle: Text(api.serverUrl), + ), + ListTile( + leading: const Icon(Icons.logout, color: PV.warning), + title: const Text('Sign out'), + onTap: () async { + s.uplink.disable(); + await api.signOut(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ], + ), + ), + const SectionLabel('Live uplink'), + Card( + child: Column( + children: [ + SwitchListTile( + title: const Text('Stream the paired aircraft to PilotVault'), + subtitle: Text(_uplinkText(s.uplink)), + value: s.uplink.status != UplinkStatus.off, + onChanged: (bool on) => on ? s.uplink.enable(api.serverUrl) : s.uplink.disable(), + ), + ], + ), + ), + const SectionLabel('Pairing'), + Card( + child: ListTile( + leading: const Icon(Icons.link), + title: Text(s.rid.pairing?.droneName ?? 'No aircraft paired'), + subtitle: Text(s.rid.pairing?.serial ?? 'Pair one under Nearby'), + trailing: s.rid.pairing == null + ? null + : TextButton(onPressed: s.rid.unpair, child: const Text('Unpair')), + ), + ), + const SectionLabel('Background'), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(Icons.battery_saver_outlined, color: _batteryExempt == false ? PV.caution : PV.ready), + title: const Text('Battery optimisation'), + subtitle: Text(_batteryExempt == null + ? '…' + : _batteryExempt! + ? 'Exempt: Android will not stop the receiver' + : 'Android may stop the receiver while DJI Fly is in front'), + trailing: _batteryExempt == false + ? TextButton( + onPressed: () async { + await s.rid.requestIgnoreBatteryOptimizations(); + await Future.delayed(const Duration(seconds: 1)); + _checkBattery(); + }, + child: const Text('Exempt'), + ) + : null, + ), + const ListTile( + leading: Icon(Icons.wifi_find), + title: Text('Wi-Fi Beacon updates'), + subtitle: Text('Android limits Wi-Fi scans to 4 per 2 minutes. For faster Beacon updates turn off ' + '"Wi-Fi scan throttling" in Developer options.'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _uplinkText(Uplink u) => switch (u.status) { + UplinkStatus.off => 'Off', + UplinkStatus.connecting => 'Waiting for the paired aircraft', + UplinkStatus.live => 'Live as ${u.deviceId} · ${u.sent} sent', + UplinkStatus.offline => 'Server unreachable · ${u.buffered} buffered', + }; +} diff --git a/Fly Agent App/lib/ui/shell.dart b/Fly Agent App/lib/ui/shell.dart new file mode 100644 index 0000000..ef3cc22 --- /dev/null +++ b/Fly Agent App/lib/ui/shell.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../services.dart'; +import '../theme.dart'; +import '../uplink/uplink.dart'; +import 'flight_tab.dart'; +import 'logbook_tab.dart'; +import 'nearby_tab.dart'; +import 'settings_page.dart'; + +/// Signed-in home: Flight (session), Nearby (Remote ID receiver), Logbook. +class AgentShell extends StatefulWidget { + const AgentShell({super.key}); + + @override + State createState() => _AgentShellState(); +} + +class _AgentShellState extends State { + int _tab = 0; + bool _started = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + // Signing in is what turns the Agent on: GPS, the receiver and the uplink + // all start here so the pilot only has to open DJI Fly. + final Services s = Services.of(context); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await s.pilot.start(); + if (!s.rid.running) { + try { + await s.rid.start(); + } catch (_) { + // Shown on the Nearby tab; the session still works by hand. + } + } + s.uplink.enable(s.api.serverUrl); + }); + } + + @override + Widget build(BuildContext context) { + const List titles = ['Flight', 'Nearby aircraft', 'Logbook']; + return Scaffold( + appBar: AppBar( + title: Text(titles[_tab]), + actions: [ + const _UplinkChip(), + IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SettingsPage())), + ), + ], + ), + body: IndexedStack(index: _tab, children: const [FlightTab(), NearbyTab(), LogbookTab()]), + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: (int i) => setState(() => _tab = i), + destinations: const [ + NavigationDestination(icon: Icon(Icons.flight_takeoff), label: 'Flight'), + NavigationDestination(icon: Icon(Icons.radar), label: 'Nearby'), + NavigationDestination(icon: Icon(Icons.menu_book_outlined), label: 'Logbook'), + ], + ), + ); + } +} + +class _UplinkChip extends StatelessWidget { + const _UplinkChip(); + + @override + Widget build(BuildContext context) { + final Uplink uplink = Services.of(context).uplink; + return ListenableBuilder( + listenable: uplink, + builder: (BuildContext context, _) { + final (Color c, String t) = switch (uplink.status) { + UplinkStatus.live => (PV.ready, 'LIVE'), + UplinkStatus.connecting => (PV.inkMuted, 'IDLE'), + UplinkStatus.offline => (PV.caution, 'OFFLINE'), + UplinkStatus.off => (PV.inkMuted, 'OFF'), + }; + return Tooltip( + message: 'Live uplink to PilotVault', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row(children: [StatusDot(c), const SizedBox(width: 6), Text(t, style: PV.label)]), + ), + ); + }, + ); + } +} diff --git a/Fly Agent App/lib/ui/track_map.dart b/Fly Agent App/lib/ui/track_map.dart new file mode 100644 index 0000000..c3f27aa --- /dev/null +++ b/Fly Agent App/lib/ui/track_map.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../session/flight_session.dart'; +import '../theme.dart'; + +/// OpenStreetMap with the session track, the aircraft and the pilot. +class TrackMap extends StatelessWidget { + const TrackMap({super.key, required this.track, this.aircraft, this.pilot, this.height = 220}); + + final List track; + final LatLng? aircraft; + final LatLng? pilot; + final double height; + + @override + Widget build(BuildContext context) { + final List line = track.map((TrackPoint p) => LatLng(p.lat, p.lon)).toList(); + final List all = [...line, if (aircraft != null) aircraft!, if (pilot != null) pilot!]; + if (all.isEmpty) { + return Container( + height: height, + alignment: Alignment.center, + decoration: BoxDecoration(color: PV.surface2, borderRadius: BorderRadius.circular(PV.radius)), + child: const Text('No position yet', style: PV.caption), + ); + } + final MapOptions options = all.length == 1 + ? MapOptions(initialCenter: all.first, initialZoom: 16) + : MapOptions( + initialCameraFit: CameraFit.coordinates( + coordinates: all, + padding: const EdgeInsets.all(32), + maxZoom: 18, + ), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(PV.radius), + child: SizedBox( + height: height, + child: FlutterMap( + // The camera only fits on creation; a new key refits as the track grows. + key: ValueKey(all.length ~/ 10), + options: options, + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.pilotvault.flyagent', + ), + if (line.length >= 2) + PolylineLayer(polylines: [Polyline(points: line, strokeWidth: 3, color: PV.accent)]), + MarkerLayer(markers: [ + if (pilot != null) + Marker( + point: pilot!, + width: 22, + height: 22, + child: const Icon(Icons.person_pin_circle, color: PV.navy, size: 22), + ), + if (aircraft != null) + Marker( + point: aircraft!, + width: 26, + height: 26, + child: Container( + decoration: BoxDecoration( + color: PV.accent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon(Icons.flight, size: 14, color: Colors.white), + ), + ), + ]), + ], + ), + ), + ); + } +} diff --git a/Fly Agent App/lib/uplink/uplink.dart b/Fly Agent App/lib/uplink/uplink.dart new file mode 100644 index 0000000..e947198 --- /dev/null +++ b/Fly Agent App/lib/uplink/uplink.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import '../pilot_location.dart'; +import '../rid/rid_aircraft.dart'; +import '../rid/rid_service.dart'; + +enum UplinkStatus { off, connecting, live, offline } + +/// Maps a Remote ID fix onto the hub's telemetry event. +/// +/// Keys the hub already understands (`latitude`, `altitude`, `velocityX`…, see +/// API Server `internal/hub/models.go`) keep the Fly App's names and meanings: +/// altitude is height above take-off and velocity is north/east/down. The rest +/// (`source`, `ridSerial`, …) rides along in the raw event the Web App receives. +/// No battery, signal or flight mode: Remote ID does not carry them, and a +/// made-up value would be worse than a blank. +Map ridTelemetryEvent(RidAircraft a, {double? phoneLat, double? phoneLon, DateTime? now}) { + final double? speed = a.speedHorizontal; + final double? track = a.track; + final Map e = { + 'type': 'telemetry', + 'source': 'remote-id', + 'ridSerial': a.serial, + 'latitude': a.latitude, + 'longitude': a.longitude, + 'altitude': a.height, + 'heightReference': a.heightReference.name, + 'altitudeMsl': a.altitudeGeodetic ?? a.altitudePressure, + 'isFlying': a.isAirborne, + 'ridStatus': a.status.name, + 'transport': a.transports.join(','), + 'ts': (now ?? DateTime.now()).millisecondsSinceEpoch, + }; + if (speed != null && track != null) { + final double rad = track * math.pi / 180; + e['velocityX'] = speed * math.cos(rad); // north + e['velocityY'] = speed * math.sin(rad); // east + } + if (a.speedVertical != null) e['velocityZ'] = -a.speedVertical!; // down positive, like the SDK + if (track != null) e['heading'] = track; + if (a.operatorLatitude != null) { + e['pilotLatitude'] = a.operatorLatitude; + e['pilotLongitude'] = a.operatorLongitude; + } + if (a.euClass != null) e['uaClass'] = a.euClass; + if (a.euCategory != null) e['uaCategory'] = a.euCategory; + if (phoneLat != null && phoneLon != null) { + e['phoneLatitude'] = phoneLat; + e['phoneLongitude'] = phoneLon; + } + e.removeWhere((String _, dynamic v) => v == null); + return e; +} + +/// Streams the paired aircraft's Remote ID position to the API Server hub over +/// `/ws/device?id=agent-`, so the Web App's live map follows the +/// flight. Reconnects on its own and buffers while offline. +class Uplink extends ChangeNotifier { + Uplink({required this.rid, required this.pilot}); + + final RidService rid; + final PilotLocation pilot; + + /// The hub only needs ~1 Hz; Remote ID rarely sends faster anyway. + static const Duration minInterval = Duration(milliseconds: 900); + + /// Remote ID silence after which the aircraft is reported disconnected. + static const Duration lostAfter = Duration(seconds: 60); + + /// Offline buffer cap (~30 min at 1 Hz). Oldest points go first. + static const int maxBuffered = 1800; + + UplinkStatus status = UplinkStatus.off; + String _server = ''; + String? _deviceId; + WebSocket? _socket; + Timer? _reconnect; + Timer? _watchdog; + StreamSubscription? _sub; + DateTime _lastSent = DateTime.fromMillisecondsSinceEpoch(0); + bool _announced = false; + final Queue _buffer = Queue(); + int sent = 0; + + int get buffered => _buffer.length; + String? get deviceId => _deviceId; + + /// [server] is the API Server base URL (http[s]://host:port). + void enable(String server) { + if (status != UplinkStatus.off && server == _server) return; + disable(); + _server = server; + status = UplinkStatus.connecting; + _sub = rid.pairedLocations.listen(_onFix); + _watchdog = Timer.periodic(const Duration(seconds: 5), (_) => _checkLost()); + notifyListeners(); + _connect(); + } + + void disable() { + _sub?.cancel(); + _sub = null; + _watchdog?.cancel(); + _reconnect?.cancel(); + if (_announced) _send({'type': 'connection', 'connected': false}); + _socket?.close(); + _socket = null; + _announced = false; + status = UplinkStatus.off; + notifyListeners(); + } + + void _onFix(RidAircraft a) { + final String id = 'agent-${a.serial}'; + if (_deviceId != id) { + // Pairing changed: the old device id goes quiet and the socket reopens + // under the new one. + _deviceId = id; + _announced = false; + _socket?.close(); + _socket = null; + _connect(); + } + final DateTime now = DateTime.now(); + if (now.difference(_lastSent) < minInterval) return; + _lastSent = now; + if (!_announced) { + _send({ + 'type': 'connection', + 'connected': true, + 'model': '${rid.pairing?.droneName.isNotEmpty == true ? rid.pairing!.droneName : a.serial} · Remote ID', + 'source': 'remote-id', + }); + _announced = true; + } + _send(ridTelemetryEvent(a, phoneLat: pilot.position?.latitude, phoneLon: pilot.position?.longitude)); + } + + void _checkLost() { + final RidAircraft? a = rid.paired; + if (!_announced || a == null) return; + if (DateTime.now().difference(a.lastSeen) > lostAfter) { + _send({'type': 'connection', 'connected': false, 'reason': 'remote-id-lost'}); + _announced = false; + } + } + + void _send(Map event) { + final String frame = jsonEncode(event); + final WebSocket? s = _socket; + if (s != null && status == UplinkStatus.live) { + try { + s.add(frame); + sent++; + return; + } catch (_) {} + } + _buffer.addLast(frame); + while (_buffer.length > maxBuffered) { + _buffer.removeFirst(); + } + } + + Future _connect() async { + if (status == UplinkStatus.off || _deviceId == null || _socket != null) return; + _reconnect?.cancel(); + final Uri uri = deviceSocketUri(_server, _deviceId!); + try { + final WebSocket s = await WebSocket.connect(uri.toString()).timeout(const Duration(seconds: 8)); + if (status == UplinkStatus.off) { + await s.close(); + return; + } + _socket = s; + status = UplinkStatus.live; + notifyListeners(); + s.listen((dynamic _) {}, onDone: _onClosed, onError: (Object _) => _onClosed(), cancelOnError: true); + while (_buffer.isNotEmpty) { + s.add(_buffer.removeFirst()); + sent++; + } + } catch (_) { + _onClosed(); + } + } + + void _onClosed() { + _socket = null; + if (status == UplinkStatus.off) return; + status = UplinkStatus.offline; + notifyListeners(); + _reconnect?.cancel(); + _reconnect = Timer(const Duration(seconds: 5), _connect); + } + + @override + void dispose() { + disable(); + super.dispose(); + } +} + +/// `http://host:port` → `ws://host:port/ws/device?id=…` (https → wss). +Uri deviceSocketUri(String server, String deviceId) { + final Uri base = Uri.parse(server); + return base.replace( + scheme: base.scheme == 'https' ? 'wss' : 'ws', + path: '/ws/device', + queryParameters: {'id': deviceId}, + ); +} diff --git a/Fly Agent App/pubspec.lock b/Fly Agent App/pubspec.lock new file mode 100644 index 0000000..8711bbb --- /dev/null +++ b/Fly Agent App/pubspec.lock @@ -0,0 +1,778 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767" + url: "https://pub.dev" + source: hosted + version: "8.3.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09" + url: "https://pub.dev" + source: hosted + version: "14.1.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22 + url: "https://pub.dev" + source: hosted + version: "5.2.1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: a596abaef55d63dd0a8a61acd96ad388f9b54017c88224741f54fd0400aa4ffe + url: "https://pub.dev" + source: hosted + version: "2.4.2" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: ea90e0de6cf8a5e6c65911b378dfe87f80e25df13fc9514da92af447cc91cad0 + url: "https://pub.dev" + source: hosted + version: "9.6.3" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "732b86c5c726402ac781a9fe14fe9cadad3c7dde8f99c72ab43a75a307077336" + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/Fly Agent App/pubspec.yaml b/Fly Agent App/pubspec.yaml new file mode 100644 index 0000000..2060a8e --- /dev/null +++ b/Fly Agent App/pubspec.yaml @@ -0,0 +1,45 @@ +name: pilotvault_fly_agent +description: "PilotVault Fly Agent — companion app that runs next to DJI Fly: Remote ID receiver, live uplink and logbook filing. Uses no DJI SDK." +publish_to: 'none' + +version: 0.1.0+1 + +environment: + sdk: ^3.6.2 + +dependencies: + flutter: + sdk: flutter + + # Auth token, server URL, Remote ID pairing, the open session and the + # upload queue all persist across launches. + shared_preferences: ^2.5.6 + # API Server REST calls (login, drones, flights, weather). + http: ^1.6.0 + # The pilot's own position: briefing weather, distance to the aircraft, + # and the pilot fix sent with the live uplink. + geolocator: ^14.1.2 + # Bluetooth scan / nearby Wi-Fi / location / notification runtime grants. + permission_handler: ^12.0.1 + # Track map on the flight screens. OpenStreetMap tiles need no API key. + flutter_map: ^8.3.2 + latlong2: ^0.10.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + # PilotVault brand fonts (same files as the Fly App, Google Fonts OFL). + fonts: + - family: Space Grotesk + fonts: + - asset: assets/fonts/SpaceGrotesk-VariableFont_wght.ttf + - family: Space Mono + fonts: + - asset: assets/fonts/SpaceMono-Regular.ttf + - asset: assets/fonts/SpaceMono-Bold.ttf + weight: 700 diff --git a/Fly Agent App/test/odid_fixtures.dart b/Fly Agent App/test/odid_fixtures.dart new file mode 100644 index 0000000..46cdd1f --- /dev/null +++ b/Fly Agent App/test/odid_fixtures.dart @@ -0,0 +1,90 @@ +import 'dart:typed_data'; + +/// Builds OpenDroneID messages byte-for-byte as an aircraft would broadcast +/// them, so the decoder is tested against the wire format, not against itself. + +Uint8List basicId(String serial, {int idType = 1, int uaType = 2}) { + final Uint8List b = Uint8List(25); + b[0] = 0x02; // type 0, protocol version 2 + b[1] = (idType << 4) | uaType; + final List s = serial.codeUnits; + for (int i = 0; i < s.length && i < 20; i++) { + b[2 + i] = s[i]; + } + return b; +} + +Uint8List location({ + int status = 2, + double lat = 55.6761, + double lon = 12.5683, + double? height = 42.5, + double? geo = 80, + double? speed = 5.25, + int track = 270, + double vSpeed = 1.5, + int tenthsIntoHour = 12345, +}) { + final ByteData d = ByteData(25); + int flags = status << 4; + int dir = track; + if (dir >= 180) { + flags |= 0x02; + dir -= 180; + } + int speedRaw = 255; + if (speed != null) { + if (speed <= 255 * 0.25) { + speedRaw = (speed / 0.25).round(); + } else { + flags |= 0x01; + speedRaw = ((speed - 255 * 0.25) / 0.75).round(); + } + } + d.setUint8(0, 0x12); + d.setUint8(1, flags); + d.setUint8(2, dir); + d.setUint8(3, speedRaw); + d.setInt8(4, (vSpeed / 0.5).round()); + d.setInt32(5, (lat * 1e7).round(), Endian.little); + d.setInt32(9, (lon * 1e7).round(), Endian.little); + d.setUint16(13, 0, Endian.little); // pressure altitude unknown + d.setUint16(15, geo == null ? 0 : ((geo + 1000) / 0.5).round(), Endian.little); + d.setUint16(17, height == null ? 0 : ((height + 1000) / 0.5).round(), Endian.little); + d.setUint8(19, (4 << 4) | 10); // vertical < 10 m, horizontal < 10 m + d.setUint16(21, tenthsIntoHour, Endian.little); + return d.buffer.asUint8List(); +} + +Uint8List system({double opLat = 55.6760, double opLon = 12.5680, int category = 1, int cls = 2}) { + final ByteData d = ByteData(25); + d.setUint8(0, 0x42); + d.setUint8(1, (1 << 2) | 1); // EU classification, live GNSS operator position + d.setInt32(2, (opLat * 1e7).round(), Endian.little); + d.setInt32(6, (opLon * 1e7).round(), Endian.little); + d.setUint16(10, 1, Endian.little); + d.setUint8(17, (category << 4) | cls); + d.setUint32(20, 3600, Endian.little); // 2019-01-01T01:00:00Z + return d.buffer.asUint8List(); +} + +Uint8List operatorId(String id) { + final Uint8List b = Uint8List(25); + b[0] = 0x52; + final List s = id.codeUnits; + for (int i = 0; i < s.length && i < 20; i++) { + b[2 + i] = s[i]; + } + return b; +} + +Uint8List pack(List messages) { + final BytesBuilder b = BytesBuilder() + ..addByte(0xF2) + ..addByte(25) + ..addByte(messages.length); + for (final Uint8List m in messages) { + b.add(m); + } + return b.toBytes(); +} diff --git a/Fly Agent App/test/rid_test.dart b/Fly Agent App/test/rid_test.dart new file mode 100644 index 0000000..819ff41 --- /dev/null +++ b/Fly Agent App/test/rid_test.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly_agent/rid/odid_decoder.dart'; +import 'package:pilotvault_fly_agent/rid/rid_aircraft.dart'; +import 'package:pilotvault_fly_agent/uplink/uplink.dart'; + +import 'odid_fixtures.dart'; + +void main() { + group('decodeOdid', () { + test('Basic ID: serial and UA type', () { + final List m = decodeOdid(basicId('1581F5FKD229400B0123')); + expect(m.single, isA()); + final OdidBasicId b = m.single as OdidBasicId; + expect(b.uasId, '1581F5FKD229400B0123'); + expect(b.isSerial, isTrue); + expect(b.uaType, 2); + }); + + test('Location: position, height, speed, direction past 180°', () { + final OdidLocation l = decodeOdid(location()).single as OdidLocation; + expect(l.status, UaStatus.airborne); + expect(l.heightReference, HeightReference.takeoff); + expect(l.latitude, closeTo(55.6761, 1e-7)); + expect(l.longitude, closeTo(12.5683, 1e-7)); + expect(l.height, 42.5); + expect(l.altitudeGeodetic, 80); + expect(l.altitudePressure, isNull); + expect(l.speedHorizontal, 5.25); + expect(l.speedVertical, 1.5); + expect(l.track, 270); + expect(l.horizontalAccuracy, 10); + expect(l.verticalAccuracy, 10); + expect(l.secondsIntoHour, 1234.5); + }); + + test('Location: high speeds use the 0.75 multiplier', () { + final OdidLocation l = decodeOdid(location(speed: 100)).single as OdidLocation; + expect(l.speedHorizontal, closeTo(100, 0.75)); + }); + + test('Location: unknowns decode to null, not sentinels', () { + final OdidLocation l = decodeOdid(location(lat: 0, lon: 0, height: null, speed: null)).single as OdidLocation; + expect(l.hasFix, isFalse); + expect(l.height, isNull); + expect(l.speedHorizontal, isNull); + }); + + test('System: operator position and EU category/class', () { + final OdidSystem s = decodeOdid(system()).single as OdidSystem; + expect(s.classificationType, 1); + expect(s.operatorLatitude, closeTo(55.6760, 1e-7)); + expect(s.euCategory, 'open'); + expect(s.euClass, 'C1'); + expect(s.timestamp, DateTime.utc(2019, 1, 1, 1)); + }); + + test('Message pack carries several messages', () { + final List m = decodeOdid(pack([basicId('SN1'), location(), operatorId('DNK-OP-123')])); + expect(m.map((OdidMessage e) => e.runtimeType).toList(), [OdidBasicId, OdidLocation, OdidOperatorId]); + expect((m[2] as OdidOperatorId).operatorId, 'DNK-OP-123'); + }); + + test('Truncated input never throws', () { + expect(decodeOdid(Uint8List(0)), isEmpty); + expect(decodeOdid(Uint8List.fromList([0x12, 0x20, 0x01])), isEmpty); + final Uint8List p = pack([basicId('SN1'), location()]); + expect(decodeOdid(Uint8List.sublistView(p, 0, p.length - 5)).length, 1); + }); + }); + + group('RidRegistry', () { + final DateTime t0 = DateTime(2026, 10, 22, 12); + + test('BLE legacy: location before Basic ID merges under the serial', () { + final RidRegistry r = RidRegistry(); + r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(location())); + expect(r.aircraft.single.serial, isNull); + r.ingest(transport: 'ble', source: 'AA', rssi: -61, at: t0, messages: decodeOdid(basicId('SN1'))); + expect(r.aircraft.length, 1); + final RidAircraft a = r.bySerial('SN1')!; + expect(a.hasFix, isTrue); + expect(a.height, 42.5); + // Later location-only frames from the same radio land on the same aircraft. + r.ingest(transport: 'ble', source: 'AA', rssi: -62, at: t0.add(const Duration(seconds: 1)), + messages: decodeOdid(location(height: 50))); + expect(r.aircraft.length, 1); + expect(r.bySerial('SN1')!.height, 50); + }); + + test('Same aircraft on two transports is one entry', () { + final RidRegistry r = RidRegistry(); + r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(pack([basicId('SN1'), location()]))); + r.ingest(transport: 'beacon', source: 'BB', rssi: -70, at: t0, messages: decodeOdid(pack([basicId('SN1'), location()]))); + expect(r.aircraft.length, 1); + expect(r.bySerial('SN1')!.transports, {'ble', 'beacon'}); + }); + + test('prune keeps the paired aircraft', () { + final RidRegistry r = RidRegistry(); + r.ingest(transport: 'ble', source: 'AA', rssi: -60, at: t0, messages: decodeOdid(basicId('SN1'))); + r.ingest(transport: 'ble', source: 'BB', rssi: -60, at: t0, messages: decodeOdid(basicId('SN2'))); + r.prune(t0.add(const Duration(minutes: 5)), const Duration(minutes: 2), keep: 'SN1'); + expect(r.aircraft.map((RidAircraft a) => a.serial), ['SN1']); + }); + }); + + test('ridTelemetryEvent uses the hub\'s keys and north/east/down velocity', () { + final RidRegistry r = RidRegistry(); + final RidAircraft a = r.ingest( + transport: 'ble', + source: 'AA', + rssi: -60, + at: DateTime(2026), + messages: decodeOdid(pack([basicId('SN1'), location(track: 90, speed: 4, vSpeed: 2), system()])), + ); + final Map e = ridTelemetryEvent(a, phoneLat: 1, phoneLon: 2, now: DateTime(2026)); + expect(e['type'], 'telemetry'); + expect(e['latitude'], closeTo(55.6761, 1e-7)); + expect(e['altitude'], 42.5); + expect(e['isFlying'], isTrue); + expect(e['velocityX'] as double, closeTo(0, 1e-9)); // heading east: no north component + expect(e['velocityY'] as double, closeTo(4, 1e-9)); + expect(e['velocityZ'], -2.0); + expect(e['pilotLatitude'], closeTo(55.6760, 1e-7)); + expect(e['uaClass'], 'C1'); + expect(e['phoneLatitude'], 1); + expect(e.containsKey('batteryPercent'), isFalse); + }); + + test('deviceSocketUri', () { + expect(deviceSocketUri('http://10.2.1.101:8080', 'agent-SN1').toString(), 'ws://10.2.1.101:8080/ws/device?id=agent-SN1'); + expect(deviceSocketUri('https://pv.example.com', 'agent-SN1').toString(), 'wss://pv.example.com/ws/device?id=agent-SN1'); + }); +} diff --git a/Fly Agent App/test/session_test.dart b/Fly Agent App/test/session_test.dart new file mode 100644 index 0000000..af48684 --- /dev/null +++ b/Fly Agent App/test/session_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly_agent/session/flight_session.dart'; + +void main() { + final DateTime t0 = DateTime(2026, 10, 22, 14, 5); + + FixSample fix(int s, {bool airborne = true, double height = 30, double lat = 55.0, double lon = 12.0}) => + FixSample(at: t0.add(Duration(seconds: s)), airborne: airborne, lat: lat, lon: lon, height: height, speed: 3); + + FlightSession armed() => FlightSession() + ..droneId = 'd1' + ..ridSerial = 'SN1' + ..arm(t0); + + test('armed session starts on the first airborne fix', () { + final FlightSession s = armed(); + expect(s.onFix(fix(0, airborne: false, height: 0)), isFalse); + expect(s.phase, SessionPhase.armed); + expect(s.onFix(fix(5)), isTrue); + expect(s.phase, SessionPhase.active); + expect(s.autoStarted, isTrue); + expect(s.startedAt, t0.add(const Duration(seconds: 5))); + }); + + test('stops at touchdown after the landed hold, not at the end of the hold', () { + final FlightSession s = armed(); + s.onFix(fix(0)); + s.onFix(fix(10, airborne: false, height: 0)); + expect(s.onFix(fix(20, airborne: false, height: 0)), isFalse); + expect(s.onFix(fix(26, airborne: false, height: 0)), isTrue); + expect(s.phase, SessionPhase.landed); + expect(s.endedAt, t0.add(const Duration(seconds: 10))); + }); + + test('a brief low hover does not end the flight', () { + final FlightSession s = armed(); + s.onFix(fix(0)); + s.onFix(fix(10, airborne: false, height: 0.5)); + s.onFix(fix(20, airborne: true)); + s.onFix(fix(30, airborne: false, height: 0)); + s.onFix(fix(40, airborne: false, height: 0)); + expect(s.phase, SessionPhase.active); + }); + + test('Remote ID silence stops the flight at last contact', () { + final FlightSession s = armed(); + s.onFix(fix(0)); + s.onFix(fix(30)); + expect(s.onTick(t0.add(const Duration(seconds: 60))), isFalse); + expect(s.onTick(t0.add(const Duration(seconds: 91))), isTrue); + expect(s.endedAt, t0.add(const Duration(seconds: 30))); + expect(s.alerts.single.code, 'rid_lost'); + }); + + test('tracks max height, distance and a single height alert', () { + final FlightSession s = armed(); + s.onFix(fix(0, height: 50, lat: 55.0)); + s.onFix(fix(1, height: 125, lat: 55.001)); + s.onFix(fix(2, height: 130, lat: 55.002)); + expect(s.maxHeight, 130); + expect(s.distance, closeTo(222, 2)); + expect(s.alerts.where((SessionAlert a) => a.code == 'height').length, 1); + }); + + test('flight input carries the § 5 manual fields and a GeoJSON route', () { + final FlightSession s = armed() + ..purpose = 'commercial' + ..category = 'open' + ..weather = '12°C'; + s.onFix(fix(0, lat: 55.0)); + s.onFix(fix(60, lat: 55.001, height: 80)); + s.stop(t0.add(const Duration(minutes: 12))); + final Map j = s.toFlightInput(); + expect(j['operationDate'], '2026-10-22'); + expect(j['startTime'], '14:05'); + expect(j['endTime'], '14:17'); + expect(j['drone'], 'd1'); + expect(j['maxAltitudeAgl'], 80.0); + expect(j['loggingPath'], 'manual'); + expect((j['areaRoute'] as String), contains('Remote ID track')); + expect(j['routeGeojson']['type'], 'LineString'); + expect((j['routeGeojson']['coordinates'] as List).first, [12.0, 55.0, 30.0]); + expect(j['notes'], contains('serial SN1')); + }); + + test('survives a round trip through persistence', () { + final FlightSession s = armed(); + s.onFix(fix(0)); + s.onFix(fix(30, height: 140, lat: 55.001)); + final FlightSession r = FlightSession.decode(s.encode()); + expect(r.phase, SessionPhase.active); + expect(r.track.length, 2); + expect(r.maxHeight, 140); + expect(r.alerts.single.code, 'height'); + expect(r.startedAt, s.startedAt); + }); +}