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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user