Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones

Vision radar: graded obstacle sectors around the centre of the video,
the nearest distance per side, the obstacle overhead, and BLOCKED on a
side whose sensors cannot see once the motors run. On aircraft with 360°
sensing, sides without their own readings are filled from the
perception ring. The same sectors sit on the attitude ball's rim, turned
by the heading. Settings > Vision gains Display radar chart and the
braking / sensing distances per direction.

On-screen sticks: the rail's STICKS button hands the aircraft to two
stick pads after a slide-to-confirm. The 25 Hz stream runs natively and
sends zeros once Dart's last command is 300 ms old, so a stalled UI
leaves the aircraft hovering. The sticks close when the remote takes
over, on RTH or on landing. They are simulator-only on aircraft with a
remote unless Settings > General > Developer allows real flights; Spark
and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward
in velocity + body mode is coded from DJI's documentation and must be
checked in the simulator first.

AirSense: a banner for manned aircraft at warning level 2 and up, with
distance, direction and ICAO code; the planes on the map; the full list
in a sheet and Settings > Safety; and the aircraft's own avoidance
action with its countdown.

Fly zones: DJI GEO zones around the aircraft (or the phone) on the map
with their height limits, a banner for FlySafe notices and in/near-zone
states, take-off refused in restricted and locked authorization zones,
self-unlock of authorization zones, the aircraft's licences (enable,
sync from the DJI account, remove all; ground only), and the GEO
database version with an update button.

Tested with unit and widget tests and screenshot renders only; nothing
has run on an aircraft or the simulator yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 21:14:12 +02:00
1 parent 93f4a580e9
commit e02bb0e5cd
34 files changed
+4376 -15

No files matched your search

+33
View File
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'virtual_sticks.dart' show StickMode;
/// The video overlay grid (camera menu → Other, and the rail's GRID button).
enum GridStyle {
off('Off'),
@@ -31,6 +33,9 @@ abstract final class FlyPrefs {
static const String _longPressKey = 'fly.longPress';
static const String _routeKey = 'fly.showRoute';
static const String _termsKey = 'fly.modeTerms';
static const String _radarKey = 'fly.radar';
static const String _stickModeKey = 'fly.stickMode';
static const String _sticksRealKey = 'fly.sticksRealFlight';
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
@@ -50,6 +55,16 @@ abstract final class FlyPrefs {
/// Intelligent flight modes whose first-use terms the pilot accepted (plan P7), by name.
static final ValueNotifier<Set<String>> acceptedModeTerms = ValueNotifier<Set<String>>(<String>{});
/// Draw the vision radar on the video (GO 4's "Display Radar Chart", plan P8).
static final ValueNotifier<bool> showRadar = ValueNotifier<bool>(true);
/// What each on-screen stick does (plan P8); matches the remote's stick mode by default.
static final ValueNotifier<StickMode> stickMode = ValueNotifier<StickMode>(StickMode.mode2);
/// Developer switch: allow on-screen sticks outside the simulator on aircraft
/// that have a remote. Off by default (plan P8: simulator first).
static final ValueNotifier<bool> sticksInRealFlight = ValueNotifier<bool>(false);
static SharedPreferences? _prefs;
static Future<void> load() async {
@@ -62,6 +77,9 @@ abstract final class FlyPrefs {
longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter;
showRoute.value = _prefs!.getBool(_routeKey) ?? true;
acceptedModeTerms.value = (_prefs!.getStringList(_termsKey) ?? const <String>[]).toSet();
showRadar.value = _prefs!.getBool(_radarKey) ?? true;
stickMode.value = _byName(StickMode.values, _prefs!.getString(_stickModeKey)) ?? StickMode.mode2;
sticksInRealFlight.value = _prefs!.getBool(_sticksRealKey) ?? false;
} catch (_) {
// Storage unavailable: run on the defaults.
}
@@ -103,6 +121,21 @@ abstract final class FlyPrefs {
await _prefs?.setStringList(_termsKey, acceptedModeTerms.value.toList()..sort());
}
static Future<void> setShowRadar(bool on) async {
showRadar.value = on;
await _prefs?.setBool(_radarKey, on);
}
static Future<void> setStickMode(StickMode m) async {
stickMode.value = m;
await _prefs?.setString(_stickModeKey, m.name);
}
static Future<void> setSticksInRealFlight(bool on) async {
sticksInRealFlight.value = on;
await _prefs?.setBool(_sticksRealKey, on);
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
+94
View File
@@ -0,0 +1,94 @@
import 'dart:async';
import '../dji_service.dart';
import '../flight_model.dart';
/// Keeps [FlightModel.flyZones] stocked with the GEO zones around (plan P8):
/// fetched around the aircraft's fix, or the phone's before it has one, again
/// after moving 2 km or every 10 minutes; plus the unlocked list once per
/// connection with the aircraft's licences, and the GEO database's state at
/// start. One fetch at a time; failures wait for the next position.
class FlyZoneLoader {
FlyZoneLoader({required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
bool _busy = false;
bool _unlockedLoaded = false;
void start() {
model.aircraft.addListener(_check);
model.addListener(_onModel);
unawaited(refreshDatabase());
_check();
}
void dispose() {
model.aircraft.removeListener(_check);
model.removeListener(_onModel);
}
void _onModel() {
if (!model.connected) _unlockedLoaded = false;
_check();
}
/// The aircraft's fix, else the phone's.
(double, double)? get _position {
final double? lat = model.aircraft.latitude, lon = model.aircraft.longitude;
if (lat != null && lon != null && !(lat == 0 && lon == 0)) return (lat, lon);
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
if (pLat != null && pLon != null) return (pLat, pLon);
return null;
}
void _check() {
if (model.connected && !_unlockedLoaded) {
_unlockedLoaded = true;
unawaited(refreshUnlocked());
unawaited(refreshLicences());
}
final (double, double)? p = _position;
if (p == null || _busy || !model.flyZones.needsZonesFor(p.$1, p.$2)) return;
unawaited(_fetch(p));
}
Future<void> _fetch((double, double) p) async {
_busy = true;
try {
final List<Map<String, dynamic>> zones = await dji.getFlyZonesNearby(latitude: p.$1, longitude: p.$2);
model.flyZones.applyZones(zones, latitude: p.$1, longitude: p.$2);
} catch (_) {
// Database not ready or offline: retry when the position moves on.
} finally {
_busy = false;
}
}
/// The aircraft's licences (custom unlock zones).
Future<void> refreshLicences() async {
try {
model.flyZones.applyLicences(await dji.getFlyZoneLicences());
} catch (_) {
// Not reported by this aircraft.
}
}
/// The GEO database's versions and state (app-level, no aircraft needed).
Future<void> refreshDatabase() async {
try {
model.flyZones.applyDatabase(await dji.getFlyZoneDatabase());
} catch (_) {
// SDK not registered yet.
}
}
Future<void> refreshUnlocked() async {
try {
model.flyZones.applyUnlocked(await dji.getUnlockedFlyZones());
} catch (_) {
// Not reported by this aircraft.
}
}
}
+8
View File
@@ -95,6 +95,12 @@ abstract final class HudFeatures {
/// Precision take-off records the spot with the downward cameras.
static final HudFeature precisionTakeoff = HudFeature(
'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps);
/// ADS-B In: the receiver's own reports decide at runtime; the profile only rules out non-aircraft.
static final HudFeature airSense = HudFeature(
'overlay.airSense', 'AirSense (ADS-B) warnings', 'Overlays', (ProductProfile p) => p.isAircraft);
/// DJI GEO zones: the database is app-level, every aircraft gets it.
static final HudFeature flyZones = HudFeature(
'overlay.flyZones', 'Fly zones (GEO) warnings + map', 'Overlays', (ProductProfile p) => p.isAircraft);
static final HudFeature simulator = HudFeature(
'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator);
@@ -174,6 +180,8 @@ abstract final class HudFeatures {
vpsReadout,
visionRadar,
virtualSticks,
airSense,
flyZones,
precisionTakeoff,
simulator,
settingsVision,
+160
View File
@@ -8,13 +8,16 @@ import '../flight_model.dart';
import 'action_rail_state.dart';
import 'calibration.dart';
import 'flight_status.dart';
import 'fly_prefs.dart';
import 'hud_geometry.dart';
import 'modes/vision_modes.dart';
import 'modes/waypoint_mission.dart';
import 'product_profile.dart';
import 'state/aircraft_state.dart';
import 'state/alert_state.dart';
import 'state/fly_zone_state.dart';
import 'state/mission_state.dart';
import 'virtual_sticks.dart';
export 'action_rail_state.dart' show FlightCommand;
@@ -197,6 +200,93 @@ class SafetyGate {
if (model.profile.hasCamera && model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.warn('${model.camera.storage!.status}: photos and video will not be saved.'));
}
_flyZone(checks);
}
// ── Fly zones (plan P8) ─────────────────────────────────────────────────────
/// DJI's GEO system refuses take-off in restricted and locked authorization
/// zones on its own; saying so here keeps the slider from promising otherwise.
void _flyZone(List<GateCheck> checks) {
final FlyZoneState z = model.flyZones;
switch (z.state) {
case 'IN_RESTRICTED_ZONE':
case 'SUSPECTED_IN_RESTRICTED_ZONE':
checks.add(const GateCheck.block('The aircraft is in a restricted zone: take-off is not allowed here.'));
case 'IN_AUTHORIZED_ZONE':
case 'SUSPECTED_IN_AUTHORIZED_ZONE':
checks.add(const GateCheck.block('Authorization zone: unlock it first (Settings → Safety → Fly zones).'));
case 'IN_ENHANCE_WARNING_ZONE':
checks.add(const GateCheck.warn('Enhanced warning zone: check local rules before flying.'));
case 'IN_WARNING_ZONE':
checks.add(const GateCheck.warn('Warning zone: check local rules before flying.'));
case 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
case 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
checks.add(const GateCheck.warn('Height-limited zone: the aircraft will not climb above the zone\'s limit.'));
}
}
/// Switching or removing licences changes what the flight controller lets the
/// aircraft do, so it happens on the ground only.
List<GateCheck> checkLicenceChange() => <GateCheck>[
if (!model.connected) const GateCheck.block('Connect the aircraft to change its licences.'),
if (model.connected && _a.isFlying == true) const GateCheck.block('Land before changing licences.'),
];
Future<void> _licences(Future<void> Function() change) async {
final List<GateCheck> checks = checkLicenceChange();
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await change();
try {
model.flyZones.applyLicences(await dji.getFlyZoneLicences());
} catch (_) {
// The list refreshes on the next connection.
}
}
/// Enables [licence] (the aircraft keeps one enabled at a time) or disables it.
Future<void> setLicenceEnabled(FlyZoneLicence licence, bool enabled) async {
if (enabled && licence.expired) throw const MissionRefused(<GateCheck>[GateCheck.block('This licence has expired.')]);
await _licences(() => dji.setFlyZoneLicenceEnabled(licence.id, enabled));
}
/// Fetches the DJI account's licences and writes them to the aircraft.
Future<void> syncLicences() => _licences(dji.syncFlyZoneLicences);
/// Removes every licence from the aircraft.
Future<void> deleteAllLicences() => _licences(dji.deleteAllFlyZoneLicences);
/// Downloads the basic GEO database; the database state refreshes when it ends.
Future<void> upgradeFlyZoneDatabase() async {
model.flyZones.applyUpgradeProgress(0);
try {
await dji.startFlyZoneDatabaseUpgrade();
} finally {
model.flyZones.applyUpgradeProgress(null);
try {
model.flyZones.applyDatabase(await dji.getFlyZoneDatabase());
} catch (_) {
// Keeps the last known state.
}
}
}
Future<void> cancelFlyZoneDatabaseUpgrade() async {
await dji.stopFlyZoneDatabaseUpgrade();
model.flyZones.applyUpgradeProgress(null);
}
/// Self-unlocks an authorization zone (DJI account required). Unlocking is
/// the pilot declaring they may fly there, so the UI confirms it first.
Future<void> unlockFlyZone(FlyZone zone) async {
if (!zone.unlockable) throw const MissionRefused(<GateCheck>[GateCheck.block('Only authorization zones can be self-unlocked.')]);
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('Connect the aircraft to unlock a zone for it.')]);
await dji.unlockAuthorizationFlyZone(zone.id);
try {
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, ...await dji.getUnlockedFlyZones()});
} catch (_) {
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, zone.id});
}
}
void _goHome(List<GateCheck> checks) {
@@ -577,6 +667,76 @@ class SafetyGate {
await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed));
}
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
/// Preconditions for handing the aircraft to the on-screen sticks.
///
/// Simulator first: on an aircraft that has a remote, the sticks are refused
/// outside the simulator unless the developer switch allows them. Aircraft
/// whose primary control *is* the phone (Spark / Mavic Air on Wi-Fi) may fly
/// them for real, with a warning.
List<GateCheck> checkVirtualSticks() {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
final ProductProfile p = model.profile;
if (p.connected && !p.supportsVirtualStick) return checks..add(const GateCheck.block('This aircraft cannot be flown with on-screen sticks.'));
final String mode = _a.flightModeEnum ?? '';
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
switch (RailState.activeMode(_a, model.mission)) {
case ActiveMode.waypoint:
checks.add(const GateCheck.block('Stop the waypoint mission first.'));
case ActiveMode.activeTrack:
checks.add(const GateCheck.block('Stop ActiveTrack first.'));
case ActiveMode.tapFly:
checks.add(const GateCheck.block('Stop TapFly first.'));
case ActiveMode.other:
checks.add(const GateCheck.block('Stop the intelligent flight mode first.'));
case ActiveMode.none:
break;
}
final int? percent = model.battery.percent;
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
}
final bool simulating = _a.simulatorActive == true;
if (!simulating) {
if (!p.virtualStickPrimary && !FlyPrefs.sticksInRealFlight.value) {
checks.add(const GateCheck.block(
'On-screen sticks are for the simulator on this aircraft: start it in Settings → General → Developer, '
'or allow real flights there.'));
} else {
checks.add(const GateCheck.warn('This is a real flight: the aircraft moves as you push the sticks.'));
}
}
if (_a.isFlying != true) {
checks.add(const GateCheck.warn('The sticks do not start the motors: take off with TAKE OFF, then fly.'));
}
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel && !simulating) {
checks.add(const GateCheck.warn('Weak GPS: the aircraft drifts when the sticks are centred.'));
}
if (p.hasRcLink) {
checks.add(const GateCheck.warn("The remote's sticks are ignored meanwhile: flip its flight-mode switch to take back control."));
}
return checks;
}
/// Re-checks and puts the flight controller in virtual-stick mode.
Future<void> enableVirtualSticks() async {
final List<GateCheck> checks = checkVirtualSticks();
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setVirtualStickEnabled(true);
}
/// Hands control back to the remote. Always allowed.
Future<void> disableVirtualSticks() => dji.setVirtualStickEnabled(false);
/// One stick packet. Not re-checked per packet (25 Hz); the controller closes
/// the sticks when RTH, landing or the remote takes over.
Future<void> sendSticks(StickCommand c) => dji.sendVirtualStick(forward: c.forward, right: c.right, yaw: c.yaw, up: c.up);
/// True when the pilot stands far enough from the recorded home point that
/// RTH should ask whether to come back to them instead (GO 4's RTH target).
bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance;
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/foundation.dart';
import 'state/parse.dart';
/// The three directions MSDK's FlightAssistant sets distances for.
enum SensingDirection {
horizontal('Horizontal', 'Horizontal'),
up('Upward', 'Upward'),
down('Downward', 'Downward');
const SensingDirection(this.sdk, this.label);
/// `DJIFlightAssistantObstacleSensingDirection` name.
final String sdk;
final String label;
}
/// Which of the two distances.
enum SensingKind {
/// `setVisualObstaclesAvoidanceDistance`: where the aircraft brakes.
avoidance,
/// `setMaxPerceptionDistance`: how far it looks (the warning distance).
perception,
}
/// The aircraft's obstacle braking and perception distances (plan P8, GO 4 /
/// Pilot's Vision "obstacle distance" rows). A null value is a direction the
/// aircraft refused to report, and its row hides.
@immutable
class SensingDistances {
const SensingDistances({this.avoidance = const <SensingDirection, double?>{}, this.perception = const <SensingDirection, double?>{}});
factory SensingDistances.fromBridge(Map<String, dynamic> m) {
Map<SensingDirection, double?> read(Object? v) {
final Map<String, dynamic>? g = asMap(v);
return <SensingDirection, double?>{for (final SensingDirection d in SensingDirection.values) d: finite(g?[d.sdk])};
}
return SensingDistances(avoidance: read(m['avoidance']), perception: read(m['perception']));
}
final Map<SensingDirection, double?> avoidance;
final Map<SensingDirection, double?> perception;
double? value(SensingKind k, SensingDirection d) => (k == SensingKind.avoidance ? avoidance : perception)[d];
bool get isEmpty => avoidance.values.every((double? v) => v == null) && perception.values.every((double? v) => v == null);
SensingDistances withValue(SensingKind k, SensingDirection d, double v) => SensingDistances(
avoidance: k == SensingKind.avoidance ? <SensingDirection, double?>{...avoidance, d: v} : avoidance,
perception: k == SensingKind.perception ? <SensingDirection, double?>{...perception, d: v} : perception,
);
/// Range and step a row offers. MSDK 4.18 documents no limits; these follow
/// DJI Pilot's sliders and the flight controller refuses anything it does not
/// accept (the row then rolls back). Unverified on hardware.
static ({double min, double max, double step}) range(SensingKind k, SensingDirection d) => switch ((k, d)) {
(SensingKind.avoidance, SensingDirection.down) => (min: 0.5, max: 3, step: 0.1),
(SensingKind.avoidance, _) => (min: 1, max: 10, step: 0.5),
(SensingKind.perception, SensingDirection.down) => (min: 1, max: 10, step: 0.5),
(SensingKind.perception, _) => (min: 5, max: 40, step: 1),
};
/// [v] moved [steps] steps within the range, snapped to the step.
static double stepped(SensingKind k, SensingDirection d, double v, int steps) {
final ({double min, double max, double step}) r = range(k, d);
final double next = ((v / r.step).round() + steps) * r.step;
return double.parse(next.clamp(r.min, r.max).toStringAsFixed(2));
}
}
@@ -100,6 +100,13 @@ abstract final class SettingsTabs {
static const String stickDiagram = 'stickDiagram';
static const String gimbalList = 'gimbalList';
static const String showRoute = 'showRoute';
static const String radarChart = 'radarChart';
static const String stickPads = 'stickPads';
static const String airSenseList = 'airSenseList';
static const String sensingDistances = 'sensingDistances';
static const String flyZoneList = 'flyZoneList';
static const String flyZoneLicences = 'flyZoneLicences';
static const String flyZoneDatabase = 'flyZoneDatabase';
static const String clearRoute = 'clearRoute';
static const String about = 'about';
static const String developer = 'developer';
@@ -195,11 +202,18 @@ abstract final class SettingsTabs {
const KeySwitchSpec(id: 'escBeep', label: 'ESC beeping', key: SettingsKeys.escBeep),
const GroupSpec('SENSORS'),
const CustomSpec(id: sensors, keys: <DjiKey>[SettingsKeys.imuCount, SettingsKeys.compassCount]),
GroupSpec('AIRSENSE', requires: HudFeatures.airSense),
CustomSpec(id: airSenseList, requires: HudFeatures.airSense),
GroupSpec('FLY ZONES', requires: HudFeatures.flyZones),
CustomSpec(id: flyZoneList, requires: HudFeatures.flyZones),
CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones),
CustomSpec(id: flyZoneDatabase, requires: HudFeatures.flyZones),
];
// ── Vision ──────────────────────────────────────────────────────────────────
static final List<SettingSpec> _vision = <SettingSpec>[
GroupSpec('OBSTACLE AVOIDANCE', requires: HudFeatures.settingsObstacle),
CustomSpec(id: radarChart, requires: HudFeatures.visionRadar),
KeySwitchSpec(
id: 'collision',
label: 'Obstacle avoidance',
@@ -238,6 +252,7 @@ abstract final class SettingsTabs {
key: SettingsKeys.rthRemoteAvoidance,
requires: HudFeatures.settingsObstacle,
),
CustomSpec(id: sensingDistances, requires: HudFeatures.settingsObstacle),
const GroupSpec('POSITIONING & LANDING'),
const KeySwitchSpec(id: 'vps', label: 'Vision positioning', key: SettingsKeys.vps),
const KeySwitchSpec(
@@ -496,6 +511,8 @@ abstract final class SettingsTabs {
const GroupSpec('MAP'),
const CustomSpec(id: showRoute),
const CustomSpec(id: clearRoute),
GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks),
CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks),
const GroupSpec('ABOUT'),
const CustomSpec(id: about, keys: <DjiKey>[SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]),
const GroupSpec('DEVELOPER'),
+6 -1
View File
@@ -118,6 +118,10 @@ class AircraftState extends ChangeNotifier {
/// The flight controller's built-in simulator is running (developer only).
bool? simulatorActive;
/// The flight controller is in virtual-stick mode (plan P8), from the native
/// stick loop's `virtualStick` group; null until sticks were first enabled.
bool? virtualStickEnabled;
bool? compassHasError;
bool? compassCalibrating;
@@ -172,6 +176,7 @@ class AircraftState extends ChangeNotifier {
failsafe = asBool(t['isFailsafe']);
orientationMode = asString(t['orientationMode']);
simulatorActive = asBool(t['simulatorActive']);
virtualStickEnabled = asBool(asMap(t['virtualStick'])?['enabled']);
compassHasError = asBool(t['compassHasError']);
compassCalibrating = asBool(t['compassCalibrating']);
compassCalibrationState = asString(t['compassCalibrationState']);
@@ -183,7 +188,7 @@ class AircraftState extends ChangeNotifier {
satellites = gpsSignalLevel = flightTimeSeconds = null;
isFlying = motorsOn = homeLocationSet = ultrasonicBeingUsed = ultrasonicError = null;
visionPositioningBeingUsed = landingConfirmationNeeded = goingHome = null;
lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = null;
lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = virtualStickEnabled = null;
compassHasError = compassCalibrating = null;
compassCalibrationState = null;
flightMode = flightModeEnum = goHomeExecutionState = batteryThresholdBehavior = windWarning = orientationMode = null;
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// One manned aircraft the AirSense (ADS-B In) receiver hears.
@immutable
class AirSensePlane {
const AirSensePlane({
required this.code,
this.warningLevel = 0,
this.direction,
this.heading,
this.distance,
this.latitude,
this.longitude,
});
factory AirSensePlane.fromMap(Map<String, dynamic> m) => AirSensePlane(
code: (asString(m['code']) ?? '').trim(),
warningLevel: asInt(m['warningLevel']) ?? 0,
direction: asString(m['direction']),
heading: finite(m['heading']),
distance: finite(m['distance']),
latitude: finite(m['latitude']),
longitude: finite(m['longitude']),
);
/// ICAO 24-bit address, as the receiver formats it.
final String code;
/// 0 (no threat) … 4 (collision risk).
final int warningLevel;
/// `AirSenseDirection` octant relative to the drone: `NORTH`, `NORTH_EAST`, … `UNKNOWN`.
final String? direction;
final double? heading; // deg
final double? distance; // m
final double? latitude;
final double? longitude;
bool get hasPosition => latitude != null && longitude != null && !(latitude == 0 && longitude == 0);
/// `NE`, `S`, … or null when unknown.
String? get octant => switch (direction) {
'NORTH' => 'N',
'NORTH_EAST' => 'NE',
'EAST' => 'E',
'SOUTH_EAST' => 'SE',
'SOUTH' => 'S',
'SOUTH_WEST' => 'SW',
'WEST' => 'W',
'NORTH_WEST' => 'NW',
_ => null,
};
}
/// AirSense (plan P8): the receiver's overall warning and every plane it hears,
/// from the telemetry tick's `airSense` group. [reported] stays false on
/// aircraft without a receiver.
class AirSenseState extends ChangeNotifier {
bool reported = false;
/// The receiver's overall level, 0–4.
int warningLevel = 0;
/// Nearest first.
List<AirSensePlane> planes = const <AirSensePlane>[];
/// The level to act on: the receiver's own, or the worst single plane's.
int get level => planes.fold(warningLevel, (int w, AirSensePlane p) => p.warningLevel > w ? p.warningLevel : w);
/// Level 2 asks for caution; 3 and 4 ask the pilot to descend.
static const int cautionLevel = 2;
static const int dangerLevel = 3;
/// Planes at caution level or worse, nearest first.
List<AirSensePlane> get threats => <AirSensePlane>[for (final AirSensePlane p in planes) if (p.warningLevel >= cautionLevel) p];
/// The plane a warning should name: the worst, then the nearest.
AirSensePlane? get worst {
AirSensePlane? best;
for (final AirSensePlane p in planes) {
if (best == null || p.warningLevel > best.warningLevel) best = p;
}
return best;
}
void apply(Map<String, dynamic>? m) {
reported = m != null;
warningLevel = asInt(m?['warningLevel']) ?? 0;
planes = <AirSensePlane>[for (final Map<String, dynamic> p in asMapList(m?['airplanes'])) AirSensePlane.fromMap(p)]
..sort((AirSensePlane a, AirSensePlane b) => (a.distance ?? double.infinity).compareTo(b.distance ?? double.infinity));
notifyListeners();
}
/// What the flight controller does about a threat by itself, on the aircraft
/// that report it (`airSenseAvoidance`): `NONE`, `AVOIDING` or `LANDING`.
String? avoidanceMode;
/// Seconds until the avoidance action starts.
double? avoidanceCountdown;
bool get avoiding => avoidanceMode == 'AVOIDING' || avoidanceMode == 'LANDING';
void applyAvoidance(Map<String, dynamic>? m) {
avoidanceMode = asString(m?['mode']);
avoidanceCountdown = finite(m?['countdown']);
notifyListeners();
}
void clear() {
reported = false;
warningLevel = 0;
planes = const <AirSensePlane>[];
avoidanceMode = null;
avoidanceCountdown = null;
notifyListeners();
}
}
+450
View File
@@ -0,0 +1,450 @@
import 'package:flutter/foundation.dart';
import '../hud_geometry.dart';
import 'parse.dart';
/// How a fly zone (or the aircraft's standing towards one) should look.
enum ZoneLevel { none, info, caution, danger }
/// One part of a GEO zone: a circle, or a polygon with its own height limit.
@immutable
class SubFlyZone {
const SubFlyZone({required this.shape, this.latitude, this.longitude, this.radius, this.maxHeight, this.vertices = const <(double, double)>[]});
factory SubFlyZone.fromMap(Map<String, dynamic> m) => SubFlyZone(
shape: asString(m['shape']) ?? 'UNKNOWN',
latitude: finite(m['latitude']),
longitude: finite(m['longitude']),
radius: finite(m['radius']),
maxHeight: asInt(m['maxHeight']),
vertices: <(double, double)>[
if (m['vertices'] is List)
for (final Object? v in m['vertices'] as List)
if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!),
],
);
/// `CYLINDER` or `POLYGON`.
final String shape;
final double? latitude;
final double? longitude;
final double? radius; // m
/// Height limit inside this part, m (0 = no flight).
final int? maxHeight;
final List<(double, double)> vertices;
bool get isPolygon => shape == 'POLYGON' && vertices.length >= 3;
/// Where the height label goes: the circle's centre, or the polygon's vertex average.
(double, double)? get labelPoint {
if (isPolygon) {
double la = 0, lo = 0;
for (final (double, double) v in vertices) {
la += v.$1;
lo += v.$2;
}
return (la / vertices.length, lo / vertices.length);
}
return latitude == null || longitude == null ? null : (latitude!, longitude!);
}
/// `NO FLY` at 0 m, `120 m` for a ceiling, null when the part sets none.
String? get heightLabel {
final int? h = maxHeight;
if (h == null || h < 0) return null;
return h == 0 ? 'NO FLY' : '$h m';
}
}
/// One DJI GEO zone (`FlyZoneInformation`).
@immutable
class FlyZone {
const FlyZone({
required this.id,
this.name = '',
this.category = 'UNKNOWN',
this.reason,
this.latitude,
this.longitude,
this.radius,
this.startTime,
this.endTime,
this.unlockStartTime,
this.unlockEndTime,
this.subZones = const <SubFlyZone>[],
});
factory FlyZone.fromMap(Map<String, dynamic> m) => FlyZone(
id: asInt(m['id']) ?? 0,
name: (asString(m['name']) ?? '').trim(),
category: asString(m['category']) ?? 'UNKNOWN',
reason: asString(m['reason']),
latitude: finite(m['latitude']),
longitude: finite(m['longitude']),
radius: finite(m['radius']),
startTime: _blank(asString(m['startTime'])),
endTime: _blank(asString(m['endTime'])),
unlockStartTime: _blank(asString(m['unlockStartTime'])),
unlockEndTime: _blank(asString(m['unlockEndTime'])),
subZones: <SubFlyZone>[for (final Map<String, dynamic> s in asMapList(m['subZones'])) SubFlyZone.fromMap(s)],
);
static String? _blank(String? s) => s == null || s.trim().isEmpty ? null : s.trim();
final int id;
final String name;
/// `WARNING`, `ENHANCED_WARNING`, `AUTHORIZATION`, `RESTRICTED`, `UNKNOWN`.
final String category;
/// `FlyZoneReason` name: `AIRPORT`, `MILITARY`, `PRISON`, …
final String? reason;
final double? latitude;
final double? longitude;
final double? radius; // m
final String? startTime;
final String? endTime;
final String? unlockStartTime;
final String? unlockEndTime;
final List<SubFlyZone> subZones;
bool get unlockable => category == 'AUTHORIZATION';
ZoneLevel get level => switch (category) {
'RESTRICTED' => ZoneLevel.danger,
'AUTHORIZATION' || 'ENHANCED_WARNING' => ZoneLevel.caution,
'WARNING' => ZoneLevel.info,
_ => ZoneLevel.none,
};
String get categoryLabel => switch (category) {
'RESTRICTED' => 'Restricted zone',
'AUTHORIZATION' => 'Authorization zone',
'ENHANCED_WARNING' => 'Enhanced warning zone',
'WARNING' => 'Warning zone',
_ => 'Fly zone',
};
/// `COMMERCIAL_AIRPORTS` → `Commercial airports`.
String? get reasonLabel {
final String? r = reason;
if (r == null || r == 'UNKNOWN') return null;
final String s = r.replaceAll('_', ' ').toLowerCase();
return s[0].toUpperCase() + s.substring(1);
}
/// Metres from ([lat], [lon]) to the zone's centre, minus its radius (0 inside).
double? distanceFrom(double lat, double lon) {
if (latitude == null || longitude == null) return null;
final double d = HudGeometry.distance(lat, lon, latitude!, longitude!) - (radius ?? 0);
return d < 0 ? 0 : d;
}
}
/// One FlySafe notification: transient advice from DJI's GEO system.
@immutable
class FlySafeNotice {
const FlySafeNotice({required this.kind, this.event, this.description, this.heightLimit, this.countdown, this.zoneIds = const <int>[], required this.at});
factory FlySafeNotice.fromMap(Map<String, dynamic> m, DateTime at) => FlySafeNotice(
kind: asString(m['kind']) ?? 'UNKNOWN',
event: asString(m['event']),
description: (asString(m['description']) ?? '').trim(),
heightLimit: asInt(m['heightLimit']),
countdown: asInt(m['countdown']),
zoneIds: <int>[if (m['zones'] is List) for (final Object? z in m['zones'] as List) if (z is int) z],
at: at,
);
/// `FLYING_SAFETY_TIP`, `WARNING`, `SERIOUS_WARNING`, `RETURN_TO_HOME`.
final String kind;
final String? event;
final String? description;
final int? heightLimit;
final int? countdown;
final List<int> zoneIds;
final DateTime at;
ZoneLevel get level => switch (kind) {
'SERIOUS_WARNING' => ZoneLevel.danger,
'WARNING' || 'RETURN_TO_HOME' => ZoneLevel.caution,
_ => ZoneLevel.info,
};
/// A short title when the SDK's description is empty.
String get title => switch (event) {
'IN_NO_FLY_ZONE' => 'In a no-fly zone: the aircraft is landing',
'IN_LIMIT_HEIGHT_AREA' => 'In a height-limited zone',
'IN_AUTHORIZATION_ZONE_WITHOUT_UNLOCKING' => 'In an authorization zone that is not unlocked',
'TAKE_OFF_FAILED_IN_NO_FLY_ZONE' || 'TAKE_OFF_FAILED_IN_NO_FLY_ZONE_WITHOUT_GPS' => 'Take-off refused: no-fly zone',
'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE_WITHOUT_UNLOCKING' ||
'TAKE_OFF_FAILED_IN_AUTHORIZED_AREA_WITHOUT_GPS_AND_UNLOCKING' =>
'Take-off refused: unlock the authorization zone first',
'NEAR_NO_FLY_ZONE' => 'Return to Home passes near a no-fly zone',
'CROSS_NO_FLY_ZONE' => 'Return to Home would cross a no-fly zone',
'TOUCH_NO_FLY_ZONE' => 'At the edge of a no-fly zone',
'TOUCH_AUTHORIZATION_ZONE' => 'At the edge of an authorization zone',
'REACH_MAXIMUM_FLIGHT_HEIGHT' => 'At the zone\'s height limit',
'TAKE_OFF_NEAR_NO_FLY_ZONE' => 'Near a no-fly zone',
'AUTHORIZED_ZONE_WITHOUT_UNLOCKING_NEARBY' || 'AUTHORIZED_ZONE_AND_RESTRICTIONS_NEARBY' => 'Authorization zone nearby',
'IN_AUTHORIZATION_ZONE' || 'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE' => 'In an authorization zone',
'LIMITED_RESTRICTIONS_NEARBY' || 'RESTRICTIONS_IN_LIMITED_FLY_ZONE_NEARBY' => 'Height-limited zone nearby',
'TAKE_OFF_IN_ENHANCED_WARNING_ZONE' => 'In an enhanced warning zone',
'TAKE_OFF_IN_WARNING_AREA' => 'In a warning zone',
'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE' || 'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE_WITHOUT_GPS' => 'Taking off in a height-limited zone',
'RTH_AFFECTED_AT_BOUNDARY_OF_GEO_ZONE' => 'Return to Home is limited by a fly zone',
_ => kind == 'SERIOUS_WARNING' ? 'Fly zone warning' : 'Fly zone notice',
};
}
/// DJI GEO fly zones (plan P8): the aircraft's standing (`flyZone.state`), the
/// latest FlySafe notification, the zones around, and which are unlocked.
class FlyZoneState extends ChangeNotifier {
/// `FlyZoneState` name, null until reported.
String? state;
FlySafeNotice? notice;
List<FlyZone> zones = const <FlyZone>[];
/// Where [zones] were fetched around, and when.
(double, double)? zonesAround;
DateTime? zonesAt;
Set<int> unlocked = const <int>{};
/// A notice stays on screen this long (a serious one with a countdown, until replaced).
static const Duration noticeLife = Duration(seconds: 15);
ZoneLevel get stateLevel => switch (state) {
'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' || 'PHONE_IN_RESTRICTED_ZONE' => ZoneLevel.danger,
'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' || 'IN_ENHANCE_WARNING_ZONE' || 'NEAR_RESTRICTED_ZONE' => ZoneLevel.caution,
'IN_WARNING_ZONE' ||
'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
'NEAR_HEIGHT_LIMITED_ZONE' ||
'NEAR_AUTHORIZED_ZONE' ||
'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' ||
'NEAR_MULTIPLE_TYPE_FLY_ZONE' ||
'IN_AUTHORIZED_ZONE_WITH_LICENSE' =>
ZoneLevel.info,
_ => ZoneLevel.none,
};
String get stateLabel => switch (state) {
'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' => 'In a restricted zone',
'PHONE_IN_RESTRICTED_ZONE' => 'You are in a restricted zone',
'NEAR_RESTRICTED_ZONE' => 'Near a restricted zone',
'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' => 'In an authorization zone',
'IN_AUTHORIZED_ZONE_WITH_LICENSE' => 'In an unlocked authorization zone',
'NEAR_AUTHORIZED_ZONE' || 'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' => 'Near an authorization zone',
'IN_ENHANCE_WARNING_ZONE' => 'In an enhanced warning zone',
'IN_WARNING_ZONE' => 'In a warning zone',
'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' =>
'In a height-limited zone',
'NEAR_HEIGHT_LIMITED_ZONE' => 'Near a height-limited zone',
'NEAR_MULTIPLE_TYPE_FLY_ZONE' => 'Near several fly zones',
_ => 'No fly zone around',
};
/// The notice to show at [now]: fresh, or a serious one still counting down.
FlySafeNotice? noticeAt(DateTime now) {
final FlySafeNotice? n = notice;
if (n == null) return null;
final Duration age = now.difference(n.at);
if (age <= noticeLife) return n;
if (n.kind == 'SERIOUS_WARNING' && (n.countdown ?? 0) > age.inSeconds) return n;
return null;
}
/// The zone(s) a notice names, as loaded.
List<FlyZone> zonesFor(FlySafeNotice n) => <FlyZone>[for (final FlyZone z in zones) if (n.zoneIds.contains(z.id)) z];
/// Zones nearest first from ([lat], [lon]).
List<FlyZone> nearest(double? lat, double? lon) {
if (lat == null || lon == null) return zones;
return <FlyZone>[...zones]..sort((FlyZone a, FlyZone b) =>
(a.distanceFrom(lat, lon) ?? double.infinity).compareTo(b.distanceFrom(lat, lon) ?? double.infinity));
}
void applyState(Map<String, dynamic>? m) {
state = asString(m?['state']);
notifyListeners();
}
void applyNotice(Map<String, dynamic> m, {DateTime? now}) {
notice = FlySafeNotice.fromMap(m, now ?? DateTime.now());
notifyListeners();
}
void applyZones(List<Map<String, dynamic>> list, {required double latitude, required double longitude, DateTime? now}) {
zones = <FlyZone>[for (final Map<String, dynamic> m in list) FlyZone.fromMap(m)];
zonesAround = (latitude, longitude);
zonesAt = now ?? DateTime.now();
notifyListeners();
}
void applyUnlocked(Iterable<int> ids) {
unlocked = ids.toSet();
notifyListeners();
}
/// Whether a fresh fetch is due for a fix at ([lat], [lon]).
bool needsZonesFor(double lat, double lon, {DateTime? now, double moveM = 2000, Duration maxAge = const Duration(minutes: 10)}) {
final (double, double)? at = zonesAround;
final DateTime? when = zonesAt;
if (at == null || when == null) return true;
if ((now ?? DateTime.now()).difference(when) > maxAge) return true;
return HudGeometry.distance(at.$1, at.$2, lat, lon) > moveM;
}
// ── Licences and the GEO database ─────────────────────────────────────────
/// Null until listed; false on aircraft without custom unlock zones.
bool? licencesSupported;
List<FlyZoneLicence> licences = const <FlyZoneLicence>[];
FlyZoneDatabase? database;
/// Basic database download, 0..1, while an upgrade runs; null otherwise.
double? upgradeProgress;
FlyZoneLicence? get enabledLicence => licences.where((FlyZoneLicence l) => l.enabled).firstOrNull;
void applyLicences(Map<String, dynamic> m) {
licencesSupported = asBool(m['supported']) ?? false;
licences = <FlyZoneLicence>[for (final Map<String, dynamic> z in asMapList(m['zones'])) FlyZoneLicence.fromMap(z)]
..sort((FlyZoneLicence a, FlyZoneLicence b) => a.index.compareTo(b.index));
notifyListeners();
}
void applyDatabase(Map<String, dynamic> m) {
database = FlyZoneDatabase.fromMap(m);
notifyListeners();
}
/// The SDK's progress float; read as a percentage when above 1. A complete
/// download ends the upgrade (MSDK does not say whether the start call's
/// completion waits for it).
void applyUpgradeProgress(double? p) {
final double? v = p == null ? null : (p > 1 ? p / 100 : p).clamp(0.0, 1.0);
upgradeProgress = v != null && v >= 1 ? null : v;
notifyListeners();
}
/// Aircraft-bound parts only: the zones database is app-level and stays.
void clearAircraft() {
state = null;
notice = null;
unlocked = const <int>{};
licencesSupported = null;
licences = const <FlyZoneLicence>[];
notifyListeners();
}
}
/// One licence on the aircraft (`CustomUnlockZone`): a DJI-issued unlock for a
/// circle, polygon, height or whole country, valid between two dates. One at a
/// time is enabled.
@immutable
class FlyZoneLicence {
const FlyZoneLicence({
required this.id,
this.index = 0,
this.name = '',
this.type = 'UNKNOWN',
this.latitude,
this.longitude,
this.radius,
this.height,
this.country,
this.points = const <(double, double)>[],
this.startTime,
this.endTime,
this.expired = false,
this.enabled = false,
});
factory FlyZoneLicence.fromMap(Map<String, dynamic> m) => FlyZoneLicence(
id: asInt(m['id']) ?? 0,
index: asInt(m['index']) ?? 0,
name: (asString(m['name']) ?? '').trim(),
type: asString(m['type']) ?? 'UNKNOWN',
latitude: finite(m['latitude']),
longitude: finite(m['longitude']),
radius: finite(m['radius']),
height: asInt(m['height']),
country: asString(m['country']),
points: <(double, double)>[
if (m['points'] is List)
for (final Object? v in m['points'] as List)
if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!),
],
startTime: FlyZone._blank(asString(m['startTime'])),
endTime: FlyZone._blank(asString(m['endTime'])),
expired: asBool(m['expired']) ?? false,
enabled: asBool(m['enabled']) ?? false,
);
final int id;
final int index;
final String name;
/// `UnlockZoneType`: `GEO_UNLOCK`, `CIRCLE_UNLOCK_AREA`, `COUNTRY_UNLOCK`, `HEIGHT_UNLOCK`, `PENTAGON_UNLOCK`.
final String type;
final double? latitude;
final double? longitude;
final double? radius; // m
final int? height; // m, for height unlocks
final String? country;
final List<(double, double)> points;
final String? startTime;
final String? endTime;
final bool expired;
final bool enabled;
String get typeLabel => switch (type) {
'GEO_UNLOCK' => 'GEO zone unlock',
'CIRCLE_UNLOCK_AREA' => 'Circle unlock',
'PENTAGON_UNLOCK' => 'Area unlock',
'COUNTRY_UNLOCK' => 'Country unlock',
'HEIGHT_UNLOCK' => 'Height unlock',
_ => 'Licence',
};
/// Drawable on the map: a circle with a centre and radius, or a polygon.
bool get hasArea => points.length >= 3 || (latitude != null && longitude != null && (radius ?? 0) > 0);
String get validity => <String>[if (startTime != null) startTime!, if (endTime != null) endTime!].join(' → ');
}
/// The GEO database's versions and state (`FlyZoneDatabaseState` names).
@immutable
class FlyZoneDatabase {
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState});
factory FlyZoneDatabase.fromMap(Map<String, dynamic> m) => FlyZoneDatabase(
basicVersion: asString(m['basicVersion']),
basicState: asString(m['basicState']),
preciseVersion: asString(m['preciseVersion']),
preciseState: asString(m['preciseState']),
);
final String? basicVersion;
final String? basicState; // INITIALIZING / NO_INTERNET_CONNECTION / OUT_OF_DATE / UP_TO_DATE / UNKNOWN
final String? preciseVersion;
final String? preciseState;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE';
static String stateLabel(String? s) => switch (s) {
'UP_TO_DATE' => 'Up to date',
'OUT_OF_DATE' => 'Out of date',
'NO_INTERNET_CONNECTION' => 'No internet connection',
'INITIALIZING' => 'Initializing',
_ => 'Unknown',
};
}
+59
View File
@@ -56,13 +56,70 @@ class VisionReading {
bool get dangerous => systemWarning == 'DANGEROUS';
}
/// Omnidirectional perception (Mavic 2-class): one distance per [angleInterval]
/// degrees clockwise from the nose, plus straight up and down. Null distances
/// saw nothing in range.
@immutable
class Perception {
const Perception({required this.angleInterval, this.distances = const <double?>[], this.up, this.down});
factory Perception.fromMap(Map<String, dynamic> m) => Perception(
angleInterval: finite(m['angleInterval']) ?? 0,
distances: <double?>[if (m['distances'] is List) for (final Object? d in m['distances'] as List) finite(d)],
up: finite(m['up']),
down: finite(m['down']),
);
final double angleInterval; // deg
final List<double?> distances; // m
final double? up;
final double? down;
}
/// One obstacle-sensing direction's health (`ObstacleAvoidanceSensorState`).
@immutable
class SensorHealth {
const SensorHealth({required this.working, required this.enabled});
final bool working;
final bool enabled;
/// Switched on but not seeing: covered, dirty or too dark.
bool get blocked => enabled && !working;
}
/// Obstacle sensing per sensor position. Fed from the `vision` group of the
/// telemetry tick; empty on aircraft without obstacle sensing.
class VisionState extends ChangeNotifier {
Map<String, VisionReading> sensors = const <String, VisionReading>{};
/// Omni perception, on the aircraft that report it (`perception` group).
Perception? perception;
/// Per-direction sensor health, keyed `forward` / `backward` / `left` /
/// `right` / `up` / `down` (`obstacleSensors` group).
Map<String, SensorHealth> health = const <String, SensorHealth>{};
DateTime? updatedAt;
void applyPerception(Map<String, dynamic>? m) {
perception = m == null ? null : Perception.fromMap(m);
notifyListeners();
}
void applyHealth(Map<String, dynamic>? m) {
health = <String, SensorHealth>{
if (m != null)
for (final MapEntry<String, dynamic> e in m.entries)
if (e.value is Map)
e.key: SensorHealth(
working: asBool((e.value as Map)['working']) ?? true,
enabled: asBool((e.value as Map)['enabled']) ?? true,
),
};
notifyListeners();
}
VisionReading? get nose => sensors['NOSE'];
VisionReading? get tail => sensors['TAIL'];
@@ -108,6 +165,8 @@ class VisionState extends ChangeNotifier {
void clear() {
sensors = const <String, VisionReading>{};
perception = null;
health = const <String, SensorHealth>{};
updatedAt = null;
palmControl = null;
notifyListeners();
+15 -2
View File
@@ -10,7 +10,8 @@ import 'state/parse.dart';
/// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight
/// controller's flat keys plus one nested group per subsystem (`battery`,
/// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`,
/// `gimbalSettings`, `vision`, `calibration`). It arrives at
/// `gimbalSettings`, `vision`, `calibration`, `perception`, `obstacleSensors`,
/// `airSense`, `airSenseAvoidance`, `flyZone`). It arrives at
/// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz
/// refreshes, so each group is compared with the last one seen and its
/// sub-model only notified when it actually moved. A group missing from the
@@ -22,7 +23,7 @@ class TelemetryReducer {
final Map<String, Object?> _lastGroups = <String, Object?>{};
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl'];
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl', 'perception', 'obstacleSensors', 'airSense', 'airSenseAvoidance', 'flyZone'];
/// Applies [event] if it is one of the telemetry types; returns whether it was.
bool apply(Map<String, dynamic> event) {
@@ -50,6 +51,13 @@ class TelemetryReducer {
case 'mission':
model.mission.apply(event);
return true;
case 'flySafe':
// The GEO system is app-level: its notices count with or without an aircraft.
model.flyZones.applyNotice(event);
return true;
case 'flyZoneDbProgress':
model.flyZones.applyUpgradeProgress(finite(event['progress']));
return true;
case 'videoSize':
final int? w = asInt(event['width']), h = asInt(event['height']);
if (w != null && h != null && w > 0 && h > 0) model.videoSize.value = Size(w.toDouble(), h.toDouble());
@@ -97,5 +105,10 @@ class TelemetryReducer {
if (changed.contains('vision')) model.vision.apply(asMap(t['vision']));
if (changed.contains('calibration')) model.calibration.apply(asMap(t['calibration']));
if (changed.contains('palmControl')) model.vision.applyPalm(asString(t['palmControl']));
if (changed.contains('perception')) model.vision.applyPerception(asMap(t['perception']));
if (changed.contains('obstacleSensors')) model.vision.applyHealth(asMap(t['obstacleSensors']));
if (changed.contains('airSense')) model.airSense.apply(asMap(t['airSense']));
if (changed.contains('airSenseAvoidance')) model.airSense.applyAvoidance(asMap(t['airSenseAvoidance']));
if (changed.contains('flyZone')) model.flyZones.applyState(asMap(t['flyZone']));
}
}
+217
View File
@@ -0,0 +1,217 @@
import 'dart:async';
import 'dart:ui' show Offset;
import 'package:flutter/foundation.dart';
/// Which stick does what, as on the remote (RC `AircraftMappingStyle`).
enum StickMode {
/// Left: forward/back + yaw; right: throttle + sideways.
mode1('Mode 1'),
/// Left: throttle + yaw; right: forward/back + sideways. DJI's default.
mode2('Mode 2'),
/// Left: forward/back + sideways; right: throttle + yaw.
mode3('Mode 3');
const StickMode(this.label);
final String label;
}
/// What a full stick deflection means.
@immutable
class StickLimits {
const StickLimits({this.horizontal = 5, this.vertical = 2, this.yaw = 60});
/// GO 4's Wi-Fi joystick default is gentle; these are a step below P mode.
static const StickLimits normal = StickLimits();
static const StickLimits gentle = StickLimits(horizontal: 2, vertical: 1, yaw: 30);
final double horizontal; // m/s
final double vertical; // m/s
final double yaw; // °/s
}
/// One packet for the native stick loop: body-frame velocities and a yaw rate.
@immutable
class StickCommand {
const StickCommand({this.forward = 0, this.right = 0, this.yaw = 0, this.up = 0});
static const StickCommand zero = StickCommand();
final double forward; // m/s
final double right; // m/s
final double yaw; // °/s, clockwise
final double up; // m/s
bool get isZero => forward == 0 && right == 0 && yaw == 0 && up == 0;
@override
bool operator ==(Object other) =>
other is StickCommand && other.forward == forward && other.right == right && other.yaw == yaw && other.up == up;
@override
int get hashCode => Object.hash(forward, right, yaw, up);
@override
String toString() => 'StickCommand(f ${forward.toStringAsFixed(2)}, r ${right.toStringAsFixed(2)}, '
'yaw ${yaw.toStringAsFixed(1)}, up ${up.toStringAsFixed(2)})';
}
/// Stick positions → [StickCommand] (plan P8). Pure, so the mapping is unit-tested.
///
/// Positions are −1…1 with x to the right and **y up** (push = +1).
abstract final class StickMapper {
/// Below this deflection the stick counts as centred (thumbs never rest at 0).
static const double deadzone = 0.08;
/// Share of the cubic term: small deflections are finer than a linear map.
static const double expo = 0.4;
/// The curve one axis goes through: deadzone, rescale, expo.
static double shape(double v) {
final double a = v.abs().clamp(0.0, 1.0);
if (a <= deadzone) return 0;
final double x = (a - deadzone) / (1 - deadzone);
return (expo * x * x * x + (1 - expo) * x) * v.sign;
}
static StickCommand command(Offset left, Offset right, {StickMode mode = StickMode.mode2, StickLimits limits = StickLimits.normal}) {
final (double throttle, double yaw, double pitch, double roll) = switch (mode) {
StickMode.mode1 => (right.dy, left.dx, left.dy, right.dx),
StickMode.mode2 => (left.dy, left.dx, right.dy, right.dx),
StickMode.mode3 => (right.dy, right.dx, left.dy, left.dx),
};
double r(double v) => v == 0 ? 0 : v; // no −0.0 in packets or tests
return StickCommand(
forward: r(shape(pitch) * limits.horizontal),
right: r(shape(roll) * limits.horizontal),
yaw: r(shape(yaw) * limits.yaw),
up: r(shape(throttle) * limits.vertical),
);
}
/// Clamps a finger's offset from the pad centre into the unit circle, y up.
static Offset normalise(Offset fromCentre, double radius) {
if (radius <= 0) return Offset.zero;
Offset v = Offset(fromCentre.dx / radius, -fromCentre.dy / radius);
final double len = v.distance;
if (len > 1) v = v / len;
return v;
}
}
/// The on-screen sticks' state while they fly the aircraft.
///
/// [setLeft] / [setRight] send at once when the command changes, and a
/// [heartbeat] timer repeats it while a stick is off centre — the native loop
/// hovers once a command is 300 ms old, so a held stick must keep saying so.
/// Centred sticks send one zero and go quiet.
///
/// [open] is what the pilot asked for; the aircraft's own answer
/// (`virtualStickEnabled` in the telemetry) closes it through [onAircraft] when
/// the remote's mode switch or RTH takes over.
class VirtualStickController extends ChangeNotifier {
VirtualStickController({required this.send, this.heartbeat = const Duration(milliseconds: 100)});
/// Delivers one command to the bridge.
final Future<void> Function(StickCommand c) send;
final Duration heartbeat;
StickMode mode = StickMode.mode2;
StickLimits limits = StickLimits.normal;
Offset left = Offset.zero;
Offset right = Offset.zero;
/// The sticks are on and flying the aircraft.
bool open = false;
/// Why the sticks last closed without the pilot asking, for one snackbar.
String? closedReason;
/// The aircraft confirmed virtual-stick mode since [open] turned on.
bool _confirmed = false;
StickCommand _last = StickCommand.zero;
Timer? _beat;
StickCommand get command => StickMapper.command(left, right, mode: mode, limits: limits);
void setLeft(Offset v) {
left = v;
_changed();
}
void setRight(Offset v) {
right = v;
_changed();
}
/// Both sticks back to centre (a finger lifted off a pad does this too).
void centre() {
left = right = Offset.zero;
_changed();
}
void _changed() {
notifyListeners();
if (!open) return;
final StickCommand c = command;
if (c != _last) _emit(c);
if (c.isZero) {
_beat?.cancel();
_beat = null;
} else {
_beat ??= Timer.periodic(heartbeat, (_) => _emit(command));
}
}
void _emit(StickCommand c) {
_last = c;
unawaited(send(c).catchError((Object _) {}));
}
/// The aircraft entered virtual-stick mode.
void opened() {
open = true;
_confirmed = false;
closedReason = null;
left = right = Offset.zero;
_last = StickCommand.zero;
notifyListeners();
}
/// Stops sending; [reason] is shown when the aircraft, not the pilot, ended it.
void close([String? reason]) {
_beat?.cancel();
_beat = null;
final bool wasOpen = open;
open = false;
left = right = Offset.zero;
_last = StickCommand.zero;
if (wasOpen) closedReason = reason;
notifyListeners();
}
/// Follows the flight controller: once it has confirmed the mode, a report
/// that it left (remote mode switch), or an RTH / landing, closes the sticks.
void onAircraft({required bool? enabled, required bool goingHome, required bool landing}) {
if (!open) return;
if (enabled == true) _confirmed = true;
if (_confirmed && enabled == false) {
close('The remote took back control');
} else if (goingHome) {
close('Return to Home took over');
} else if (landing) {
close('The aircraft is landing');
}
}
@override
void dispose() {
_beat?.cancel();
super.dispose();
}
}
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/foundation.dart';
import 'state/vision_state.dart';
/// The four horizontal radar sides, clockwise from the nose.
enum RadarSide {
front('Front', 'NOSE', 'forward', 0, 60),
right('Right', 'RIGHT', 'right', 90, 40),
back('Rear', 'TAIL', 'backward', 180, 60),
left('Left', 'LEFT', 'left', 270, 40);
const RadarSide(this.label, this.position, this.healthKey, this.bearing, this.span);
final String label;
/// `VisionDetectionState` position name.
final String position;
/// `obstacleSensors` key.
final String healthKey;
/// Centre of the side, degrees clockwise from the nose.
final double bearing;
/// Arc the side covers on the radar, degrees.
final double span;
}
/// How alarming a sector is, for its colour.
enum RadarBand { none, notice, caution, danger }
@immutable
class RadarSector {
const RadarSector({required this.start, required this.sweep, this.distance, this.level = 0});
/// Degrees clockwise from the nose.
final double start;
final double sweep;
final double? distance; // m
/// 0 (nothing / invalid) … 6 (very close), as MSDK's `ObstacleDetectionSectorWarning`.
final int level;
RadarBand get band => VisionRadar.bandFor(level);
}
@immutable
class RadarSideView {
const RadarSideView({required this.side, this.sectors = const <RadarSector>[], this.closest, this.blocked = false, this.off = false});
final RadarSide side;
final List<RadarSector> sectors;
final double? closest; // m
/// Switched on but not seeing (covered, dirty, too dark).
final bool blocked;
/// Obstacle sensing for this side is switched off.
final bool off;
int get level => sectors.fold(0, (int w, RadarSector s) => s.level > w ? s.level : w);
RadarBand get band => VisionRadar.bandFor(level);
}
/// What the vision radar overlay (plan P8, GO 4's `vision_radar_view`) draws:
/// per side, sectors graded by distance, the nearest distance, and whether the
/// side's sensors are blocked; plus the obstacle overhead.
///
/// Pure: built from [VisionState] so the grading is unit-tested without a
/// painter. Each side prefers the aircraft's own `VisionDetectionState`
/// sectors (and their warning levels); a side that has none is filled from
/// omni perception's distance ring, graded by [levelFor].
@immutable
class VisionRadar {
const VisionRadar({this.sides = const <RadarSide, RadarSideView>{}, this.up});
/// Obstacles further than this are not drawn.
static const double range = 16;
/// Above this the overhead distance is not shown.
static const double upRange = 10;
/// Sectors a perception-filled side is split into.
static const int perceptionSectors = 4;
final Map<RadarSide, RadarSideView> sides;
/// Overhead obstacle, m, when within [upRange].
final double? up;
bool get isEmpty => sides.values.every((RadarSideView s) => s.sectors.every((RadarSector x) => x.level == 0) && !s.blocked) && up == null;
int get level => sides.values.fold(0, (int w, RadarSideView s) => s.level > w ? s.level : w);
List<RadarSide> get blocked => <RadarSide>[for (final RadarSideView s in sides.values) if (s.blocked) s.side];
static RadarBand bandFor(int level) => level >= 5
? RadarBand.danger
: level >= 3
? RadarBand.caution
: level >= 1
? RadarBand.notice
: RadarBand.none;
/// A warning level for a bare distance (perception reports no level).
static int levelFor(double? d) {
if (d == null || d > range) return 0;
if (d <= 1.5) return 6;
if (d <= 2.5) return 5;
if (d <= 4) return 4;
if (d <= 6) return 3;
if (d <= 10) return 2;
return 1;
}
static int _sdkLevel(VisionSector s) => s.distance != null && s.distance! > range ? 0 : s.level;
static VisionRadar of(VisionState v) {
final Map<RadarSide, RadarSideView> sides = <RadarSide, RadarSideView>{};
final Perception? p = v.perception;
for (final RadarSide side in RadarSide.values) {
final VisionReading? r = v.sensors[side.position];
final SensorHealth? h = v.health[side.healthKey];
List<RadarSector> sectors = const <RadarSector>[];
if (r != null && r.sectors.isNotEmpty) {
// Sectors run left to right as the sensor sees them: clockwise, whichever way it faces.
final double sweep = side.span / r.sectors.length;
final double start = side.bearing - side.span / 2;
sectors = <RadarSector>[
for (int i = 0; i < r.sectors.length; i++)
RadarSector(start: start + i * sweep, sweep: sweep, distance: r.sectors[i].distance, level: _sdkLevel(r.sectors[i])),
];
} else if (p != null && p.angleInterval > 0 && p.distances.isNotEmpty) {
sectors = _fromPerception(side, p);
}
final bool off = r?.isDisabled == true || (h != null && !h.enabled);
final bool blocked = !off && (h?.blocked ?? false);
if (sectors.isEmpty && !blocked && !off && r == null) continue;
double? closest;
for (final RadarSector s in sectors) {
final double? d = s.distance;
if (s.level > 0 && d != null && (closest == null || d < closest)) closest = d;
}
sides[side] = RadarSideView(side: side, sectors: sectors, closest: closest, blocked: blocked, off: off);
}
final double? up = p?.up;
return VisionRadar(sides: sides, up: up != null && up <= upRange ? up : null);
}
/// Splits [side]'s arc into [perceptionSectors] and takes the nearest ring
/// sample in each.
static List<RadarSector> _fromPerception(RadarSide side, Perception p) {
final int n = p.distances.length;
final double sweep = side.span / perceptionSectors;
final double start = side.bearing - side.span / 2;
return <RadarSector>[
for (int i = 0; i < perceptionSectors; i++)
() {
double? nearest;
final double a0 = start + i * sweep, a1 = a0 + sweep;
for (int k = 0; k < n; k++) {
final double? d = p.distances[k];
if (d == null || !_within(k * p.angleInterval, a0, a1)) continue;
if (nearest == null || d < nearest) nearest = d;
}
return RadarSector(start: a0, sweep: sweep, distance: nearest, level: levelFor(nearest));
}(),
];
}
/// Whether bearing [a] lies in [a0, a1), all in degrees, wrapping at 360.
static bool _within(double a, double a0, double a1) {
double norm(double x) => ((x % 360) + 360) % 360;
final double x = norm(a - a0);
return x < a1 - a0;
}
}