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:
1 parent
415c6184b1
commit
3dd31b257a
55 files changed
+5716
No files matched your search
@@ -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
|
||||
@@ -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'
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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)]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user