// ============================================================================= // data/autopilot_state.dart — Live autopilot data model // ============================================================================= // // Dual-mode: demo simulation (Sprint 4) or live USB serial (Sprint 7). // // Default constructor starts in demo mode (animated vessel simulation). // Call [connectToSerial] to switch to live data from the AR-Concentrador. // If the serial link drops, the state automatically falls back to demo. // // The public API (fields + methods) is identical in both modes — the UI // never needs to know which mode is active; it reads [isConnected] for // the status indicator only. // ============================================================================= import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import '../services/concentrador_service.dart'; import '../services/parp_codec.dart'; import '../widgets/themed/mode_selector.dart'; class AutopilotState extends ChangeNotifier { // ── Navigation data ────────────────────────────────────────────────────────── double headingDeg = 125.0; double setpointDeg = 125.0; double rudderDeg = 0.0; double sogKn = 6.2; double cogDeg = 127.0; double rotDpm = 0.0; double depthM = 42.5; // ── Autopilot state ────────────────────────────────────────────────────────── AutopilotMode mode = AutopilotMode.standby; /// True when the USB serial link to the concentrador is active. bool isConnected = false; // ── Serial service ─────────────────────────────────────────────────────────── ConcentradorService? _service; // ── Demo simulation ────────────────────────────────────────────────────────── Timer? _demoTimer; final _rng = math.Random(); AutopilotState() { _startDemo(); } // --------------------------------------------------------------------------- // Serial connection // --------------------------------------------------------------------------- /// Connect to the AR-Concentrador via USB serial. /// /// Stops the demo timer and switches to live data. /// Falls back to demo automatically if the link drops. /// /// Throws if the ports cannot be opened (caller should catch and show error). Future connectToSerial({ required String rxPort, required String txPort, int stationId = 2, }) async { _demoTimer?.cancel(); _demoTimer = null; _service?.disconnect(); _service = ConcentradorService( rxPort: rxPort, txPort: txPort, stationId: stationId, ); _service!.onStatus = _onSerialStatus; _service!.onConnectionChanged = _onConnectionChanged; await _service!.connect(); // may throw — caller handles } /// Disconnect the serial link and return to demo mode. Future disconnectSerial() async { await _service?.disconnect(); _service = null; _startDemo(); } void _onConnectionChanged(bool connected) { isConnected = connected; if (!connected) { // Link dropped — fall back to animated demo so the UI stays alive. _startDemo(); } notifyListeners(); } void _onSerialStatus(ParpStatus status) { isConnected = true; headingDeg = status.headingDeg; setpointDeg = status.setpointDeg; rudderDeg = status.rudderDeg; mode = status.mode; notifyListeners(); } // --------------------------------------------------------------------------- // Demo simulation // --------------------------------------------------------------------------- void _startDemo() { _demoTimer?.cancel(); _demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick()); } void _tick() { switch (mode) { case AutopilotMode.standby: headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360; if (headingDeg < 0) headingDeg += 360; rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8).clamp(-5.0, 5.0); rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2; case AutopilotMode.headingHold: case AutopilotMode.trackKeep: final error = _angleDiff(setpointDeg, headingDeg); rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5).clamp(-35.0, 35.0); headingDeg = (headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08) % 360; if (headingDeg < 0) headingDeg += 360; rotDpm = error * 0.4; } cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3) % 360; if (cogDeg < 0) cogDeg += 360; notifyListeners(); } double _angleDiff(double target, double current) { double d = (target - current) % 360; if (d > 180) d -= 360; if (d < -180) d += 360; return d; } // --------------------------------------------------------------------------- // Commands — work in both demo and serial modes // --------------------------------------------------------------------------- void engage() { setpointDeg = headingDeg; mode = AutopilotMode.headingHold; _service?.sendEngage(headingDeg); notifyListeners(); } void disengage() { mode = AutopilotMode.standby; _service?.sendDisengage(); notifyListeners(); } void adjustSetpoint(double deltaDeg) { if (mode != AutopilotMode.headingHold) return; setpointDeg = (setpointDeg + deltaDeg) % 360; if (setpointDeg < 0) setpointDeg += 360; // Route to the appropriate serial command if (_service != null && isConnected) { if (deltaDeg == -10) _service!.sendPortTen(setpointDeg); else if (deltaDeg == -1) _service!.sendPortOne(setpointDeg); else if (deltaDeg == 1) _service!.sendStbdOne(setpointDeg); else if (deltaDeg == 10) _service!.sendStbdTen(setpointDeg); else _service!.sendSetHeading(setpointDeg); } notifyListeners(); } void selectMode(AutopilotMode newMode) { switch (newMode) { case AutopilotMode.standby: disengage(); case AutopilotMode.headingHold: engage(); case AutopilotMode.trackKeep: mode = AutopilotMode.trackKeep; setpointDeg = headingDeg; notifyListeners(); } } // --------------------------------------------------------------------------- // Port discovery (delegate to service layer) // --------------------------------------------------------------------------- static List availablePorts() => ConcentradorService.availablePorts(); @override void dispose() { _demoTimer?.cancel(); _service?.disconnect(); super.dispose(); } }