Files
PilotVault/Fly App/lib/fly/telemetry_reducer.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

115 lines
5.1 KiB
Dart

import 'dart:ui' show Size;
import '../flight_model.dart';
import 'state/parse.dart';
/// Routes the bridge's telemetry-bearing events into [FlightModel]'s sub-models
/// (plan P1). Kept out of the widget tree so it can be unit-tested against
/// recorded events.
///
/// 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`, `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
/// snapshot was never reported by this aircraft and leaves its sub-model empty.
class TelemetryReducer {
TelemetryReducer(this.model);
final FlightModel model;
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', '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) {
switch (event['type'] as String?) {
case 'telemetry':
// Stray packets trailing a disconnect would repopulate what was cleared.
if (model.connected) model.batch(() => _telemetry(event));
return true;
case 'camera':
if (model.connected) model.camera.applySystem(event);
return true;
case 'exposure':
if (model.connected) model.camera.applyExposure(event);
return true;
case 'histogram':
if (model.connected) model.camera.applyHistogram(event);
return true;
case 'diagnostics':
if (model.connected) model.alerts.apply(asMapList(event['items']));
return true;
case 'key':
final String? id = asString(event['id']);
if (model.connected && id != null) model.keys.apply(id, event['value']);
return true;
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());
return true;
}
return false;
}
/// Forgets the last-seen groups, so the next aircraft's first tick applies in full.
void reset() => _lastGroups.clear();
void _telemetry(Map<String, dynamic> t) {
// Flight-controller keys change every tick; absent until the FC first reports.
if (t.containsKey('satelliteCount')) model.aircraft.apply(t);
final Set<String> changed = <String>{};
for (final String g in _groups) {
final Object? v = t[g];
if (_lastGroups.containsKey(g) && deepEquals(_lastGroups[g], v)) continue;
_lastGroups[g] = v;
changed.add(g);
}
if (changed.isEmpty) return;
if (changed.contains('battery')) {
final Map<String, dynamic>? b = asMap(t['battery']);
b == null ? model.battery.clear() : model.battery.apply(b);
}
if (changed.contains('links') || changed.contains('rc')) {
model.links.apply(links: asMap(t['links']), rc: asMap(t['rc']));
}
if (const <String>['storage', 'ssd', 'thermal', 'cameraSettings'].any(changed.contains)) {
model.camera.applyTelemetry(
storage: asMap(t['storage']),
ssd: asMap(t['ssd']),
thermal: asMap(t['thermal']),
settings: asMap(t['cameraSettings']),
);
}
if (changed.contains('gimbal')) {
final Map<String, dynamic>? g = asMap(t['gimbal']);
g == null ? model.gimbal.clear() : model.gimbal.apply(g);
}
if (changed.contains('gimbalSettings')) model.gimbal.applySettings(asMap(t['gimbalSettings']));
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']));
}
}