Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
6.1 KiB
Dart
138 lines
6.1 KiB
Dart
import 'dart:ui' show Size;
|
|
|
|
import '../flight_model.dart';
|
|
import 'rc_buttons.dart';
|
|
import 'state/link_extras.dart';
|
|
import 'state/live_stream_state.dart' show MediaPlayback;
|
|
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 'liveStream':
|
|
// App-level in the SDK: a stream can outlive the connection.
|
|
model.liveStream.apply(event);
|
|
return true;
|
|
case 'mediaPlayback':
|
|
model.mediaPlayback.value = MediaPlayback.fromEvent(event);
|
|
return true;
|
|
case 'rcButton':
|
|
final RcButton? b = RcButton.fromEvent(event['button']);
|
|
if (model.connected && b != null) model.rcButton.value = RcButtonPress(b, (model.rcButton.value?.seq ?? 0) + 1);
|
|
return true;
|
|
case 'rcMasters':
|
|
model.rcMasters.value = <RcPeer>[for (final Map<String, dynamic> m in asMapList(event['masters'])) RcPeer.fromMap(m)];
|
|
return true;
|
|
case 'spectrum':
|
|
model.spectrum.value = Spectrum.fromEvent(event);
|
|
return true;
|
|
case 'flyZoneDbRemote':
|
|
model.flyZones.applyRemoteVersion(asString(event['preciseRemoteVersion']));
|
|
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']));
|
|
}
|
|
}
|