feat(display): Sprint 7 — USB serial link to AR-Concentrador via NMEA $PARP
- Add flutter_libserialport dependency (pubspec.yaml) - New ParpCodec: XOR-checksum NMEA parser + command builders for all PARP sentences - New ConcentradorService: manages two independent COM ports (RX-OUT broadcast, TX-IN commands) at 115200/8N1; auto-fires onConnectionChanged on link drop - AutopilotState: dual-mode operation (demo timer OR live serial); connectToSerial / disconnectSerial; command methods (engage/disengage/adjustSetpoint) forward to ConcentradorService when connected; falls back to demo on disconnect - New PortSettingsScreen (/settings/ports): RX+TX dropdowns populated from SerialPort.availablePorts, persisted in SharedPreferences; Connect/Disconnect buttons with error display and snackbar feedback - main.dart: auto-connect to saved ports on startup (silent fail → demo mode); registers /settings/ports route - CockpitScreen: gear icon replaced with PopupMenuButton (Puertos COM / Apariencia) AR_electronics — AR-Autopilot Project
This commit is contained in:
@@ -2,9 +2,15 @@
|
||||
// data/autopilot_state.dart — Live autopilot data model
|
||||
// =============================================================================
|
||||
//
|
||||
// Sprint 4: simulated demo data (vessel drifting / heading-hold correction).
|
||||
// Sprint 7: internals replaced by Modbus RTU over USB serial.
|
||||
// The public API (fields + methods) must not change between sprints.
|
||||
// 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';
|
||||
@@ -12,48 +18,30 @@ 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';
|
||||
|
||||
/// Live state of the autopilot and navigation instruments.
|
||||
///
|
||||
/// Consumed by [CockpitScreen] via `context.watch<AutopilotState>()`.
|
||||
///
|
||||
/// ## Data sources (Sprint 4 → Sprint 7)
|
||||
/// Sprint 4: internal [Timer]-driven demo that simulates a vessel at sea.
|
||||
/// Sprint 7: [AutopilotStateModbus] subclass (or internal swap) feeds real
|
||||
/// Modbus RTU data received over USB serial from the AR-Concentrador.
|
||||
class AutopilotState extends ChangeNotifier {
|
||||
// ── Navigation data ──────────────────────────────────────────────────────────
|
||||
/// Current vessel heading from NMEA 2000 backbone (degrees, magnetic, 0–359.9).
|
||||
double headingDeg = 125.0;
|
||||
|
||||
/// Operator-commanded heading setpoint sent to autopilot (degrees, magnetic).
|
||||
double headingDeg = 125.0;
|
||||
double setpointDeg = 125.0;
|
||||
|
||||
/// Rudder angle in degrees. Negative = port, positive = starboard.
|
||||
double rudderDeg = 0.0;
|
||||
|
||||
/// Speed over ground (knots).
|
||||
double sogKn = 6.2;
|
||||
|
||||
/// Course over ground (degrees, true).
|
||||
double cogDeg = 127.0;
|
||||
|
||||
/// Rate of turn (degrees per minute). Positive = turning starboard.
|
||||
double rotDpm = 0.0;
|
||||
|
||||
/// Water depth below keel (metres).
|
||||
double depthM = 42.5;
|
||||
double rudderDeg = 0.0;
|
||||
double sogKn = 6.2;
|
||||
double cogDeg = 127.0;
|
||||
double rotDpm = 0.0;
|
||||
double depthM = 42.5;
|
||||
|
||||
// ── Autopilot state ──────────────────────────────────────────────────────────
|
||||
/// Current autopilot mode as reported by the concentrador $PARP,STATUS.
|
||||
AutopilotMode mode = AutopilotMode.standby;
|
||||
|
||||
/// True when the Modbus RTU link to the concentrador is active.
|
||||
/// Sprint 4: always false (demo mode label shown in UI).
|
||||
/// True when the USB serial link to the concentrador is active.
|
||||
bool isConnected = false;
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────────
|
||||
// ── Serial service ───────────────────────────────────────────────────────────
|
||||
ConcentradorService? _service;
|
||||
|
||||
// ── Demo simulation ──────────────────────────────────────────────────────────
|
||||
Timer? _demoTimer;
|
||||
final _rng = math.Random();
|
||||
|
||||
@@ -62,55 +50,92 @@ class AutopilotState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo simulation (Sprint 4 only)
|
||||
// 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<void> 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<void> 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 = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
_tick();
|
||||
});
|
||||
_demoTimer?.cancel();
|
||||
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
switch (mode) {
|
||||
case AutopilotMode.standby:
|
||||
// Vessel drifts slightly — small random walk on heading and rudder.
|
||||
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;
|
||||
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:
|
||||
// Simulated P-controller: autopilot drives rudder toward setpoint.
|
||||
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;
|
||||
|
||||
case AutopilotMode.trackKeep:
|
||||
// Sprint 6: identical to headingHold for demo purposes.
|
||||
final error = _angleDiff(setpointDeg, headingDeg);
|
||||
rudderDeg =
|
||||
(error * 1.2).clamp(-35.0, 35.0);
|
||||
headingDeg = (headingDeg + error * 0.025) % 360;
|
||||
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;
|
||||
}
|
||||
|
||||
// COG tracks heading with a small lag.
|
||||
cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3)
|
||||
% 360;
|
||||
cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3) % 360;
|
||||
if (cogDeg < 0) cogDeg += 360;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Signed difference in [−180, +180]: target − current.
|
||||
double _angleDiff(double target, double current) {
|
||||
double d = (target - current) % 360;
|
||||
if (d > 180) d -= 360;
|
||||
@@ -119,32 +144,39 @@ class AutopilotState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands — called by UI; Sprint 7 will also send Modbus frames here.
|
||||
// Commands — work in both demo and serial modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Engage heading hold on the current heading.
|
||||
void engage() {
|
||||
setpointDeg = headingDeg;
|
||||
mode = AutopilotMode.headingHold;
|
||||
mode = AutopilotMode.headingHold;
|
||||
_service?.sendEngage(headingDeg);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Return to STANDBY (manual helm).
|
||||
void disengage() {
|
||||
mode = AutopilotMode.standby;
|
||||
_service?.sendDisengage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Adjust the heading setpoint while in heading-hold mode.
|
||||
/// No-op in STANDBY or TRACK.
|
||||
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();
|
||||
}
|
||||
|
||||
/// Handle ModeSelector taps.
|
||||
void selectMode(AutopilotMode newMode) {
|
||||
switch (newMode) {
|
||||
case AutopilotMode.standby:
|
||||
@@ -152,16 +184,22 @@ class AutopilotState extends ChangeNotifier {
|
||||
case AutopilotMode.headingHold:
|
||||
engage();
|
||||
case AutopilotMode.trackKeep:
|
||||
// Sprint 6: engage track-keep mode (requires GPS cross-track error).
|
||||
mode = AutopilotMode.trackKeep;
|
||||
mode = AutopilotMode.trackKeep;
|
||||
setpointDeg = headingDeg;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port discovery (delegate to service layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static List<String> availablePorts() => ConcentradorService.availablePorts();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_demoTimer?.cancel();
|
||||
_service?.disconnect();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user