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>
176 lines
6.4 KiB
Dart
176 lines
6.4 KiB
Dart
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;
|
|
}
|
|
}
|