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:
1 parent
93f4a580e9
commit
e02bb0e5cd
34 files changed
+4376
-15
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user