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 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-22 19:25:52 +02:00
1 parent 415c6184b1
commit 3dd31b257a
55 files changed
+5716

No files matched your search

+45
View File
@@ -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
+30
View File
@@ -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'
+64
View File
@@ -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-<serial>` | `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).
+28
View File
@@ -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
+14
View File
@@ -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
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Remote ID over Bluetooth LE. BLUETOOTH_SCAN deliberately has no
neverForLocation flag: the scan results ARE used to derive locations,
and that flag makes Android strip beacon advertisements. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Remote ID over Wi-Fi Beacon (scan results) and Wi-Fi NaN (Aware). -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Phone GPS (pilot position) and, below Android 12/13, the gate for BLE/Wi-Fi scanning. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Keeps scanning while DJI Fly is in front. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<application
android:label="PV Fly Agent"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".RidScanService"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- To offer "Open DJI Fly" from the session screen. -->
<package android:name="dji.go.v5" />
</queries>
</manifest>
@@ -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<ScanResult>) = results.forEach(::handle)
override fun onScanFailed(errorCode: Int) {
running = false
RidHub.status(TRANSPORT_LEGACY, "failed", "scan error $errorCode")
}
}
fun capabilities(): Map<String, Boolean> {
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))
}
}
@@ -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
}
}
@@ -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<String, Any>) {
main.post { sink?.success(event) }
}
}
@@ -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()
}
}
@@ -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<String, Boolean> = 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<ByteArray>?,
) {
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))
}
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+10
View File
@@ -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
@@ -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
+26
View File
@@ -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")
Binary file not shown.
Binary file not shown.
+235
View File
@@ -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<String, dynamic> 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<String> redFlags;
factory Flight.fromJson(Map<String, dynamic> 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 <String>[],
);
}
/// 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<void> init() async {
_prefs = await SharedPreferences.getInstance();
_token = _prefs!.getString('api_token') ?? '';
_email = _prefs!.getString('api_email') ?? '';
if (isAuthed) unawaited(refreshMe());
}
Future<void> 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: <String, String>{'Content-Type': 'application/json'},
body: jsonEncode(<String, String>{'email': email.trim(), 'password': password}),
),
);
if (resp.statusCode == 400) throw const ApiException('Invalid email or password.', status: 400);
final Map<String, dynamic> 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<void> signOut() async {
_token = '';
_email = '';
me = null;
await _prefs?.remove('api_token');
await _prefs?.remove('api_email');
notifyListeners();
}
Future<void> refreshMe() async {
try {
final Map<String, dynamic> 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<List<Drone>> drones() async {
final Map<String, dynamic> j = await get('/api/drones');
return ((j['drones'] as List?) ?? const <Object?>[])
.map((Object? e) => Drone.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
Future<List<Flight>> flights() async {
final Map<String, dynamic> j = await get('/api/flights');
return ((j['flights'] as List?) ?? const <Object?>[])
.map((Object? e) => Flight.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
Future<Drone> createDrone({required String name, required String model, required String serial}) async {
final Map<String, dynamic> j =
await post('/api/drones', <String, dynamic>{'name': name, 'model': model, 'serial': serial});
return Drone.fromJson((j['drone'] as Map).cast<String, dynamic>());
}
/// One-line weather for the briefing and the logbook entry, or null when the
/// OpenWeather integration is off for this user.
Future<String?> weatherSummary(double lat, double lon) async {
final Map<String, dynamic> j =
await get('/api/integrations/openweather/current?lat=${lat.toStringAsFixed(5)}&lon=${lon.toStringAsFixed(5)}');
final Map<String, dynamic>? w = (j['weather'] as Map?)?.cast<String, dynamic>();
if (j['unavailable'] == true || w == null) return null;
final bool metric = (j['units'] ?? 'metric') != 'imperial';
final List<String> parts = <String>[
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<String> createFlight(Map<String, dynamic> flight) async {
final Map<String, dynamic> j = await post('/api/flights', flight);
return ((j['flight'] as Map?)?['id'] ?? '').toString();
}
// ── transport ──────────────────────────────────────────────────────────
Map<String, String> get _headers => <String, String>{
'Authorization': _token,
'Content-Type': 'application/json',
};
Future<Map<String, dynamic>> get(String path) async =>
_decode(await _send(() => http.get(Uri.parse('$serverUrl$path'), headers: _headers)));
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async => _decode(
await _send(() => http.post(Uri.parse('$serverUrl$path'), headers: _headers, body: jsonEncode(body))),
);
Future<http.Response> _send(Future<http.Response> 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<String, dynamic> _decode(http.Response resp) {
Map<String, dynamic> body = <String, dynamic>{};
if (resp.body.isNotEmpty) {
try {
body = (jsonDecode(resp.body) as Map).cast<String, dynamic>();
} 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;
}
}
+53
View File
@@ -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<void> 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(),
),
);
}
}
+50
View File
@@ -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<Position>? _sub;
Future<void> 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();
}
}
+284
View File
@@ -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<OdidMessage> decodeOdid(Uint8List payload) {
if (payload.isEmpty) return const <OdidMessage>[];
final int type = payload[0] >> 4;
if (type != 0xF) {
final OdidMessage? m = _decodeOne(payload, 0);
return m == null ? const <OdidMessage>[] : <OdidMessage>[m];
}
// Pack: [header][message size][count][count × message size]
if (payload.length < 3) return const <OdidMessage>[];
final int size = payload[1];
final int count = payload[2];
if (size != odidMessageSize) return const <OdidMessage>[];
final List<OdidMessage> out = <OdidMessage>[];
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<double?> _horizontalAccuracy = <double?>[
null, 18520, 7408, 3704, 1852, 926, 555.6, 185.2, 92.6, 30, 10, 3, 1, null, null, null,
];
const List<double?> _verticalAccuracy = <double?>[
null, 150, 45, 25, 10, 3, 1, null, null, null, null, null, null, null, null, null,
];
+234
View File
@@ -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:<radio address>`.
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<String> transports = <String>{};
final Set<String> sources = <String>{};
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<String, RidAircraft> _byKey = <String, RidAircraft>{};
final Map<String, String> _keyBySource = <String, String>{};
Iterable<RidAircraft> 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<OdidMessage> 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<String> stale = _byKey.entries
.where((MapEntry<String, RidAircraft> e) => e.key != keep && now.difference(e.value.lastSeen) > maxAge)
.map((MapEntry<String, RidAircraft> e) => e.key)
.toList();
for (final String k in stale) {
_byKey.remove(k);
_keyBySource.removeWhere((String _, String v) => v == k);
}
}
}
+224
View File
@@ -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<String, dynamic> toJson() => <String, dynamic>{'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<String, TransportStatus> transports = <String, TransportStatus>{};
Map<String, bool> capabilities = <String, bool>{};
bool running = false;
int framesReceived = 0;
Timer? _pruneTimer;
DateTime _lastNotify = DateTime.fromMillisecondsSinceEpoch(0);
bool _notifyQueued = false;
RidPairing? _pairing;
RidPairing? get pairing => _pairing;
final StreamController<RidAircraft> _pairedUpdates = StreamController<RidAircraft>.broadcast();
/// Every frame that updated the paired aircraft's position.
Stream<RidAircraft> get pairedLocations => _pairedUpdates.stream;
RidAircraft? get paired {
final String? s = _pairing?.serial;
return s == null ? null : _registry.bySerial(s);
}
List<RidAircraft> get aircraft {
final List<RidAircraft> list = _registry.aircraft.toList()
..sort((RidAircraft a, RidAircraft b) => b.lastSeen.compareTo(a.lastSeen));
return list;
}
Future<void> 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<Map<Object?, Object?>>('capabilities')) ?? <Object?, Object?>{})
.map((Object? k, Object? v) => MapEntry<String, bool>(k.toString(), v == true));
running = await _methods.invokeMethod<bool>('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<List<String>> requestPermissions() async {
final Map<Permission, PermissionStatus> result = await <Permission>[
Permission.locationWhenInUse,
Permission.bluetoothScan,
Permission.nearbyWifiDevices,
Permission.notification,
].request();
return result.entries
.where((MapEntry<Permission, PermissionStatus> e) =>
e.value.isDenied || e.value.isPermanentlyDenied)
.map((MapEntry<Permission, PermissionStatus> e) => _permissionLabel(e.key))
.toList();
}
Future<void> start() async {
await requestPermissions();
await _methods.invokeMethod<bool>('start');
running = true;
notifyListeners();
}
Future<void> stop() async {
await _methods.invokeMethod<bool>('stop');
running = false;
transports.clear();
notifyListeners();
}
Future<bool> isIgnoringBatteryOptimizations() async =>
await _methods.invokeMethod<bool>('isIgnoringBatteryOptimizations') ?? false;
Future<void> requestIgnoreBatteryOptimizations() => _methods.invokeMethod<void>('requestIgnoreBatteryOptimizations');
/// Brings DJI Fly to the front. False when it is not installed.
Future<bool> openDjiFly() async => await _methods.invokeMethod<bool>('openDjiFly') ?? false;
Future<void> pair(RidPairing p) async {
_pairing = p;
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('rid_pairing', jsonEncode(p.toJson()));
notifyListeners();
}
Future<void> 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<OdidMessage> 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<String, Object> 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';
}
}
+32
View File
@@ -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<Services>()!;
@override
bool updateShouldNotify(Services oldWidget) => false;
}
@@ -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<Object?> toJson() => <Object?>[at.millisecondsSinceEpoch, lat, lon, height];
static TrackPoint fromJson(List<Object?> 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<String, Object> toJson() => <String, Object>{'at': at.millisecondsSinceEpoch, 'code': code, 'message': message};
static SessionAlert fromJson(Map<String, dynamic> 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<TrackPoint> track = <TrackPoint>[];
final List<SessionAlert> alerts = <SessionAlert>[];
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<String, dynamic> toFlightInput() {
final DateTime s = (startedAt ?? armedAt ?? DateTime.now()).toLocal();
final DateTime e = (endedAt ?? DateTime.now()).toLocal();
final List<String> alertLines = alerts.map((SessionAlert a) => '${_hhmm(a.at.toLocal())} ${a.message}').toList();
final String incidentText = <String>[
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 <String, dynamic>{
'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': <String>[if (notes.trim().isNotEmpty) notes.trim(), source].join('\n'),
};
}
Map<String, dynamic> routeGeoJson() => <String, dynamic>{
'type': 'LineString',
'coordinates': track
.map((TrackPoint p) => <double>[
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(<String, dynamic>{
'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<String, dynamic> j = (jsonDecode(raw) as Map).cast<String, dynamic>();
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 <Object?>[]).map((Object? e) => TrackPoint.fromJson(e! as List<Object?>)));
s.alerts.addAll(((j['alerts'] as List?) ?? const <Object?>[])
.map((Object? e) => SessionAlert.fromJson((e! as Map).cast<String, dynamic>())));
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));
}
@@ -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<String, dynamic> 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<PendingFlight> pending = <PendingFlight>[];
StreamSubscription<RidAircraft>? _sub;
Timer? _tick;
DateTime _lastSave = DateTime.fromMillisecondsSinceEpoch(0);
SharedPreferences? _prefs;
bool filing = false;
Future<void> 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<Object?>) {
final Map<String, dynamic> m = (e! as Map).cast<String, dynamic>();
pending.add(PendingFlight('${m['id']}', (m['body'] as Map).cast<String, dynamic>(), '${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<String?> file() async {
final Map<String, dynamic> 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<int> retryPending() async {
int ok = 0;
for (final PendingFlight p in List<PendingFlight>.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) => <String, dynamic>{'id': p.id, 'body': p.body, 'error': p.error})
.toList()),
);
}
@override
void dispose() {
_save(force: true);
_sub?.cancel();
_tick?.cancel();
super.dispose();
}
}
+115
View File
@@ -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>[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>[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),
);
}
+201
View File
@@ -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<BriefingPage> createState() => _BriefingPageState();
}
class _BriefingPageState extends State<BriefingPage> {
static const List<String> checklist = <String>[
'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<List<Drone>>? _drones;
String? _droneId;
String _purpose = 'commercial';
String _category = 'open';
final TextEditingController _area = TextEditingController();
String? _weather;
String? _weatherNote;
bool _weatherLoading = false;
final Set<int> _checked = <int>{};
@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<void> _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<Drone> 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<List<Drone>>(
future: _drones,
builder: (BuildContext context, AsyncSnapshot<List<Drone>> 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<Drone> 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: <Widget>[
const SectionLabel('Drone'),
DropdownButtonFormField<String>(
initialValue: _droneId,
isExpanded: true,
hint: const Text('Choose a drone'),
items: <DropdownMenuItem<String>>[
for (final Drone d in drones)
DropdownMenuItem<String>(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: <Widget>[
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _purpose,
decoration: const InputDecoration(labelText: 'Purpose'),
items: purposeLabels.entries
.map((MapEntry<String, String> e) => DropdownMenuItem<String>(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (String? v) => setState(() => _purpose = v ?? _purpose),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _category,
decoration: const InputDecoration(labelText: 'Category'),
items: categoryLabels.entries
.map((MapEntry<String, String> e) => DropdownMenuItem<String>(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: <Widget>[
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),
],
);
},
),
);
}
}
+457
View File
@@ -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(<Listenable>[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: <Widget>[
_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<void>(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) ...<Widget>[
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: <Widget>[
StatusDot(dot, size: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
Card(
color: active ? PV.navy : PV.surface1,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
_Stat('HEIGHT', fmtMeters(a?.height)),
_Stat('DISTANCE', fmtDistance(distFromPilot)),
_Stat('SPEED', fmtSpeed(a?.speedHorizontal)),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
_Stat('MAX HEIGHT', fmtMeters(f.maxHeight)),
_Stat('FLOWN', fmtDistance(f.distance)),
_Stat('RID', a == null ? 'none' : fmtAgo(a.lastSeen)),
],
),
if (f.alerts.isNotEmpty) ...<Widget>[
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: <Widget>[
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<void> _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: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
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<void> _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<void> _confirm(BuildContext context, String title, String body, VoidCallback onYes) async {
final bool? ok = await showDialog<bool>(
context: context,
builder: (BuildContext c) => AlertDialog(
title: Text(title),
content: Text(body),
actions: <Widget>[
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Keep')),
FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('Yes')),
],
),
);
if (ok == true) onYes();
}
+54
View File
@@ -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<String, String> purposeLabels = <String, String>{
'commercial': 'Commercial',
'hobby': 'Hobby',
'research': 'Research',
'public': 'Public service',
'club_area': 'Club area',
};
const Map<String, String> categoryLabels = <String, String>{
'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,
};
+116
View File
@@ -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<LogbookTab> createState() => _LogbookTabState();
}
class _LogbookTabState extends State<LogbookTab> {
Future<List<Flight>>? _flights;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_flights ??= Services.of(context).api.flights();
}
Future<void> _refresh() async {
final SessionController c = Services.of(context).sessions;
if (c.pending.isNotEmpty) await c.retryPending();
if (!mounted) return;
final Future<List<Flight>> f = Services.of(context).api.flights();
setState(() => _flights = f);
await f.catchError((Object _) => <Flight>[]);
}
@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: <Widget>[
if (c.pending.isNotEmpty) ...<Widget>[
Row(
children: <Widget>[
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<List<Flight>>(
future: _flights,
builder: (BuildContext context, AsyncSnapshot<List<Flight>> 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<Flight> flights = snap.data!;
if (flights.isEmpty) return const Text('No flights logged yet.', style: PV.caption);
return Column(
children: <Widget>[
for (final Flight f in flights.take(50)) ...<Widget>[
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),
],
],
);
},
),
],
),
),
);
}
}
+120
View File
@@ -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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
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<void> _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: <Widget>[
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) ...<Widget>[
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'),
),
],
),
),
),
),
),
);
}
}
+363
View File
@@ -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(<Listenable>[s.rid, s.pilot]),
builder: (BuildContext context, _) {
final RidService rid = s.rid;
final Position? me = s.pilot.position;
final List<RidAircraft> list = rid.aircraft;
return ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_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) ...<Widget>[
_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: <Widget>[
Row(
children: <Widget>[
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 <String>['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: <Widget>[
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<void>(
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: <Widget>[
Row(
children: <Widget>[
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(
<String>[
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: <Widget>[
_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: <Widget>[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<List<Drone>>? _drones;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_drones ??= Services.of(context).api.drones();
}
Future<void> _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<void> _createAndPair() async {
final TextEditingController name = TextEditingController();
final TextEditingController model = TextEditingController();
final bool? ok = await showDialog<bool>(
context: context,
builder: (BuildContext c) => AlertDialog(
title: const Text('New drone'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Text(a.label, style: PV.mono.copyWith(fontSize: 16)),
const SizedBox(height: 4),
Text(
<String>[
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) ...<Widget>[
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 ...<Widget>[
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<List<Drone>>(
future: _drones,
builder: (BuildContext context, AsyncSnapshot<List<Drone>> 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<Drone> 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: <Widget>[
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,
),
],
),
);
},
),
],
],
),
),
);
}
}
+139
View File
@@ -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<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
bool? _batteryExempt;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkBattery();
}
Future<void> _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(<Listenable>[api, s.uplink, s.rid]),
builder: (BuildContext context, _) => ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
const SectionLabel('Account'),
Card(
child: Column(
children: <Widget>[
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(api.me?.email ?? api.email),
subtitle: Text(<String>[
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: <Widget>[
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: <Widget>[
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<void>.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',
};
}
+98
View File
@@ -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<AgentShell> createState() => _AgentShellState();
}
class _AgentShellState extends State<AgentShell> {
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<String> titles = <String>['Flight', 'Nearby aircraft', 'Logbook'];
return Scaffold(
appBar: AppBar(
title: Text(titles[_tab]),
actions: <Widget>[
const _UplinkChip(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const SettingsPage())),
),
],
),
body: IndexedStack(index: _tab, children: const <Widget>[FlightTab(), NearbyTab(), LogbookTab()]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (int i) => setState(() => _tab = i),
destinations: const <NavigationDestination>[
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: <Widget>[StatusDot(c), const SizedBox(width: 6), Text(t, style: PV.label)]),
),
);
},
);
}
}
+81
View File
@@ -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<TrackPoint> track;
final LatLng? aircraft;
final LatLng? pilot;
final double height;
@override
Widget build(BuildContext context) {
final List<LatLng> line = track.map((TrackPoint p) => LatLng(p.lat, p.lon)).toList();
final List<LatLng> all = <LatLng>[...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<int>(all.length ~/ 10),
options: options,
children: <Widget>[
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.pilotvault.flyagent',
),
if (line.length >= 2)
PolylineLayer(polylines: <Polyline>[Polyline(points: line, strokeWidth: 3, color: PV.accent)]),
MarkerLayer(markers: <Marker>[
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),
),
),
]),
],
),
),
);
}
}
+217
View File
@@ -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<String, dynamic> ridTelemetryEvent(RidAircraft a, {double? phoneLat, double? phoneLon, DateTime? now}) {
final double? speed = a.speedHorizontal;
final double? track = a.track;
final Map<String, dynamic> e = <String, dynamic>{
'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-<serial>`, 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<RidAircraft>? _sub;
DateTime _lastSent = DateTime.fromMillisecondsSinceEpoch(0);
bool _announced = false;
final Queue<String> _buffer = Queue<String>();
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(<String, dynamic>{'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(<String, dynamic>{
'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(<String, dynamic>{'type': 'connection', 'connected': false, 'reason': 'remote-id-lost'});
_announced = false;
}
}
void _send(Map<String, dynamic> 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<void> _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: <String, String>{'id': deviceId},
);
}
+778
View File
@@ -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"
+45
View File
@@ -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
+90
View File
@@ -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<int> 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<int> s = id.codeUnits;
for (int i = 0; i < s.length && i < 20; i++) {
b[2 + i] = s[i];
}
return b;
}
Uint8List pack(List<Uint8List> messages) {
final BytesBuilder b = BytesBuilder()
..addByte(0xF2)
..addByte(25)
..addByte(messages.length);
for (final Uint8List m in messages) {
b.add(m);
}
return b.toBytes();
}
+136
View File
@@ -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<OdidMessage> m = decodeOdid(basicId('1581F5FKD229400B0123'));
expect(m.single, isA<OdidBasicId>());
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<OdidMessage> m = decodeOdid(pack(<Uint8List>[basicId('SN1'), location(), operatorId('DNK-OP-123')]));
expect(m.map((OdidMessage e) => e.runtimeType).toList(), <Type>[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(<int>[0x12, 0x20, 0x01])), isEmpty);
final Uint8List p = pack(<Uint8List>[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(<Uint8List>[basicId('SN1'), location()])));
r.ingest(transport: 'beacon', source: 'BB', rssi: -70, at: t0, messages: decodeOdid(pack(<Uint8List>[basicId('SN1'), location()])));
expect(r.aircraft.length, 1);
expect(r.bySerial('SN1')!.transports, <String>{'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), <String>['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(<Uint8List>[basicId('SN1'), location(track: 90, speed: 4, vSpeed: 2), system()])),
);
final Map<String, dynamic> 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');
});
}
+97
View File
@@ -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<String, dynamic> 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, <double>[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);
});
}