import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../dji_service.dart'; import '../../../flight_model.dart'; import '../../../fly/state/aircraft_state.dart'; import '../../../fly/state/alert_state.dart'; import '../../../fly/state/battery_state.dart'; import '../../../fly/state/camera_state.dart'; import '../../../fly/state/vision_state.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; /// Debug screen for the telemetry foundation (plan P1): every field the bridge /// now reports, grouped by sub-model, each section rebuilt only by its own /// sub-model — the same listening pattern the P2 HUD uses — with the age of /// its last update so a stalled source is obvious. The header starts and stops /// the flight controller's simulator, where P1's fields are verified. class TelemetryInspectorPage extends StatelessWidget { const TelemetryInspectorPage({super.key, required this.model, required this.dji}); final FlightModel model; final DjiService dji; Future _toggleSimulator(BuildContext context) async { final bool active = model.aircraft.simulatorActive == true; if (!active) { final bool? go = await showDialog( context: context, builder: (BuildContext context) => AlertDialog( title: const Text('Start the simulator?'), content: const Text('Remove the propellers first: the motors can spin while the simulator runs. ' 'The aircraft must be on the ground with motors off.'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Propellers are off — start')), ], ), ); if (go != true) return; } try { if (active) { await dji.stopSimulator(); } else { // Start where the pilot is, so the map and home distance make sense. await dji.startSimulator( latitude: model.phoneLatitude ?? model.aircraft.latitude ?? 55.6761, longitude: model.phoneLongitude ?? model.aircraft.longitude ?? 12.5683, ); } } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text('Simulator: ${e is PlatformException ? (e.message ?? e.code) : e}'))); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF0A1120), body: SafeArea( child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 10, 10, 6), child: Row(children: [ const Expanded( child: Text('Telemetry inspector', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)), ), ListenableBuilder( listenable: Listenable.merge([model, model.aircraft]), builder: (BuildContext context, _) { final bool active = model.aircraft.simulatorActive == true; final bool canStart = model.connected && model.profile.hasSimulator && model.aircraft.motorsOn != true; return TextButton.icon( onPressed: active || canStart ? () => _toggleSimulator(context) : null, icon: Icon(active ? Icons.stop_circle_outlined : Icons.play_circle_outline, size: 18), label: Text(active ? 'Stop simulator' : 'Simulator'), style: TextButton.styleFrom(foregroundColor: active ? const Color(0xFFD9852B) : Glass.sat), ); }, ), IconButton( onPressed: () => Navigator.of(context).maybePop(), icon: const PVIcon('close', size: 17, color: Glass.ink), ), ]), ), Expanded(child: TelemetryInspector(model: model)), ]), ), ); } } /// The inspector body, separate from the page so widget tests can pump it. class TelemetryInspector extends StatefulWidget { const TelemetryInspector({super.key, required this.model}); final FlightModel model; @override State createState() => _TelemetryInspectorState(); } class _TelemetryInspectorState extends State { /// Ticks the "updated … ago" labels; sections otherwise rebuild on their own data. final ValueNotifier _clock = ValueNotifier(DateTime.now()); Timer? _timer; @override void initState() { super.initState(); _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _clock.value = DateTime.now()); } @override void dispose() { _timer?.cancel(); _clock.dispose(); super.dispose(); } FlightModel get _m => widget.model; @override Widget build(BuildContext context) { return ListView(padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: [ _Section( title: 'Aircraft · 10 Hz', listenable: _m.aircraft, clock: _clock, updatedAt: () => _m.aircraft.updatedAt, rows: () => _aircraftRows(_m.aircraft), ), _Section( title: 'Return to home', listenable: _m.aircraft, clock: _clock, updatedAt: () => _m.aircraft.updatedAt, rows: () => _goHomeRows(_m.aircraft), ), _Section( title: 'Battery · 2 Hz', listenable: _m.battery, clock: _clock, updatedAt: () => _m.battery.updatedAt, rows: () => _batteryRows(_m.battery), ), _Section( title: 'Links · 2 Hz', listenable: _m.links, clock: _clock, updatedAt: () => _m.links.updatedAt, rows: () => <(String, String)>[ ('Kind', _s(_m.links.kind)), ('Uplink (RC)', _q(_m.links.uplinkQuality)), ('Downlink (HD)', _q(_m.links.downlinkQuality)), ('RC battery', _m.links.rcBatteryPercent == null ? '—' : '${_m.links.rcBatteryPercent}% · ${_m.links.rcBatteryMah ?? '—'} mAh${_m.links.rcCharging == true ? ' · charging' : ''}'), ], ), _Section( title: 'Camera · 2 Hz', listenable: _m.camera, clock: _clock, updatedAt: () => _m.camera.updatedAt, rows: () => _cameraRows(_m.camera), ), _Section( title: 'Gimbal · 10 Hz', listenable: _m.gimbal, clock: _clock, updatedAt: () => _m.gimbal.updatedAt, rows: () => <(String, String)>[ ('Pitch / roll / yaw', '${_deg(_m.gimbal.pitch)} / ${_deg(_m.gimbal.roll)} / ${_deg(_m.gimbal.yaw)}'), ], ), _Section( title: 'Vision · 10 Hz', listenable: _m.vision, clock: _clock, updatedAt: () => _m.vision.updatedAt, rows: () => _visionRows(_m.vision), ), _Section( title: 'Diagnostics', listenable: _m.alerts, clock: _clock, updatedAt: () => _m.alerts.updatedAt, rows: () => _alertRows(_m.alerts), ), ]); } static List<(String, String)> _aircraftRows(AircraftState a) => <(String, String)>[ ('Flight mode', '${_s(a.flightMode)} (${_s(a.flightModeEnum)})'), ('Flying / motors', '${_b(a.isFlying)} / ${_b(a.motorsOn)}'), ('GPS', '${a.satellites ?? '—'} sats · level ${a.gpsSignalLevel ?? '—'}'), ('Attitude p/r/y', '${_deg(a.pitch)} / ${_deg(a.roll)} / ${_deg(a.heading)}'), ('Height', _m1(a.altitude)), ('VPS', a.vpsHeight == null ? 'not in use${a.ultrasonicError == true ? ' · ERROR' : ''}' : '${_m1(a.vpsHeight)}${a.ultrasonicError == true ? ' · ERROR' : ''}'), ('Vision positioning', _b(a.visionPositioningBeingUsed)), ('H / V speed', '${_ms(a.horizontalSpeed)} / ${_ms(a.verticalSpeed)}'), ('Position', a.latitude == null ? '—' : '${a.latitude!.toStringAsFixed(6)}, ${a.longitude!.toStringAsFixed(6)}'), ('Home', a.homeLatitude == null ? 'not set' : '${_m1(a.homeDistance)} away'), ('Flight time', _mmss(a.flightTimeSeconds)), ('Landing confirm', _b(a.landingConfirmationNeeded)), ('Low / serious low', '${_b(a.lowBattery)} / ${_b(a.seriousLowBattery)} · ${_s(a.batteryThresholdBehavior)}'), ('Wind', _s(a.windWarning)), ('Max height / radius hit', '${_b(a.reachedMaxHeight)} / ${_b(a.reachedMaxRadius)}'), ('IMU preheating', _b(a.imuPreheating)), ('Orientation', _s(a.orientationMode)), ('Simulator', _b(a.simulatorActive)), ]; static List<(String, String)> _goHomeRows(AircraftState a) { final GoHomeAssessment? g = a.goHome; return <(String, String)>[ ('Going home', '${_b(a.goingHome)} · ${_s(a.goHomeExecutionState)}'), ('RTH height', _m1(a.goHomeHeight)), ('Remaining flight', _mmss(g?.remainingFlightTime)), ('Needed to go home', g == null ? '—' : '${g.batteryNeededToGoHome ?? '—'}% · ${_mmss(g.timeNeededToGoHome)}'), ('Needed to land', g == null ? '—' : '${g.batteryNeededToLand ?? '—'}% · ${_mmss(g.timeNeededToLand)}'), ('Max radius & return', _m1(g?.maxRadiusCanFlyAndGoHome)), ('Smart RTH', g == null ? '—' : '${_s(g.smartRTHState)} · ${g.smartRTHCountdown ?? '—'} s'), ]; } static List<(String, String)> _batteryRows(BatteryState b) => <(String, String)>[ ('Weakest pack', b.percent == null ? '—' : '${b.percent}% · ${_v(b.voltage)} · ${_c(b.temperature)}'), ('Thresholds', 'low ${b.lowWarningThreshold ?? '—'}% · serious ${b.seriousLowWarningThreshold ?? '—'}%'), for (final BatteryPack p in b.packs) ...<(String, String)>[ ('Pack ${p.index}', '${p.percent ?? '—'}% · ${_v(p.voltage)} · ${p.current?.toStringAsFixed(2) ?? '—'} A · ${_c(p.temperature)}'), (' charge', '${p.chargeRemainingMah ?? '—'} / ${p.fullChargeCapacityMah ?? '—'} mAh (design ${p.designCapacityMah ?? '—'})'), (' cells', p.cellVoltages.isEmpty ? '—' : '${p.cellVoltages.map((double v) => v.toStringAsFixed(2)).join(' ')} V · Δ ${(p.cellDeviation! * 1000).round()} mV'), (' health', '${p.discharges ?? '—'} cycles · life ${p.lifetimePercent ?? '—'}% · ${_s(p.connectionState)}'), ], ]; static List<(String, String)> _cameraRows(CameraState c) { final StorageInfo? s = c.storage; return <(String, String)>[ ('Mode', '${_s(c.mode)}${c.isRecording ? ' · REC ${_mmss(c.recordSeconds)}' : ''}'), ('Exposure', 'ISO ${_s(c.iso)} · ${_s(c.shutter)} · ${_s(c.aperture)} · EV ${_s(c.ev)}'), ('Storage', s == null ? '—' : '${s.status} · ${_s(s.location)}'), ('Space', s == null ? '—' : '${s.remainingMB ?? '—'} / ${s.totalMB ?? '—'} MB'), ('Remaining', s == null ? '—' : '${s.availableCaptureCount ?? '—'} shots · ${_mmss(s.availableRecordingSeconds)}'), ('AE lock / focus', '${_b(c.aeLock)} / ${_s(c.focusMode)}'), ('Photo', '${_s(c.photoAspectRatio)} · ${_s(c.photoFileFormat)}'), ('Video', '${_s(c.videoResolution)} · ${_s(c.videoFrameRate)} · ${_s(c.videoFileFormat)}'), ('Colour', _s(c.colorProfile)), ]; } static List<(String, String)> _visionRows(VisionState v) { if (v.sensors.isEmpty) return const <(String, String)>[('Sensors', 'none reporting')]; return <(String, String)>[ ('Closest', _m1(v.closest)), for (final VisionReading r in v.sensors.values) ( r.position, '${r.isSensorBeingUsed ? 'active' : 'idle'}${r.isDisabled ? ' · disabled' : ''} · ${_s(r.systemWarning)} · ' '${_m1(r.distance)} · [${r.sectors.map((VisionSector s) => s.distance?.toStringAsFixed(1) ?? '·').join(' ')}]' ), ]; } static List<(String, String)> _alertRows(AlertState a) { if (a.all.isEmpty) return const <(String, String)>[('Active', 'none')]; return <(String, String)>[ for (final DiagnosticAlert d in a.all) ('${d.severity.name} · ${d.component} ${d.code}', '${d.reason}${d.solution == null ? '' : ' — ${d.solution}'}'), ]; } static String _s(String? v) => v ?? '—'; static String _b(bool? v) => v == null ? '—' : (v ? 'yes' : 'no'); static String _q(int? v) => v == null ? '—' : '$v / 100'; static String _deg(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)}°'; static String _m1(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} m'; static String _ms(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} m/s'; static String _v(double? v) => v == null ? '—' : '${v.toStringAsFixed(2)} V'; static String _c(double? v) => v == null ? '—' : '${v.toStringAsFixed(1)} °C'; static String _mmss(int? s) => s == null || s < 0 ? '—' : '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}'; } class _Section extends StatelessWidget { const _Section({ required this.title, required this.listenable, required this.clock, required this.updatedAt, required this.rows, }); final String title; final Listenable listenable; final ValueNotifier clock; final DateTime? Function() updatedAt; final List<(String, String)> Function() rows; static const TextStyle _label = TextStyle(fontFamily: PV.fontSans, fontSize: 12, color: Glass.ink); static const TextStyle _value = TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: Color(0xCCEAF0FA)); static const TextStyle _eyebrow = TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA)); @override Widget build(BuildContext context) { return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(top: 18, bottom: 6), child: Row(children: [ Expanded(child: Text(title.toUpperCase(), style: _eyebrow)), ListenableBuilder( listenable: Listenable.merge([listenable, clock]), builder: (BuildContext context, _) { final DateTime? at = updatedAt(); final int? ms = at == null ? null : clock.value.difference(at).inMilliseconds; final bool stale = ms == null || ms > 3000; return Text( ms == null ? 'no data' : (ms < 1000 ? 'live' : '${(ms / 1000).toStringAsFixed(0)} s ago'), style: _eyebrow.copyWith(color: stale ? const Color(0xFFD9852B) : Glass.sat), ); }, ), ]), ), ListenableBuilder( listenable: listenable, builder: (BuildContext context, _) => Column(children: [ for (final (String, String) r in rows()) Container( padding: const EdgeInsets.symmetric(vertical: 5), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 150, child: Text(r.$1, style: _label)), Expanded(child: Text(r.$2, style: _value)), ]), ), ]), ), ]); } }