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
@@ -0,0 +1,197 @@
// =============================================================================
// services/concentrador_service.dart — USB serial link to AR-Concentrador
// =============================================================================
//
// The AR-Concentrador exposes two separate CH340N virtual COM ports:
// rxPort — USB-OUT: concentrador broadcasts $PARP,STATUS + NMEA at 2 Hz
// txPort — USB-IN : display sends $PARP commands to the concentrador
//
// Both ports run at 115 200 baud, 8N1, no flow control.
//
// Usage:
// final svc = ConcentradorService(rxPort: 'COM3', txPort: 'COM4', stationId: 2);
// svc.onStatus = (status) { ... };
// svc.onConnectionChanged = (connected) { ... };
// await svc.connect();
// svc.sendEngage(headingDeg: 125.0);
// svc.disconnect();
// =============================================================================
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_libserialport/flutter_libserialport.dart';
import 'parp_codec.dart';
/// Callback type for status updates from the concentrador.
typedef StatusCallback = void Function(ParpStatus status);
/// Callback type for connection state changes.
typedef ConnectionCallback = void Function(bool connected);
class ConcentradorService {
ConcentradorService({
required this.rxPort,
required this.txPort,
this.stationId = 2,
this.baudRate = 115200,
});
final String rxPort;
final String txPort;
final int stationId;
final int baudRate;
/// Called whenever a valid $PARP,STATUS sentence is received.
StatusCallback? onStatus;
/// Called when the connection state changes.
ConnectionCallback? onConnectionChanged;
SerialPort? _rx;
SerialPort? _tx;
SerialPortReader? _reader;
StreamSubscription<Uint8List>? _rxSub;
bool _connected = false;
bool get isConnected => _connected;
// Accumulation buffer for partial sentences
final StringBuffer _buf = StringBuffer();
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
/// Open both serial ports and start listening for STATUS sentences.
///
/// Throws [SerialPortError] if either port cannot be opened.
Future<void> connect() async {
await disconnect(); // clean slate
_rx = SerialPort(rxPort);
_tx = SerialPort(txPort);
_configPort(_rx!);
_configPort(_tx!);
if (!_rx!.openRead()) {
throw SerialPortError('Cannot open RX port $rxPort');
}
if (!_tx!.openWrite()) {
throw SerialPortError('Cannot open TX port $txPort');
}
_reader = SerialPortReader(_rx!);
_rxSub = _reader!.stream.listen(
_onData,
onError: (_) => _handleDisconnect(),
onDone: _handleDisconnect,
);
_connected = true;
onConnectionChanged?.call(true);
}
/// Close both ports gracefully.
Future<void> disconnect() async {
_rxSub?.cancel();
_rxSub = null;
_reader = null;
_rx?.close();
_tx?.close();
_rx = null;
_tx = null;
if (_connected) {
_connected = false;
onConnectionChanged?.call(false);
}
}
// ---------------------------------------------------------------------------
// Command senders
// ---------------------------------------------------------------------------
void sendEngage(double headingDeg) =>
_send(ParpCodec.engage(stationId, headingDeg));
void sendDisengage() =>
_send(ParpCodec.disengage(stationId));
void sendSetHeading(double headingDeg) =>
_send(ParpCodec.setHeading(stationId, headingDeg));
void sendPortOne(double setpointDeg) =>
_send(ParpCodec.portOne(stationId, setpointDeg));
void sendStbdOne(double setpointDeg) =>
_send(ParpCodec.stbdOne(stationId, setpointDeg));
void sendPortTen(double setpointDeg) =>
_send(ParpCodec.portTen(stationId, setpointDeg));
void sendStbdTen(double setpointDeg) =>
_send(ParpCodec.stbdTen(stationId, setpointDeg));
void sendReqCmd() =>
_send(ParpCodec.reqCmd(stationId));
void sendRelCmd() =>
_send(ParpCodec.relCmd(stationId));
// ---------------------------------------------------------------------------
// Port listing (for settings screen)
// ---------------------------------------------------------------------------
/// All serial ports currently visible to the OS.
static List<String> availablePorts() => SerialPort.availablePorts;
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
void _configPort(SerialPort port) {
final cfg = SerialPortConfig()
..baudRate = baudRate
..bits = 8
..stopBits = 1
..parity = SerialPortParity.none
..setFlowControl(SerialPortFlowControl.none);
port.config = cfg;
}
void _onData(Uint8List data) {
_buf.write(String.fromCharCodes(data));
final raw = _buf.toString();
final lines = raw.split('\n');
// Keep the last (possibly incomplete) chunk in the buffer.
_buf.clear();
_buf.write(lines.last);
for (int i = 0; i < lines.length - 1; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final status = ParpCodec.parseStatus(line);
if (status != null) {
onStatus?.call(status);
}
}
}
void _send(String sentence) {
if (_tx == null || !_connected) return;
try {
_tx!.write(Uint8List.fromList(sentence.codeUnits));
} catch (_) {
_handleDisconnect();
}
}
void _handleDisconnect() {
disconnect();
}
}
+144
View File
@@ -0,0 +1,144 @@
// =============================================================================
// services/parp_codec.dart — $PARP sentence parser and builder
// =============================================================================
//
// Protocol reference: docs/concentrador_protocol.md
//
// Inbound ($PARP,STATUS — broadcast by concentrador at 2 Hz):
// $PARP,STATUS,<mode>,<setpoint>,<heading>,<rudder>,<commander>*XX\r\n
// Example: $PARP,STATUS,HEADING_HOLD,125.0,125.3,2.5,01*3A
//
// Outbound ($PARP commands — sent by display to concentrador):
// $PARP,<CMD>,<value>,<station_id>*XX\r\n
// Example: $PARP,ENGAGE,0.0,02*XX
// =============================================================================
import '../widgets/themed/mode_selector.dart';
/// Parsed content of a $PARP,STATUS sentence.
class ParpStatus {
const ParpStatus({
required this.mode,
required this.setpointDeg,
required this.headingDeg,
required this.rudderDeg,
required this.commander,
});
final AutopilotMode mode;
final double setpointDeg;
final double headingDeg;
final double rudderDeg;
final int commander; // station ID of the current commander (0 = none)
}
/// Stateless NMEA $PARP sentence codec.
abstract final class ParpCodec {
// ---------------------------------------------------------------------------
// Inbound parser
// ---------------------------------------------------------------------------
/// Parse a complete NMEA sentence string (with or without leading $).
///
/// Returns [ParpStatus] if the sentence is a valid $PARP,STATUS with correct
/// checksum. Returns null for any other sentence type or if CRC fails.
static ParpStatus? parseStatus(String sentence) {
// Strip whitespace / CRLF
final s = sentence.trim();
// Locate checksum delimiter
final starIdx = s.lastIndexOf('*');
if (starIdx < 0) return null;
final body = s.startsWith('\$') ? s.substring(1, starIdx) : s.substring(0, starIdx);
final crcHex = s.substring(starIdx + 1);
// Verify checksum
if (!_crcOk(body, crcHex)) return null;
// Tokenise
final parts = body.split(',');
if (parts.length < 7) return null;
if (parts[0] != 'PARP' || parts[1] != 'STATUS') return null;
final mode = _parseMode(parts[2]);
final setpoint = double.tryParse(parts[3]);
final heading = double.tryParse(parts[4]);
final rudder = double.tryParse(parts[5]);
final commander = int.tryParse(parts[6]);
if (setpoint == null || heading == null || rudder == null || commander == null) {
return null;
}
return ParpStatus(
mode: mode,
setpointDeg: setpoint,
headingDeg: heading,
rudderDeg: rudder,
commander: commander,
);
}
// ---------------------------------------------------------------------------
// Outbound builders
// ---------------------------------------------------------------------------
static String engage(int stationId, double currentHeadingDeg) =>
_build('ENGAGE', currentHeadingDeg, stationId);
static String disengage(int stationId) =>
_build('DISENGAGE', 0.0, stationId);
static String setHeading(int stationId, double headingDeg) =>
_build('SETHEADING', headingDeg, stationId);
static String portOne(int stationId, double currentSetpoint) =>
_build('PORTONE', currentSetpoint, stationId);
static String stbdOne(int stationId, double currentSetpoint) =>
_build('STBDONE', currentSetpoint, stationId);
static String portTen(int stationId, double currentSetpoint) =>
_build('PORTTEN', currentSetpoint, stationId);
static String stbdTen(int stationId, double currentSetpoint) =>
_build('STBDTEN', currentSetpoint, stationId);
static String reqCmd(int stationId) =>
_build('REQCMD', 0.0, stationId);
static String relCmd(int stationId) =>
_build('RELCMD', 0.0, stationId);
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
static String _build(String cmd, double value, int stationId) {
final body = 'PARP,$cmd,${value.toStringAsFixed(1)},'
'${stationId.toString().padLeft(2, '0')}';
final crc = _computeCrc(body);
return '\$$body*${crc.toRadixString(16).toUpperCase().padLeft(2, '0')}\r\n';
}
static int _computeCrc(String body) {
int crc = 0;
for (final ch in body.codeUnits) {
crc ^= ch;
}
return crc;
}
static bool _crcOk(String body, String crcHex) {
final expected = _computeCrc(body);
final received = int.tryParse(crcHex, radix: 16);
return received != null && received == expected;
}
static AutopilotMode _parseMode(String raw) => switch (raw) {
'HEADING_HOLD' => AutopilotMode.headingHold,
'TRACK' => AutopilotMode.trackKeep,
_ => AutopilotMode.standby,
};
}