From 3dd31b257aa05019b7e256feac637bec0d5dd5c4 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:25:52 +0200 Subject: [PATCH] Add the Fly Agent App, a Remote ID companion to DJI Fly A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground service, streams the paired aircraft to the hub over /ws/device, and turns take-off to landing into a logbook entry filed through /api/flights, queued when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change. Co-Authored-By: Claude Opus 5.5 --- Fly Agent App/.gitignore | 45 + Fly Agent App/.metadata | 30 + Fly Agent App/README.md | 64 ++ Fly Agent App/analysis_options.yaml | 28 + Fly Agent App/android/.gitignore | 14 + Fly Agent App/android/app/build.gradle.kts | 45 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 76 ++ .../com/pilotvault/flyagent/BleRidScanner.kt | 110 +++ .../com/pilotvault/flyagent/MainActivity.kt | 71 ++ .../kotlin/com/pilotvault/flyagent/RidHub.kt | 50 ++ .../com/pilotvault/flyagent/RidScanService.kt | 98 +++ .../com/pilotvault/flyagent/WifiRidScanner.kt | 197 +++++ .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + Fly Agent App/android/build.gradle.kts | 24 + Fly Agent App/android/gradle.properties | 10 + .../gradle/wrapper/gradle-wrapper.properties | 5 + Fly Agent App/android/settings.gradle.kts | 26 + .../fonts/SpaceGrotesk-VariableFont_wght.ttf | Bin 0 -> 136676 bytes Fly Agent App/assets/fonts/SpaceMono-Bold.ttf | Bin 0 -> 98232 bytes .../assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 99356 bytes Fly Agent App/lib/api/api.dart | 235 ++++++ Fly Agent App/lib/main.dart | 53 ++ Fly Agent App/lib/pilot_location.dart | 50 ++ Fly Agent App/lib/rid/odid_decoder.dart | 284 +++++++ Fly Agent App/lib/rid/rid_aircraft.dart | 234 ++++++ Fly Agent App/lib/rid/rid_service.dart | 224 +++++ Fly Agent App/lib/services.dart | 32 + Fly Agent App/lib/session/flight_session.dart | 322 ++++++++ .../lib/session/session_controller.dart | 208 +++++ Fly Agent App/lib/theme.dart | 115 +++ Fly Agent App/lib/ui/briefing_page.dart | 201 +++++ Fly Agent App/lib/ui/flight_tab.dart | 457 ++++++++++ Fly Agent App/lib/ui/format.dart | 54 ++ Fly Agent App/lib/ui/logbook_tab.dart | 116 +++ Fly Agent App/lib/ui/login_page.dart | 120 +++ Fly Agent App/lib/ui/nearby_tab.dart | 363 ++++++++ Fly Agent App/lib/ui/settings_page.dart | 139 ++++ Fly Agent App/lib/ui/shell.dart | 98 +++ Fly Agent App/lib/ui/track_map.dart | 81 ++ Fly Agent App/lib/uplink/uplink.dart | 217 +++++ Fly Agent App/pubspec.lock | 778 ++++++++++++++++++ Fly Agent App/pubspec.yaml | 45 + Fly Agent App/test/odid_fixtures.dart | 90 ++ Fly Agent App/test/rid_test.dart | 136 +++ Fly Agent App/test/session_test.dart | 97 +++ 55 files changed, 5716 insertions(+) create mode 100644 Fly Agent App/.gitignore create mode 100644 Fly Agent App/.metadata create mode 100644 Fly Agent App/README.md create mode 100644 Fly Agent App/analysis_options.yaml create mode 100644 Fly Agent App/android/.gitignore create mode 100644 Fly Agent App/android/app/build.gradle.kts create mode 100644 Fly Agent App/android/app/src/debug/AndroidManifest.xml create mode 100644 Fly Agent App/android/app/src/main/AndroidManifest.xml create mode 100644 Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/BleRidScanner.kt create mode 100644 Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/MainActivity.kt create mode 100644 Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidHub.kt create mode 100644 Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/RidScanService.kt create mode 100644 Fly Agent App/android/app/src/main/kotlin/com/pilotvault/flyagent/WifiRidScanner.kt create mode 100644 Fly Agent App/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 Fly Agent App/android/app/src/main/res/drawable/launch_background.xml create mode 100644 Fly Agent App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Fly Agent App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Fly Agent App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Fly Agent App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Fly Agent App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Fly Agent App/android/app/src/main/res/values-night/styles.xml create mode 100644 Fly Agent App/android/app/src/main/res/values/styles.xml create mode 100644 Fly Agent App/android/app/src/profile/AndroidManifest.xml create mode 100644 Fly Agent App/android/build.gradle.kts create mode 100644 Fly Agent App/android/gradle.properties create mode 100644 Fly Agent App/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 Fly Agent App/android/settings.gradle.kts create mode 100644 Fly Agent App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf create mode 100644 Fly Agent App/assets/fonts/SpaceMono-Bold.ttf create mode 100644 Fly Agent App/assets/fonts/SpaceMono-Regular.ttf create mode 100644 Fly Agent App/lib/api/api.dart create mode 100644 Fly Agent App/lib/main.dart create mode 100644 Fly Agent App/lib/pilot_location.dart create mode 100644 Fly Agent App/lib/rid/odid_decoder.dart create mode 100644 Fly Agent App/lib/rid/rid_aircraft.dart create mode 100644 Fly Agent App/lib/rid/rid_service.dart create mode 100644 Fly Agent App/lib/services.dart create mode 100644 Fly Agent App/lib/session/flight_session.dart create mode 100644 Fly Agent App/lib/session/session_controller.dart create mode 100644 Fly Agent App/lib/theme.dart create mode 100644 Fly Agent App/lib/ui/briefing_page.dart create mode 100644 Fly Agent App/lib/ui/flight_tab.dart create mode 100644 Fly Agent App/lib/ui/format.dart create mode 100644 Fly Agent App/lib/ui/logbook_tab.dart create mode 100644 Fly Agent App/lib/ui/login_page.dart create mode 100644 Fly Agent App/lib/ui/nearby_tab.dart create mode 100644 Fly Agent App/lib/ui/settings_page.dart create mode 100644 Fly Agent App/lib/ui/shell.dart create mode 100644 Fly Agent App/lib/ui/track_map.dart create mode 100644 Fly Agent App/lib/uplink/uplink.dart create mode 100644 Fly Agent App/pubspec.lock create mode 100644 Fly Agent App/pubspec.yaml create mode 100644 Fly Agent App/test/odid_fixtures.dart create mode 100644 Fly Agent App/test/rid_test.dart create mode 100644 Fly Agent App/test/session_test.dart 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a1b2e6c26093066510a31147e7aec9abdc8d6c5e GIT binary patch literal 136676 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 IH;+H=zZFcdV*mgE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2c4f2682f915d988e7314544ff7c9e38c8c733f5 GIT binary patch literal 98232 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

of^bo5Zce-mS`ZNCP^RoqUeue-;SW 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 O#a@p$^!B9uJM~X~{*CMa literal 0 HcmV?d00001 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); + }); +}