Files
PilotVault/Fly App/lib/fly/state/camera_state.dart
T
tajniak81andClaude Opus 5.5 60c2fd7f64 Give the Fly App GO 4-style gimbal controls
Plan P5: a value bubble on the pitch slider while dragging, press-and-drag
on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a
gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down /
yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness
rows gated by the gimbal's capability ranges.

GimbalBridge reports mode, trim, calibration and stop/overload state in the
gimbal telemetry group, and getter-only settings in a new gimbalSettings slow
group. The reported-plus-pending overlay moves out of CameraState into a
SettingsOverlay mixin shared by camera and gimbal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:42 +02:00

289 lines
11 KiB
Dart

import 'package:flutter/foundation.dart';
import 'parse.dart';
import 'settings_overlay.dart';
/// The camera's storage (SD card, or internal storage on cameras that have it).
@immutable
class StorageInfo {
const StorageInfo({
this.location,
this.inserted = false,
this.initializing = false,
this.formatting = false,
this.formatted = false,
this.full = false,
this.error = false,
this.readOnly = false,
this.invalidFormat = false,
this.verified = false,
this.totalMB,
this.remainingMB,
this.availableCaptureCount,
this.availableRecordingSeconds,
});
factory StorageInfo.fromMap(Map<String, dynamic> m) => StorageInfo(
location: asString(m['location']),
inserted: asBool(m['inserted']) ?? false,
initializing: asBool(m['initializing']) ?? false,
formatting: asBool(m['formatting']) ?? false,
formatted: asBool(m['formatted']) ?? false,
full: asBool(m['full']) ?? false,
error: asBool(m['error']) ?? false,
readOnly: asBool(m['readOnly']) ?? false,
invalidFormat: asBool(m['invalidFormat']) ?? false,
verified: asBool(m['verified']) ?? false,
totalMB: asInt(m['totalMB']),
remainingMB: asInt(m['remainingMB']),
availableCaptureCount: asInt(m['availableCaptureCount']),
availableRecordingSeconds: asInt(m['availableRecordingSeconds']),
);
final String? location; // SDCARD / INTERNAL_STORAGE
final bool inserted;
final bool initializing;
final bool formatting;
final bool formatted;
final bool full;
final bool error;
final bool readOnly;
final bool invalidFormat;
final bool verified;
final int? totalMB;
final int? remainingMB;
final int? availableCaptureCount;
final int? availableRecordingSeconds;
/// One word for the HUD's storage chip, worst condition first.
String get status {
if (!inserted) return 'No SD';
if (error) return 'SD error';
if (invalidFormat) return 'Format SD';
if (formatting) return 'Formatting';
if (initializing) return 'Initializing';
if (full) return 'SD full';
if (readOnly) return 'Read-only';
return 'OK';
}
bool get usable => inserted && !error && !invalidFormat && !formatting && !initializing && !full;
}
/// The SSD on cameras that record to one (X5S / X7 on Inspire 2).
@immutable
class SsdInfo {
const SsdInfo({this.connected = false, this.state, this.capacity, this.remainingMB, this.availableRecordingSeconds});
factory SsdInfo.fromMap(Map<String, dynamic> m) => SsdInfo(
connected: asBool(m['connected']) ?? false,
state: asString(m['state']),
capacity: asString(m['capacity']),
remainingMB: asInt(m['remainingMB']),
availableRecordingSeconds: asInt(m['availableRecordingSeconds']),
);
final bool connected;
/// `SSDOperationState`: `NOT_FOUND` / `IDLE` / `SAVING` / `FORMATTING` /
/// `INITIALIZING` / `ERROR` / `FULL` / `POOR_CONNECTION` …
final String? state;
final String? capacity; // CAPACITY_512_GB …
final int? remainingMB;
final int? availableRecordingSeconds;
/// One word for the HUD's SSD chip, like [StorageInfo.status].
String get status {
if (!connected || state == 'NOT_FOUND') return 'No SSD';
return switch (state) {
'ERROR' => 'SSD error',
'FULL' => 'SSD full',
'FORMATTING' => 'Formatting',
'INITIALIZING' => 'Initializing',
'POOR_CONNECTION' => 'SSD loose',
_ => 'OK',
};
}
bool get usable => status == 'OK';
}
/// Optical / hybrid zoom state. Focal lengths are in the camera's own units
/// (tenths of a millimetre on the Mavic 2 Zoom); [factor] is relative to [min].
@immutable
class ZoomInfo {
const ZoomInfo({required this.focalLength, required this.min, required this.max, this.opticalMax, this.step});
static ZoomInfo? fromSettings(Map<String, dynamic> m) {
final int? f = asInt(m['zoomFocalLength']), lo = asInt(m['zoomMin']), hi = asInt(m['zoomMax']);
if (f == null || lo == null || hi == null || lo <= 0 || hi <= lo) return null;
return ZoomInfo(focalLength: f, min: lo, max: hi, opticalMax: asInt(m['zoomOpticalMax']), step: asInt(m['zoomStep']));
}
final int focalLength;
final int min;
final int max;
final int? opticalMax;
final int? step;
double get factor => focalLength / min;
double get maxFactor => max / min;
/// Past the optical range on a hybrid camera (the image is cropped).
bool get digital => opticalMax != null && focalLength > opticalMax!;
/// The next focal length one T / W press lands on: an eighth of the range,
/// snapped to the camera's step.
int stepped(bool zoomIn) {
final int s = (step ?? 1) <= 0 ? 1 : step!;
final int raw = ((max - min) / 8).round();
final int delta = raw < s ? s : (raw ~/ s) * s;
return (focalLength + (zoomIn ? delta : -delta)).clamp(min, max);
}
}
/// Camera state from the bridge: recording, exposure read-back, storage and
/// the watched camera settings. Recording and exposure arrive as their own
/// `camera` / `exposure` events; storage and settings as the `storage` and
/// `cameraSettings` groups of the telemetry tick (≤2 Hz).
class CameraState extends ChangeNotifier with SettingsOverlay {
bool isRecording = false;
int recordSeconds = 0;
// Shooting flags from the system state.
bool shootingSingle = false;
bool shootingInterval = false;
bool shootingBurst = false;
bool shootingPanorama = false;
int panoramaProgress = 0; // 0..100
bool storingPhoto = false;
bool overheating = false;
bool hasError = false;
/// Any multi-shot capture the shutter button should stop rather than start.
bool get shootingContinuous => shootingInterval || shootingPanorama || shootingBurst;
/// `SHOOT_PHOTO` / `RECORD_VIDEO` / `PLAYBACK` / `MEDIA_DOWNLOAD`.
String? mode;
// Exposure read-back, in the same labels the setters accept.
String? iso; // "100", "AUTO", …
String? shutter; // "1/240"
String? aperture; // "f/2.8"
String? ev; // "-0.3"
StorageInfo? storage;
/// Only on cameras with an SSD slot; null elsewhere.
SsdInfo? ssd;
/// Spot-meter temperature in °C, only from thermal cameras.
double? thermalSpot;
// The watched settings, as the HUD shows them (pending writes included).
bool? get aeLock => asBool(settings['aeLock']);
String? get focusMode => asString(settings['focusMode']); // MANUAL / AUTO / AFC
String? get photoAspectRatio => asString(settings['photoAspectRatio']); // RATIO_4_3 / RATIO_16_9 / RATIO_3_2
String? get photoFileFormat => asString(settings['photoFileFormat']); // JPEG / RAW / RAW_AND_JPEG
String? get videoResolution => asString(settings['videoResolution']); // RESOLUTION_3840x2160 …
String? get videoFrameRate => asString(settings['videoFrameRate']); // FRAME_RATE_29_DOT_970_FPS …
String? get videoFileFormat => asString(settings['videoFileFormat']); // MOV / MP4
String? get colorProfile => asString(settings['colorProfile']); // NONE / D_LOG / …
// [settings] ([SettingsOverlay]) is the whole `cameraSettings` group —
// every id of `CameraBridge.kt`'s settings table plus the composite values —
// overlaid with the camera menu's pending writes. The menu's rows read it
// through [setting].
/// `PROGRAM` / `SHUTTER_PRIORITY` / `APERTURE_PRIORITY` / `MANUAL`.
String? get exposureMode => asString(settings['exposureMode']);
/// The ISO setting (`AUTO` included); [iso] is what the camera is using now.
String? get isoSetting => asString(settings['isoSetting']);
String? get whiteBalancePreset => asString(settings['whiteBalancePreset']);
int? get whiteBalanceKelvin => asInt(settings['whiteBalanceKelvin']);
String? get shootPhotoMode => asString(settings['shootPhotoMode']);
String? get meteringMode => asString(settings['meteringMode']);
int? get intervalSeconds => asInt(settings['intervalSeconds']);
int? get intervalCount => asInt(settings['intervalCount']);
bool get histogramEnabled => asBool(settings['histogramEnabled']) ?? false;
/// Zoom, in the camera's own focal-length units; null on cameras without it.
ZoomInfo? get zoom => ZoomInfo.fromSettings(settings);
/// Luminance histogram bins while [histogramEnabled]; a separate notifier
/// because frames arrive at 4 Hz and only the histogram widget redraws.
final ValueNotifier<List<int>?> histogram = ValueNotifier<List<int>?>(null);
DateTime? updatedAt;
void applySystem(Map<String, dynamic> e) {
isRecording = asBool(e['isRecording']) ?? false;
recordSeconds = asInt(e['recordingTimeSeconds']) ?? recordSeconds;
mode = asString(e['mode']) ?? mode;
shootingSingle = asBool(e['shootingSingle']) ?? false;
shootingInterval = asBool(e['shootingInterval']) ?? false;
shootingBurst = asBool(e['shootingBurst']) ?? false;
shootingPanorama = asBool(e['shootingPanorama']) ?? false;
panoramaProgress = asInt(e['panoramaProgress']) ?? 0;
storingPhoto = asBool(e['storingPhoto']) ?? false;
overheating = asBool(e['overheating']) ?? false;
hasError = asBool(e['hasError']) ?? false;
notifyListeners();
}
void applyHistogram(Map<String, dynamic> e) {
final Object? bins = e['bins'];
histogram.value = bins is List ? <int>[for (final Object? b in bins) asInt(b) ?? 0] : null;
}
/// Sets [mode] ahead of the camera's own `camera` event, so the HUD follows
/// the photo / video switch at once.
void setModeOptimistic(String mode) {
if (this.mode == mode) return;
this.mode = mode;
notifyListeners();
}
bool get isVideoMode => mode == 'RECORD_VIDEO';
void applyExposure(Map<String, dynamic> e) {
iso = asString(e['iso']);
shutter = asString(e['shutter']);
aperture = asString(e['aperture']);
ev = asString(e['ev']);
notifyListeners();
}
/// From the telemetry tick; an absent group means the camera never reported it.
void applyTelemetry({
Map<String, dynamic>? storage,
Map<String, dynamic>? ssd,
Map<String, dynamic>? thermal,
Map<String, dynamic>? settings,
}) {
this.storage = storage == null ? null : StorageInfo.fromMap(storage);
this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd);
thermalSpot = finite(thermal?['spotTemperature']);
replaceReported(settings ?? const <String, dynamic>{});
updatedAt = DateTime.now();
notifyListeners();
}
void clear() {
isRecording = false;
recordSeconds = 0;
shootingSingle = shootingInterval = shootingBurst = shootingPanorama = false;
panoramaProgress = 0;
storingPhoto = overheating = hasError = false;
clearSettings();
histogram.value = null;
mode = iso = shutter = aperture = ev = null;
storage = null;
ssd = null;
thermalSpot = null;
updatedAt = null;
notifyListeners();
}
}