Fix what the first Fly App simulator runs on a Mavic Pro turned up
- Virtual sticks come from the product matrix: the Mavic Pro answers isVirtualStickControlModeAvailable = false even in flight, which hid the STICKS button. - While landing, the rail's second slot belongs to the landing (the Mavic Pro keeps isGoingHome set through a plain Land, so it showed Cancel RTH beside Confirm landing); Cancel landing is allowed at the confirmation. - Rail buttons have a fixed width so long labels wrap instead of pushing the rail onto the gimbal slider. - Outlined HUD buttons get a transparent background (the app theme filled them white behind white text). Verified in the simulator: take-off, RTH, cancel RTH, land, cancel and confirm landing, and the virtual-stick axes (roll = forward, pitch = right). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56d43ddb1e
commit
63acacced7
8 files changed
+84
-18
No files matched your search
@@ -22,7 +22,8 @@ class RailState {
|
||||
/// Take-off, Land, Cancel landing or Confirm landing.
|
||||
final FlightCommand primary;
|
||||
|
||||
/// Go home or Cancel RTH; null on the ground.
|
||||
/// Go home or Cancel RTH; Cancel landing while waiting for a landing
|
||||
/// confirmation; null on the ground and during an auto-landing.
|
||||
final FlightCommand? rth;
|
||||
|
||||
/// The intelligent mode a Stop button would end; [ActiveMode.none] hides it.
|
||||
@@ -44,8 +45,11 @@ class RailState {
|
||||
final String mode = a.flightModeEnum ?? '';
|
||||
final bool goingHome = a.goingHome == true || mode == 'GO_HOME';
|
||||
|
||||
final bool confirming = a.landingConfirmationNeeded == true || mode == 'CONFIRM_LANDING';
|
||||
final bool landing = confirming || mode == 'AUTO_LANDING';
|
||||
|
||||
final FlightCommand primary;
|
||||
if (a.landingConfirmationNeeded == true || mode == 'CONFIRM_LANDING') {
|
||||
if (confirming) {
|
||||
primary = FlightCommand.confirmLanding;
|
||||
} else if (mode == 'AUTO_LANDING') {
|
||||
primary = FlightCommand.cancelLanding;
|
||||
@@ -55,9 +59,21 @@ class RailState {
|
||||
primary = FlightCommand.takeOff;
|
||||
}
|
||||
|
||||
// While landing, the second slot belongs to the landing: the Mavic Pro keeps
|
||||
// `isGoingHome` set through a plain Land's descent (seen in the simulator,
|
||||
// 2026-10-21), which used to put "Cancel RTH" beside Confirm landing.
|
||||
final FlightCommand? rth;
|
||||
if (!airborne) {
|
||||
rth = null;
|
||||
} else if (landing) {
|
||||
rth = confirming ? FlightCommand.cancelLanding : null;
|
||||
} else {
|
||||
rth = goingHome ? FlightCommand.cancelGoHome : FlightCommand.goHome;
|
||||
}
|
||||
|
||||
return RailState(
|
||||
primary: primary,
|
||||
rth: airborne ? (goingHome ? FlightCommand.cancelGoHome : FlightCommand.goHome) : null,
|
||||
rth: rth,
|
||||
mode: airborne ? activeMode(a, mission) : ActiveMode.none,
|
||||
enabled: reported,
|
||||
);
|
||||
|
||||
@@ -386,8 +386,19 @@ class ProductProfile {
|
||||
sources['batteryKind'] = FactSource.unknown;
|
||||
}
|
||||
|
||||
// `isVirtualStickControlModeAvailable` is a live state, not a capability: a
|
||||
// Mavic Pro answers false on the ground and in flight (simulator,
|
||||
// 2026-10-21). Every matrix aircraft takes MSDK virtual sticks, so the SDK
|
||||
// only decides for models the matrix does not know.
|
||||
final bool? sdkSticks = fc?.virtualStickAvailable;
|
||||
sources['virtualStick'] = sdkSticks == null ? FactSource.unknown : FactSource.sdk;
|
||||
final bool supportsSticks = sdkSticks == true || row != null;
|
||||
sources['virtualStick'] = sdkSticks == true
|
||||
? FactSource.sdk
|
||||
: row != null
|
||||
? FactSource.matrix
|
||||
: sdkSticks == null
|
||||
? FactSource.unknown
|
||||
: FactSource.sdk;
|
||||
|
||||
sources['modes'] = row == null ? FactSource.unknown : FactSource.matrix;
|
||||
sources['cameras'] = FactSource.sdk;
|
||||
@@ -406,7 +417,7 @@ class ProductProfile {
|
||||
batteryCount: batteryCount,
|
||||
batteryKind: batteryKind,
|
||||
modes: row?.modes ?? const <IntelligentMode>{},
|
||||
supportsVirtualStick: sdkSticks ?? false,
|
||||
supportsVirtualStick: supportsSticks,
|
||||
virtualStickPrimary: row?.virtualStickPrimary ?? false,
|
||||
flightController: fc,
|
||||
remoteController: rc,
|
||||
|
||||
@@ -140,7 +140,11 @@ class SafetyGate {
|
||||
if (_a.isFlying != true) checks.add(const GateCheck.block('The aircraft is not flying.'));
|
||||
if (_a.goingHome == true) checks.add(const GateCheck.warn('This ends Return to Home and lands where the aircraft is now.'));
|
||||
case FlightCommand.cancelLanding:
|
||||
if (_a.flightModeEnum != 'AUTO_LANDING') checks.add(const GateCheck.block('The aircraft is not landing.'));
|
||||
if (_a.flightModeEnum != 'AUTO_LANDING' &&
|
||||
_a.flightModeEnum != 'CONFIRM_LANDING' &&
|
||||
_a.landingConfirmationNeeded != true) {
|
||||
checks.add(const GateCheck.block('The aircraft is not landing.'));
|
||||
}
|
||||
case FlightCommand.confirmLanding:
|
||||
if (_a.landingConfirmationNeeded != true && _a.flightModeEnum != 'CONFIRM_LANDING') {
|
||||
checks.add(const GateCheck.block('The aircraft is not waiting for a landing confirmation.'));
|
||||
|
||||
@@ -108,17 +108,22 @@ class RailButton extends StatelessWidget {
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.45,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 20, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: HudText.label.copyWith(fontSize: 7.5)),
|
||||
]),
|
||||
// Fixed width: a long label ("CONFIRM LAND") used to widen the rail and
|
||||
// push the buttons onto the gimbal slider; it wraps instead.
|
||||
child: SizedBox(
|
||||
width: 50,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 20, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, textAlign: TextAlign.center, maxLines: 2, style: HudText.label.copyWith(fontSize: 7.5)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,8 @@ class _Row extends StatelessWidget {
|
||||
onPressed: onAction,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Glass.ink,
|
||||
// The app theme fills outlined buttons with its light surface.
|
||||
backgroundColor: Colors.transparent,
|
||||
side: const BorderSide(color: Glass.hairline),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: const Size(0, 30),
|
||||
|
||||
@@ -284,7 +284,11 @@ class CalibrationScaffold extends StatelessWidget {
|
||||
: OutlinedButton(
|
||||
key: ValueKey<String>('wizard-${a.id}'),
|
||||
onPressed: a.onTap,
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Glass.ink, side: BorderSide(color: color), minimumSize: const Size(100, 42)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Glass.ink,
|
||||
backgroundColor: Colors.transparent,
|
||||
side: BorderSide(color: color),
|
||||
minimumSize: const Size(100, 42)),
|
||||
child: label,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,17 @@ void main() {
|
||||
expect(p.sources['link'], FactSource.matrix);
|
||||
});
|
||||
|
||||
test('virtual sticks: the matrix wins over the SDK\'s live "not available"', () {
|
||||
// A real Mavic Pro answers isVirtualStickControlModeAvailable = false (2026-10-21).
|
||||
final Map<String, dynamic> fcNo = <String, dynamic>{...phantom4['flightController'] as Map<String, dynamic>, 'virtualStickAvailable': false};
|
||||
final ProductProfile mavic = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'model': 'MAVIC_PRO', 'flightController': fcNo});
|
||||
expect(mavic.supportsVirtualStick, isTrue);
|
||||
expect(mavic.sources['virtualStick'], FactSource.matrix);
|
||||
final ProductProfile unknown = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'model': 'SOMETHING_NEW', 'flightController': fcNo});
|
||||
expect(unknown.supportsVirtualStick, isFalse);
|
||||
expect(unknown.sources['virtualStick'], FactSource.sdk);
|
||||
});
|
||||
|
||||
test('battery count falls back to the matrix bay count', () {
|
||||
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...inspire2X5s, 'batteries': <dynamic>[]});
|
||||
expect(p.batteryCount, 2);
|
||||
|
||||
@@ -58,6 +58,18 @@ void main() {
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'CONFIRM_LANDING').primary, FlightCommand.confirmLanding);
|
||||
});
|
||||
|
||||
test('landing owns the second slot even while the FC says it is going home', () {
|
||||
// The Mavic Pro keeps isGoingHome set through a plain Land (simulator, 2026-10-21).
|
||||
final RailState descending = rail(telemetryTick()
|
||||
..['flightModeEnum'] = 'AUTO_LANDING'
|
||||
..['isGoingHome'] = true);
|
||||
expect(descending, const RailState(primary: FlightCommand.cancelLanding));
|
||||
final RailState asking = rail(telemetryTick()
|
||||
..['isLandingConfirmationNeeded'] = true
|
||||
..['isGoingHome'] = true);
|
||||
expect(asking, const RailState(primary: FlightCommand.confirmLanding, rth: FlightCommand.cancelLanding));
|
||||
});
|
||||
|
||||
test('going home: RTH turns into Cancel RTH, Land stays', () {
|
||||
final RailState r = rail(telemetryTick()
|
||||
..['isGoingHome'] = true
|
||||
@@ -146,6 +158,7 @@ void main() {
|
||||
expect(blockers(flying, FlightCommand.cancelGoHome), isNotEmpty);
|
||||
expect(blockers(flying, FlightCommand.stopMode), isNotEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.cancelLanding), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button'));
|
||||
|
||||
Reference in new issue
Block a user