Files
PilotVault/Fly App/lib/fly/virtual_sticks.dart
T
tajniak81andClaude Opus 5.5 e02bb0e5cd Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones
Vision radar: graded obstacle sectors around the centre of the video,
the nearest distance per side, the obstacle overhead, and BLOCKED on a
side whose sensors cannot see once the motors run. On aircraft with 360°
sensing, sides without their own readings are filled from the
perception ring. The same sectors sit on the attitude ball's rim, turned
by the heading. Settings > Vision gains Display radar chart and the
braking / sensing distances per direction.

On-screen sticks: the rail's STICKS button hands the aircraft to two
stick pads after a slide-to-confirm. The 25 Hz stream runs natively and
sends zeros once Dart's last command is 300 ms old, so a stalled UI
leaves the aircraft hovering. The sticks close when the remote takes
over, on RTH or on landing. They are simulator-only on aircraft with a
remote unless Settings > General > Developer allows real flights; Spark
and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward
in velocity + body mode is coded from DJI's documentation and must be
checked in the simulator first.

AirSense: a banner for manned aircraft at warning level 2 and up, with
distance, direction and ICAO code; the planes on the map; the full list
in a sheet and Settings > Safety; and the aircraft's own avoidance
action with its countdown.

Fly zones: DJI GEO zones around the aircraft (or the phone) on the map
with their height limits, a banner for FlySafe notices and in/near-zone
states, take-off refused in restricted and locked authorization zones,
self-unlock of authorization zones, the aircraft's licences (enable,
sync from the DJI account, remove all; ground only), and the GEO
database version with an update button.

Tested with unit and widget tests and screenshot renders only; nothing
has run on an aircraft or the simulator yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:14:12 +02:00

218 lines
6.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:ui' show Offset;
import 'package:flutter/foundation.dart';
/// Which stick does what, as on the remote (RC `AircraftMappingStyle`).
enum StickMode {
/// Left: forward/back + yaw; right: throttle + sideways.
mode1('Mode 1'),
/// Left: throttle + yaw; right: forward/back + sideways. DJI's default.
mode2('Mode 2'),
/// Left: forward/back + sideways; right: throttle + yaw.
mode3('Mode 3');
const StickMode(this.label);
final String label;
}
/// What a full stick deflection means.
@immutable
class StickLimits {
const StickLimits({this.horizontal = 5, this.vertical = 2, this.yaw = 60});
/// GO 4's Wi-Fi joystick default is gentle; these are a step below P mode.
static const StickLimits normal = StickLimits();
static const StickLimits gentle = StickLimits(horizontal: 2, vertical: 1, yaw: 30);
final double horizontal; // m/s
final double vertical; // m/s
final double yaw; // °/s
}
/// One packet for the native stick loop: body-frame velocities and a yaw rate.
@immutable
class StickCommand {
const StickCommand({this.forward = 0, this.right = 0, this.yaw = 0, this.up = 0});
static const StickCommand zero = StickCommand();
final double forward; // m/s
final double right; // m/s
final double yaw; // °/s, clockwise
final double up; // m/s
bool get isZero => forward == 0 && right == 0 && yaw == 0 && up == 0;
@override
bool operator ==(Object other) =>
other is StickCommand && other.forward == forward && other.right == right && other.yaw == yaw && other.up == up;
@override
int get hashCode => Object.hash(forward, right, yaw, up);
@override
String toString() => 'StickCommand(f ${forward.toStringAsFixed(2)}, r ${right.toStringAsFixed(2)}, '
'yaw ${yaw.toStringAsFixed(1)}, up ${up.toStringAsFixed(2)})';
}
/// Stick positions → [StickCommand] (plan P8). Pure, so the mapping is unit-tested.
///
/// Positions are −1…1 with x to the right and **y up** (push = +1).
abstract final class StickMapper {
/// Below this deflection the stick counts as centred (thumbs never rest at 0).
static const double deadzone = 0.08;
/// Share of the cubic term: small deflections are finer than a linear map.
static const double expo = 0.4;
/// The curve one axis goes through: deadzone, rescale, expo.
static double shape(double v) {
final double a = v.abs().clamp(0.0, 1.0);
if (a <= deadzone) return 0;
final double x = (a - deadzone) / (1 - deadzone);
return (expo * x * x * x + (1 - expo) * x) * v.sign;
}
static StickCommand command(Offset left, Offset right, {StickMode mode = StickMode.mode2, StickLimits limits = StickLimits.normal}) {
final (double throttle, double yaw, double pitch, double roll) = switch (mode) {
StickMode.mode1 => (right.dy, left.dx, left.dy, right.dx),
StickMode.mode2 => (left.dy, left.dx, right.dy, right.dx),
StickMode.mode3 => (right.dy, right.dx, left.dy, left.dx),
};
double r(double v) => v == 0 ? 0 : v; // no −0.0 in packets or tests
return StickCommand(
forward: r(shape(pitch) * limits.horizontal),
right: r(shape(roll) * limits.horizontal),
yaw: r(shape(yaw) * limits.yaw),
up: r(shape(throttle) * limits.vertical),
);
}
/// Clamps a finger's offset from the pad centre into the unit circle, y up.
static Offset normalise(Offset fromCentre, double radius) {
if (radius <= 0) return Offset.zero;
Offset v = Offset(fromCentre.dx / radius, -fromCentre.dy / radius);
final double len = v.distance;
if (len > 1) v = v / len;
return v;
}
}
/// The on-screen sticks' state while they fly the aircraft.
///
/// [setLeft] / [setRight] send at once when the command changes, and a
/// [heartbeat] timer repeats it while a stick is off centre — the native loop
/// hovers once a command is 300 ms old, so a held stick must keep saying so.
/// Centred sticks send one zero and go quiet.
///
/// [open] is what the pilot asked for; the aircraft's own answer
/// (`virtualStickEnabled` in the telemetry) closes it through [onAircraft] when
/// the remote's mode switch or RTH takes over.
class VirtualStickController extends ChangeNotifier {
VirtualStickController({required this.send, this.heartbeat = const Duration(milliseconds: 100)});
/// Delivers one command to the bridge.
final Future<void> Function(StickCommand c) send;
final Duration heartbeat;
StickMode mode = StickMode.mode2;
StickLimits limits = StickLimits.normal;
Offset left = Offset.zero;
Offset right = Offset.zero;
/// The sticks are on and flying the aircraft.
bool open = false;
/// Why the sticks last closed without the pilot asking, for one snackbar.
String? closedReason;
/// The aircraft confirmed virtual-stick mode since [open] turned on.
bool _confirmed = false;
StickCommand _last = StickCommand.zero;
Timer? _beat;
StickCommand get command => StickMapper.command(left, right, mode: mode, limits: limits);
void setLeft(Offset v) {
left = v;
_changed();
}
void setRight(Offset v) {
right = v;
_changed();
}
/// Both sticks back to centre (a finger lifted off a pad does this too).
void centre() {
left = right = Offset.zero;
_changed();
}
void _changed() {
notifyListeners();
if (!open) return;
final StickCommand c = command;
if (c != _last) _emit(c);
if (c.isZero) {
_beat?.cancel();
_beat = null;
} else {
_beat ??= Timer.periodic(heartbeat, (_) => _emit(command));
}
}
void _emit(StickCommand c) {
_last = c;
unawaited(send(c).catchError((Object _) {}));
}
/// The aircraft entered virtual-stick mode.
void opened() {
open = true;
_confirmed = false;
closedReason = null;
left = right = Offset.zero;
_last = StickCommand.zero;
notifyListeners();
}
/// Stops sending; [reason] is shown when the aircraft, not the pilot, ended it.
void close([String? reason]) {
_beat?.cancel();
_beat = null;
final bool wasOpen = open;
open = false;
left = right = Offset.zero;
_last = StickCommand.zero;
if (wasOpen) closedReason = reason;
notifyListeners();
}
/// Follows the flight controller: once it has confirmed the mode, a report
/// that it left (remote mode switch), or an RTH / landing, closes the sticks.
void onAircraft({required bool? enabled, required bool goingHome, required bool landing}) {
if (!open) return;
if (enabled == true) _confirmed = true;
if (_confirmed && enabled == false) {
close('The remote took back control');
} else if (goingHome) {
close('Return to Home took over');
} else if (landing) {
close('The aircraft is landing');
}
}
@override
void dispose() {
_beat?.cancel();
super.dispose();
}
}