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:
2026-05-24 01:28:04 -04:00
parent c946d2df6d
commit abe9b764c7
7 changed files with 886 additions and 81 deletions
+107 -69
View File
@@ -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, 0359.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();
}
}