Files
dmc/app/lib/transport/mock/mock_flight_controller_link.dart
T
Constantin LeueandClaude Sonnet 5 9cece7dbb6 Drone Status Menue: Sensor-Status, Arming, Temperaturen und Steig-/Sinkrate hinzugefuegt
Neue Zeile ganz oben mit farblich kodierten Sensor-Badges (ACC/BARO/MAG/
GPS/RNG/OF/PITOT/TEMP, aus dem sensorStatus-Bitfeld von MSP2_INAV_STATUS,
zuvor schon dokumentiert aber ungenutzt), darunter ein Zeilenpaar fuer
Arming-Status und die ersten 3 Temperatursensoren (neu: MSP2_INAV_
TEMPERATURES, 0x201E). Ans Ende der Liste die Steig-/Sinkrate (vario aus
MSP_ALTITUDE, bislang nur die Hoehe selbst wurde daraus gelesen).

- msp_commands.dart: inavTemperatures-Konstante ergaenzt; die Sensor-
  Status-Bits (bisher nur als Doc-Kommentar bei MSP2_INAV_STATUS notiert)
  zu echten Konstanten (MspSensorStatusBits) promoviert, da jetzt
  tatsaechlich gebraucht.
- msp_telemetry_codec.dart: parseMspAltitudeVerticalSpeedMs,
  parseMspInavStatusSensorStatus, parseMspInavTemperaturesC ergaenzt +
  Unit-Tests.
- TelemetryFrame: sensorStatusBits (Bitmaske, protokollneutral
  durchgereicht wie navMode), temperaturesC (erste 3 Sensoren, null je
  nicht konfiguriertem Slot), verticalSpeedMs.
- msp_telemetry_poller.dart: vario kommt aus derselben MSP_ALTITUDE-
  Antwort wie die Hoehe (keine zusaetzliche Anfrage), sensorStatus aus
  derselben MSP2_INAV_STATUS-Antwort wie ARMED; MSP2_INAV_TEMPERATURES neu
  im 2-Hz-Statuszyklus abgefragt.
- MockFlightControllerLink: synthetische Sensor-/Temperatur-/Vario-Werte
  (kein Pitot/Rangefinder/Opflow am T1 Ranger vorgesehen), leicht
  schwankend, damit die neuen Felder auch ohne Hardware sichtbar auf
  Werteaenderungen reagieren.
- drone_status_messages_panel.dart: eigene Sensor-Status-Zeile (lokale
  Bit-Konstanten statt MSP-Import, analog zum bestehenden navMode-Muster
  in domain/telemetry/drone_status.dart, damit die UI protokollneutral
  bleibt), Arming+Temperaturen-Zeilenpaar, Vertical-speed-Zeile am Ende.

Auf dem Pixel_10a-Emulator verifiziert: Sensor-Badges gruen/grau je nach
Bitmaske, Arming/Temperaturen-Paar, Vertical speed am Listenende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:32:56 +02:00

221 lines
9.1 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../flight_controller_link.dart';
import '../msp/msp_commands.dart';
/// Synthetische Telemetrie fuer UI-Entwicklung/Tests ohne Hardware oder SITL
/// (Architektur-Doku 3.1).
///
/// Verankert am ersten Wegpunkt der uebergebenen Mission (Doku: "solange die
/// Drohne nicht verbunden ist, Mock-Daten fuer die neue Live-Details-
/// Ansicht nutzbar machen") statt an einem fest verdrahteten Ort (frueher
/// Berlin) - dadurch liegt die simulierte Position tatsaechlich auf der
/// gerade bearbeiteten Route, und activeWaypointIndex ist von Anfang an
/// gesetzt (0, "erster Wegpunkt aktiv"), auch ohne den noch nicht
/// umgesetzten Missions-Upload-Flow. Fehlt eine Mission (keine Wegpunkte),
/// bleibt der alte Berlin-Fallback bestehen, damit die Klasse auch ganz
/// ohne Mission nutzbar ist.
class MockFlightControllerLink implements FlightControllerLink {
MockFlightControllerLink({
List<Waypoint> initialWaypoints = const [],
int batteryCellCount = 4,
}) : _anchorLat = initialWaypoints.isEmpty ? 52.5 : initialWaypoints.first.lat,
_anchorLon = initialWaypoints.isEmpty ? 13.4 : initialWaypoints.first.lon,
_activeWaypointIndex = initialWaypoints.isEmpty ? null : 0,
_batteryCellCount = batteryCellCount;
double _anchorLat;
double _anchorLon;
/// Zellenzahl des simulierten Akkus (Doku DroneProfile.batteryCellCount) -
/// bestimmt den Spannungsbereich der Mock-Telemetrie (siehe
/// batteryVoltage unten), damit die Statusanzeige mit den Ampel-
/// Schwellwerten des aktiven Profils zusammenpasst statt fest auf 6S
/// verdrahtet zu sein.
final int _batteryCellCount;
final _random = math.Random();
bool _armed = false;
int? _activeWaypointIndex;
StreamController<TelemetryFrame>? _telemetryController;
Timer? _ticker;
double _heading = 0;
double _batteryPercent = 100;
int _tickCount = 0;
/// Aktuelle Drift vom Ankerpunkt in Metern (Nord/Ost) - Zufalls-Trudeln
/// innerhalb eines 10-m-Radius statt eines fest stehenden Punkts, damit
/// auch die Distanz/ETA-Anzeige der neuen Details-Pille sichtbar auf
/// Werteaenderungen reagiert (Doku 9: UI-Entwicklung ohne Hardware).
double _driftNorthM = 0;
double _driftEastM = 0;
static const _driftRadiusM = 10.0;
static const _driftStepM = 1.5;
static const _metersPerDegreeLat = 111320.0;
@override
FcCapabilities get capabilities => const FcCapabilities(
supportsMultiMission: true,
supportsInFlightUpload: false,
maxWaypoints: 60,
);
@override
Future<void> connect() async {
// Idempotent (Doku flightControllerLinkProvider: "Verbindung bleibt
// ueber Moduswechsel hinweg aktiv") - sonst wuerde jeder erneute
// Eintritt in den Fly-Modus einen zweiten Timer/Controller anlegen und
// den vorherigen verwaist weiterlaufen lassen.
if (_telemetryController != null) return;
_telemetryController = StreamController<TelemetryFrame>.broadcast();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
// Kurs dreht sich langsam weiter statt fest zu stehen, damit die
// Rotation des Drohnen-Markers (DroneMarkerIcon) auch ohne Hardware
// sichtbar ueberprueft werden kann (Doku 9: UI-Entwicklung ohne
// Hardware).
_heading = (_heading + 5) % 360;
// Langsam sinkender Akkustand, damit die Anzeige auch ohne Hardware
// sichtbar auf Werteaenderungen reagiert (Doku 9).
_batteryPercent = (_batteryPercent - 0.1).clamp(0, 100);
_tickCount++;
// Zufallsschritt, danach auf den 10-m-Radius um den Ankerpunkt
// zurueckgeklemmt statt frei wegzulaufen.
_driftNorthM += (_random.nextDouble() - 0.5) * 2 * _driftStepM;
_driftEastM += (_random.nextDouble() - 0.5) * 2 * _driftStepM;
final driftMagnitude = math.sqrt(
_driftNorthM * _driftNorthM + _driftEastM * _driftEastM,
);
if (driftMagnitude > _driftRadiusM) {
final scale = _driftRadiusM / driftMagnitude;
_driftNorthM *= scale;
_driftEastM *= scale;
}
final metersPerDegreeLon =
_metersPerDegreeLat * math.cos(_anchorLat * math.pi / 180);
final lat = _anchorLat + _driftNorthM / _metersPerDegreeLat;
final lon = _anchorLon + _driftEastM / metersPerDegreeLon;
_telemetryController?.add(TelemetryFrame(
lat: lat,
lon: lon,
hasFix: true,
numSat: 12,
altitudeM: 120,
speedMs: 18,
headingDeg: _heading,
armed: _armed,
batteryPercent: _batteryPercent.round(),
// Spannung sinkt mit dem Mock-Ladestand von voll (4.2 V/Zelle,
// LiPo-Ladeschluss) auf nahezu leer (3.0 V/Zelle) mit, skaliert mit
// der Zellenzahl des aktiven Drohnenprofils statt fest auf 6S
// verdrahtet zu sein (Doku: "bei 4s ist 16.8 Volt aber der max
// Wert") - damit reagiert auch die neue Statusanzeige (Doku: "Drone
// battery Voltage") auf Werteaenderungen und durchlaeuft dabei
// realistisch die Ampel-Schwellwerte des Profils.
batteryVoltage:
_batteryCellCount * (3.0 + (_batteryPercent / 100) * 1.2),
currentA: 12.5,
linkQuality: 95,
snrDb: 9,
hdop: 0.9,
// MW_GPS_MODE_NAV (3), sobald armed und eine Mission aktiv ist -
// analog zu flightMode unten, damit computeDroneStatus (Doku:
// "drone state ... waypoint") auch mit Mock-Daten testbar ist.
navMode: _armed && _activeWaypointIndex != null ? 3 : 0,
flightMode: _activeWaypointIndex != null
? 'Waypoint mission (en route)'
: 'Idle',
// Kein Pitot/Rangefinder/Optical-Flow am T1 Ranger vorgesehen (Doku
// Architektur 6: "MSPV2_INAV_AIR_SPEED ... fuer den T1 Ranger
// aktuell nicht relevant") - die uebrigen Sensoren gelten als
// vorhanden/gesund, damit die Sensor-Status-Zeile im Drone-Status-
// Menue sowohl gruene als auch graue Badges zeigt.
sensorStatusBits: MspSensorStatusBits.acc |
MspSensorStatusBits.baro |
MspSensorStatusBits.mag |
MspSensorStatusBits.gps |
MspSensorStatusBits.temp,
// Leichtes Schwanken um drei Basiswerte statt fester Zahlen, damit
// die Temperaturfelder ebenfalls sichtbar auf Werteaenderungen
// reagieren (Doku 9).
temperaturesC: [
28.0 + math.sin(_tickCount * 0.05) * 1.5,
34.0 + math.sin(_tickCount * 0.07) * 2.0,
46.0 + math.sin(_tickCount * 0.06) * 3.0,
],
// Sinuskurve statt fester 0 - die Mock-Hoehe selbst bleibt
// konstant (siehe altitudeM oben), die Steig-/Sinkrate soll aber
// trotzdem sichtbar zwischen Steigen/Sinken wechseln koennen.
verticalSpeedMs: math.sin(_tickCount * 0.3) * 2.5,
activeWaypointIndex: _activeWaypointIndex,
));
});
}
@override
Future<void> disconnect() async {
_ticker?.cancel();
await _telemetryController?.close();
_telemetryController = null;
}
/// Setzt die simulierte Position auf den ersten Wegpunkt von [waypoints]
/// zurueck und loescht die bisherige Drift (Doku: "beim Laden einer
/// Mission Mock-Drohnenposition auf ersten Wegpunkt setzen, solange die
/// Drohne nicht verbunden ist") - der Aufrufer (CurrentMissionMetaNotifier)
/// ruft dies nur auf, wenn [FlightControllerLink] tatsaechlich dieser Mock
/// ist, also noch keine echte Verbindung (WLAN/MSP) aktiv ist. Ohne
/// Wegpunkte faellt die Position auf den alten Berlin-Fallback zurueck,
/// analog zum Konstruktor.
void resetPosition(List<Waypoint> waypoints) {
_anchorLat = waypoints.isEmpty ? 52.5 : waypoints.first.lat;
_anchorLon = waypoints.isEmpty ? 13.4 : waypoints.first.lon;
_driftNorthM = 0;
_driftEastM = 0;
_activeWaypointIndex = waypoints.isEmpty ? null : 0;
}
@override
Future<void> uploadMission(FlatWaypointList mission) async {
// iNAV/MAVLink erlauben Mission-Upload nur im unarmed-Zustand (Doku 4.5).
if (_armed) {
throw StateError('Mission-Upload verweigert: Drohne ist armed.');
}
_uploadedWaypointCount = mission.waypoints.length;
_activeWaypointIndex = mission.waypoints.isEmpty ? null : 0;
}
/// Kein separates FC-seitiges "gueltig"-Flag zu simulieren (anders als
/// MspFlightControllerLink, das echte MSP_WP_GETINFO-Antworten abfragt) -
/// der Mock haelt die zuletzt hochgeladene Wegpunktzahl und vergleicht sie
/// hier direkt, das reicht fuer UI-Entwicklung ohne Hardware.
int? _uploadedWaypointCount;
@override
Future<bool> verifyMission(FlatWaypointList mission) async =>
_uploadedWaypointCount == mission.waypoints.length;
@override
Future<void> setFlightMode(FlightMode mode) async {}
@override
Future<void> arm() async {
_armed = true;
}
@override
Stream<TelemetryFrame> subscribeTelemetry() {
final controller = _telemetryController;
if (controller == null) {
throw StateError('subscribeTelemetry() vor connect() aufgerufen.');
}
return controller.stream;
}
@override
Future<int?> readActiveWaypointIndex() async => _activeWaypointIndex;
}