Keep the screen on in flight, time out silent vision starts, gate Tripod on the ground

From the Mavic Pro simulator run on 2026-10-22:
- The tablet's 2-minute sleep blanked the flight screen mid-flight. The
  flight screen now sets FLAG_KEEP_SCREEN_ON while it is open.
- TapFly's GO got no answer from the aircraft and no error, so the button
  looked dead. ActiveTrack/TapFly starts now give up after 15 s with "the
  aircraft did not answer".
- Tripod works in the air but the flight controller ignores it on the
  ground, so the gate asks to take off first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-22 10:38:07 +02:00
1 parent a7984d7f28
commit 8f259339fc
7 files changed
+76 -1

No files matched your search

@@ -4,10 +4,12 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// FlutterFragmentActivity (not FlutterActivity) is required by local_auth so the
// platform BiometricPrompt can attach to a FragmentActivity host.
@@ -21,6 +23,16 @@ class MainActivity : FlutterFragmentActivity() {
// Live DJI video feed rendered behind the Flight Control HUD.
flutterEngine.platformViewsController.registry
.registerViewFactory(DjiVideoView.VIEW_TYPE, DjiVideoViewFactory())
// The flight screen keeps the display on (see ScreenAwake in Dart).
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "pilotvault/screen").setMethodCallHandler { call, result ->
if (call.method == "keepOn") {
if (call.arguments == true) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
result.success(null)
} else {
result.notImplemented()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
+2
View File
@@ -936,6 +936,8 @@ class SafetyGate {
if (!model.connected) const GateCheck.block('No aircraft connected.'),
if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none)
const GateCheck.block('Stop the intelligent flight mode that is running first.'),
// A Mavic Pro accepts the write on the ground and keeps Tripod off.
if (model.connected && on && !cinematic && _a.isFlying != true) const GateCheck.block('Take off first: Tripod works in flight.'),
if (model.connected && on)
GateCheck.warn(cinematic
? 'The aircraft brakes and turns slowly: give it room to stop.'
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/services.dart';
/// Keeps the tablet's screen on while the flight screen is open: Android's
/// sleep timeout (2 min on the dev tablet) otherwise blanks the HUD mid-flight.
abstract final class ScreenAwake {
static const MethodChannel _channel = MethodChannel('pilotvault/screen');
/// Best-effort: a missing host (widget tests) just leaves the screen alone.
static Future<void> keepOn(bool on) async {
try {
await _channel.invokeMethod<void>('keepOn', on);
} catch (_) {}
}
}
+3
View File
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart' show LatLng;
import '../../dji_service.dart';
import '../../flight_model.dart';
import '../../fly/screen_awake.dart';
import '../../fly/calibration.dart' show CalibrationKind;
import '../../fly/flight_trail.dart';
import '../../fly/fly_zone_loader.dart';
@@ -188,6 +189,7 @@ class _FlyScreenState extends State<FlyScreen> {
@override
void initState() {
super.initState();
ScreenAwake.keepOn(true);
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
@@ -549,6 +551,7 @@ class _FlyScreenState extends State<FlyScreen> {
@override
void dispose() {
ScreenAwake.keepOn(false);
_m.removeListener(_onModel);
_m.hudHidden.removeListener(_onHudHidden);
_m.mission.removeListener(_onMission);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../flight_model.dart';
@@ -33,12 +35,19 @@ Future<void> startVisionMode(
if (r == null) return;
}
try {
await start();
// A start the aircraft never answers (TapFly on a Mavic Pro in the
// simulator) would otherwise leave GO looking dead.
await start().timeout(startTimeout);
} on TimeoutException {
onError(title, 'the aircraft did not answer; try again or fly by hand');
} catch (e) {
onError(title, e);
}
}
/// How long [startVisionMode] waits for the aircraft to accept a start.
const Duration startTimeout = Duration(seconds: 15);
/// The ActiveTrack / TapFly panel at the head of the left column: what the
/// operator is doing, the follow style or direction, TapFly's speed, Stop
/// while it flies, and Exit.
+7
View File
@@ -153,6 +153,13 @@ void main() {
expect(dji.calls.last, 'setKey(CINEMATIC_MODE_ENABLED, false)');
});
test('Tripod waits for take-off; Cinematic does not', () {
final SafetyGate g = SafetyGate(model: _model(telemetryTick()..['isFlying'] = false), dji: FakeDjiService());
expect(_blockers(g.checkSlowMode(cinematic: false, on: true)), contains(contains('Take off first')));
expect(_blockers(g.checkSlowMode(cinematic: true, on: true)), isEmpty);
expect(_blockers(g.checkSlowMode(cinematic: false, on: false)), isEmpty, reason: 'turning it off is always allowed');
});
test('a Tripod write the aircraft ignores is reported, not silent', () async {
final _KeepsTripodOff dji = _KeepsTripodOff();
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
+28
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
@@ -11,6 +13,7 @@ import 'package:pilotvault_fly/fly/state/mission_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
import 'package:pilotvault_fly/ui/fly/modes/vision/vision_panel.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
@@ -444,4 +447,29 @@ void main() {
expect(dji.calls.last, 'stopTapFly');
});
});
testWidgets('a vision start the aircraft never answers reports an error', (WidgetTester tester) async {
final List<String> errors = <String>[];
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (BuildContext context) => TextButton(
onPressed: () => startVisionMode(
context,
title: 'TapFly · Forward',
icon: 'chevronsRight',
description: '',
checks: const <GateCheck>[],
start: () => Completer<void>().future,
onError: (String label, Object e) => errors.add('$label: $e'),
),
child: const Text('GO'),
),
),
));
await tester.tap(find.text('GO'));
await tester.pump(const Duration(seconds: 14));
expect(errors, isEmpty);
await tester.pump(const Duration(seconds: 2));
expect(errors.single, contains('did not answer'));
});
}