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>
289 lines
11 KiB
Dart
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();
|
|
}
|
|
}
|