Files
alro65 abe9b764c7 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
2026-05-24 01:28:04 -04:00

145 lines
4.8 KiB
Dart

// =============================================================================
// 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,
};
}