Files
PilotVault/Fly App/lib/fly/state/vision_state.dart
T
tajniak81andClaude Opus 5.5 e02bb0e5cd 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>
2026-10-20 21:14:12 +02:00

175 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// One obstacle-detection sector of a sensor, left to right.
@immutable
class VisionSector {
const VisionSector({this.distance, this.warningLevel});
final double? distance; // m
/// `LEVEL_1` (far) … `LEVEL_6` (very close), or `INVALID`.
final String? warningLevel;
/// 0 when invalid or unknown, else 1–6.
int get level {
final String? w = warningLevel;
if (w == null || !w.startsWith('LEVEL_')) return 0;
return int.tryParse(w.substring(6)) ?? 0;
}
}
/// What one vision sensor position (nose, tail, left, right) sees.
@immutable
class VisionReading {
const VisionReading({
required this.position,
this.isSensorBeingUsed = false,
this.isDisabled = false,
this.systemWarning,
this.distance,
this.avoidAlertLevel,
this.sectors = const <VisionSector>[],
});
factory VisionReading.fromMap(String position, Map<String, dynamic> m) => VisionReading(
position: position,
isSensorBeingUsed: asBool(m['isSensorBeingUsed']) ?? false,
isDisabled: asBool(m['isDisabled']) ?? false,
systemWarning: asString(m['systemWarning']),
distance: finite(m['distance']),
avoidAlertLevel: asInt(m['avoidAlertLevel']),
sectors: <VisionSector>[
for (final Map<String, dynamic> s in asMapList(m['sectors']))
VisionSector(distance: finite(s['distance']), warningLevel: asString(s['warningLevel'])),
],
);
final String position; // NOSE / TAIL / LEFT / RIGHT
final bool isSensorBeingUsed;
final bool isDisabled;
final String? systemWarning; // SAFE / DANGEROUS / INVALID
final double? distance; // m, closest obstacle
final int? avoidAlertLevel;
final List<VisionSector> sectors;
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'];
/// The nearest obstacle any sensor reports, in metres.
double? get closest {
double? best;
for (final VisionReading r in sensors.values) {
final double? d = r.distance;
if (d != null && (best == null || d < best)) best = d;
}
return best;
}
void apply(Map<String, dynamic>? vision) {
sensors = <String, VisionReading>{
if (vision != null)
for (final MapEntry<String, dynamic> e in vision.entries)
if (e.value is Map) e.key: VisionReading.fromMap(e.key, Map<String, dynamic>.from(e.value as Map)),
};
if (vision != null) updatedAt = DateTime.now();
notifyListeners();
}
/// MSDK `PalmControlState` (Spark's palm control), from the tick's `palmControl`.
String? palmControl;
/// The aircraft is reading or following the pilot's palm.
bool get palmActive => const <String>{'INITIALIZING', 'RECOGNIZING_PALM', 'STARTING_FOLLOW', 'FOLLOWING', 'ADJUSTING_POSITION'}.contains(palmControl);
String get palmLabel => switch (palmControl) {
'INITIALIZING' || 'RECOGNIZING_PALM' => 'Looking for your palm',
'STARTING_FOLLOW' => 'Palm found',
'FOLLOWING' => 'Following your palm',
'ADJUSTING_POSITION' => 'Adjusting position',
_ => 'Off',
};
void applyPalm(String? state) {
if (state == palmControl) return;
palmControl = state;
notifyListeners();
}
void clear() {
sensors = const <String, VisionReading>{};
perception = null;
health = const <String, SensorHealth>{};
updatedAt = null;
palmControl = null;
notifyListeners();
}
}