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>
175 lines
5.6 KiB
Dart
175 lines
5.6 KiB
Dart
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();
|
||
}
|
||
}
|