diff --git a/display/lib/data/autopilot_state.dart b/display/lib/data/autopilot_state.dart index 7ac1b7b..f74ccf6 100644 --- a/display/lib/data/autopilot_state.dart +++ b/display/lib/data/autopilot_state.dart @@ -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()`. -/// -/// ## 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 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 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 availablePorts() => ConcentradorService.availablePorts(); + @override void dispose() { _demoTimer?.cancel(); + _service?.disconnect(); super.dispose(); } } diff --git a/display/lib/main.dart b/display/lib/main.dart index 0cdf2af..62cb01c 100644 --- a/display/lib/main.dart +++ b/display/lib/main.dart @@ -1,25 +1,48 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'data/autopilot_state.dart'; import 'theme/theme_provider.dart'; import 'screens/cockpit/cockpit_screen.dart'; import 'screens/settings/appearance_settings.dart'; +import 'screens/settings/port_settings_screen.dart'; + +// SharedPreferences keys — must match port_settings_screen.dart +const _kRxKey = 'port.rx'; +const _kTxKey = 'port.tx'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Load persisted theme before first frame. final themeProvider = await AutopilotThemeProvider.load(); + + // Create state object early so we can attempt auto-connect. + final autopilotState = AutopilotState(); + + // Attempt to reconnect to the last-used COM ports silently. + // If the ports are not available (hardware unplugged, different PC, etc.) + // the exception is swallowed and the UI stays in demo mode. + try { + final prefs = await SharedPreferences.getInstance(); + final rxPort = prefs.getString(_kRxKey); + final txPort = prefs.getString(_kTxKey); + if (rxPort != null && txPort != null) { + await autopilotState.connectToSerial(rxPort: rxPort, txPort: txPort); + } + } catch (_) { + // Hardware not available — stay in demo mode. + } + runApp( MultiProvider( providers: [ ChangeNotifierProvider.value( value: themeProvider, ), - // AutopilotState drives the cockpit UI. - // Sprint 4: internal demo timer (vessel simulation). - // Sprint 7: replace with AutopilotStateModbus or wire Modbus RTU here. - ChangeNotifierProvider( - create: (_) => AutopilotState(), + ChangeNotifierProvider.value( + value: autopilotState, ), ], child: const ArAutopilotApp(), @@ -38,9 +61,9 @@ class ArAutopilotApp extends StatelessWidget { theme: ThemeData(useMaterial3: true), initialRoute: CockpitScreen.routeName, routes: { - CockpitScreen.routeName: (_) => const CockpitScreen(), - AppearanceSettingsScreen.routeName: (_) => - const AppearanceSettingsScreen(), + CockpitScreen.routeName: (_) => const CockpitScreen(), + AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(), + PortSettingsScreen.routeName: (_) => const PortSettingsScreen(), }, ); } diff --git a/display/lib/screens/cockpit/cockpit_screen.dart b/display/lib/screens/cockpit/cockpit_screen.dart index 989ffef..72db742 100644 --- a/display/lib/screens/cockpit/cockpit_screen.dart +++ b/display/lib/screens/cockpit/cockpit_screen.dart @@ -31,6 +31,7 @@ import '../../widgets/themed/mode_selector.dart'; import '../../widgets/themed/rudder_indicator.dart'; import '../../widgets/themed/status_chip.dart'; import '../settings/appearance_settings.dart'; +import '../settings/port_settings_screen.dart'; class CockpitScreen extends StatelessWidget { const CockpitScreen({super.key}); @@ -138,14 +139,47 @@ class _TopBar extends StatelessWidget { status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn, ), const SizedBox(width: 16), - GestureDetector( - onTap: () => Navigator.pushNamed( - context, AppearanceSettingsScreen.routeName), - child: Icon( + PopupMenuButton( + icon: Icon( Icons.settings_outlined, color: theme.textMuted, size: 22, ), + color: theme.backgroundMid, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: theme.panelBorder), + ), + onSelected: (route) => Navigator.pushNamed(context, route), + itemBuilder: (_) => [ + PopupMenuItem( + value: PortSettingsScreen.routeName, + child: Row( + children: [ + Icon(Icons.usb, color: theme.accentMid, size: 18), + const SizedBox(width: 10), + Text( + 'Puertos COM', + style: TextStyle(color: theme.textMain, fontSize: 13), + ), + ], + ), + ), + PopupMenuItem( + value: AppearanceSettingsScreen.routeName, + child: Row( + children: [ + Icon(Icons.palette_outlined, + color: theme.accentMid, size: 18), + const SizedBox(width: 10), + Text( + 'Apariencia', + style: TextStyle(color: theme.textMain, fontSize: 13), + ), + ], + ), + ), + ], ), ], ), diff --git a/display/lib/screens/settings/port_settings_screen.dart b/display/lib/screens/settings/port_settings_screen.dart new file mode 100644 index 0000000..c97f123 --- /dev/null +++ b/display/lib/screens/settings/port_settings_screen.dart @@ -0,0 +1,367 @@ +// ============================================================================= +// screens/settings/port_settings_screen.dart — COM port configuration +// ============================================================================= +// +// Lets the operator choose which COM port is the concentrador RX-OUT +// (the one the display reads from) and which is the TX-IN +// (the one the display writes commands to). +// +// Ports are persisted in SharedPreferences and auto-applied at startup. +// +// Layout: +// • Two dropdowns — RX port, TX port — populated from available COM ports +// • [Conectar] button — tries to open both ports; shows error if it fails +// • [Desconectar] button — drops to demo mode +// • Status row — green ● CONECTADO / grey ● DEMO +// ============================================================================= + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../data/autopilot_state.dart'; +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; + +class PortSettingsScreen extends StatefulWidget { + const PortSettingsScreen({super.key}); + + static const String routeName = '/settings/ports'; + + @override + State createState() => _PortSettingsScreenState(); +} + +class _PortSettingsScreenState extends State { + static const _kRxKey = 'port.rx'; + static const _kTxKey = 'port.tx'; + + List _ports = []; + String? _rxPort; + String? _txPort; + bool _connecting = false; + String? _errorMsg; + + @override + void initState() { + super.initState(); + _loadPorts(); + } + + Future _loadPorts() async { + final available = AutopilotState.availablePorts(); + final prefs = await SharedPreferences.getInstance(); + setState(() { + _ports = available; + _rxPort = prefs.getString(_kRxKey); + _txPort = prefs.getString(_kTxKey); + // If saved port no longer exists, clear it. + if (_rxPort != null && !_ports.contains(_rxPort)) _rxPort = null; + if (_txPort != null && !_ports.contains(_txPort)) _txPort = null; + }); + } + + Future _connect() async { + if (_rxPort == null || _txPort == null) return; + setState(() { + _connecting = true; + _errorMsg = null; + }); + + try { + final ap = context.read(); + await ap.connectToSerial(rxPort: _rxPort!, txPort: _txPort!); + + // Persist the working configuration + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kRxKey, _rxPort!); + await prefs.setString(_kTxKey, _txPort!); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Conectado — RX: $_rxPort TX: $_txPort'), + backgroundColor: Colors.green.shade700, + duration: const Duration(seconds: 3), + ), + ); + Navigator.pop(context); + } + } catch (e) { + setState(() => _errorMsg = 'Error al abrir puertos: $e'); + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + Future _disconnect() async { + final ap = context.read(); + await ap.disconnectSerial(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Desconectado — modo demo activo'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + final ap = context.watch(); + + return Scaffold( + backgroundColor: theme.background, + appBar: AppBar( + backgroundColor: theme.backgroundMid, + foregroundColor: theme.textMain, + elevation: 0, + title: Text( + 'Conexión al Concentrador', + style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600), + ), + ), + body: AnimatedContainer( + duration: const Duration(milliseconds: 400), + decoration: theme.backgroundDecoration, + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + // ── Status ──────────────────────────────────────────────────────── + _StatusRow(theme: theme, connected: ap.isConnected), + const SizedBox(height: 24), + + // ── Port selection ──────────────────────────────────────────────── + _SectionLabel(label: 'Puerto RX (leer datos del concentrador)', theme: theme), + const SizedBox(height: 8), + _PortDropdown( + theme: theme, + value: _rxPort, + ports: _ports, + onChanged: (v) => setState(() => _rxPort = v), + ), + const SizedBox(height: 16), + + _SectionLabel(label: 'Puerto TX (enviar comandos al concentrador)', theme: theme), + const SizedBox(height: 8), + _PortDropdown( + theme: theme, + value: _txPort, + ports: _ports, + onChanged: (v) => setState(() => _txPort = v), + ), + const SizedBox(height: 8), + + // Note + Text( + 'Conectar el cable USB-OUT del concentrador al puerto RX,\n' + 'y el USB-IN al puerto TX. Ambos a 115 200 baud, 8N1.', + style: TextStyle(color: theme.textMuted, fontSize: 11), + ), + const SizedBox(height: 24), + + // ── Error message ───────────────────────────────────────────────── + if (_errorMsg != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.disengageBackground.colors.first.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.disengageBorder.withValues(alpha: 0.4)), + ), + child: Text( + _errorMsg!, + style: TextStyle(color: theme.disengageText, fontSize: 12), + ), + ), + const SizedBox(height: 16), + ], + + // ── Buttons ─────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: _ActionButton( + theme: theme, + label: _connecting ? 'Conectando…' : 'Conectar', + enabled: !_connecting && _rxPort != null && _txPort != null, + primary: true, + onPressed: _connect, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ActionButton( + theme: theme, + label: 'Desconectar', + enabled: ap.isConnected && !_connecting, + primary: false, + onPressed: _disconnect, + ), + ), + ], + ), + const SizedBox(height: 16), + + // ── Refresh ports ───────────────────────────────────────────────── + TextButton( + onPressed: _loadPorts, + child: Text( + 'Actualizar lista de puertos', + style: TextStyle(color: theme.textMuted, fontSize: 12), + ), + ), + ], + ), + ), + ); + } +} + +// ── Private widgets ─────────────────────────────────────────────────────────── + +class _StatusRow extends StatelessWidget { + const _StatusRow({required this.theme, required this.connected}); + final AutopilotTheme theme; + final bool connected; + + @override + Widget build(BuildContext context) { + final color = connected ? theme.okColor : theme.textMuted; + final label = connected ? 'CONECTADO AL CONCENTRADOR' : 'MODO DEMO (sin conexión)'; + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: connected + ? [BoxShadow(color: color.withValues(alpha: 0.5), blurRadius: 8)] + : null, + ), + ), + const SizedBox(width: 10), + Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label, required this.theme}); + final String label; + final AutopilotTheme theme; + + @override + Widget build(BuildContext context) => Text( + label.toUpperCase(), + style: TextStyle( + color: theme.textMuted, + fontSize: 10, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ); +} + +class _PortDropdown extends StatelessWidget { + const _PortDropdown({ + required this.theme, + required this.value, + required this.ports, + required this.onChanged, + }); + final AutopilotTheme theme; + final String? value; + final List ports; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + gradient: theme.panelBackground, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.panelBorder), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: ports.contains(value) ? value : null, + hint: Text( + ports.isEmpty ? 'Sin puertos disponibles' : 'Seleccionar puerto…', + style: TextStyle(color: theme.textMuted, fontSize: 13), + ), + dropdownColor: theme.backgroundMid, + style: TextStyle(color: theme.textMain, fontSize: 13), + icon: Icon(Icons.expand_more, color: theme.textMuted), + isExpanded: true, + items: ports + .map((p) => DropdownMenuItem(value: p, child: Text(p))) + .toList(), + onChanged: onChanged, + ), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.theme, + required this.label, + required this.enabled, + required this.primary, + required this.onPressed, + }); + final AutopilotTheme theme; + final String label; + final bool enabled; + final bool primary; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final color = primary ? theme.okColor : theme.accentMid; + return GestureDetector( + onTap: enabled ? onPressed : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + color: enabled + ? color.withValues(alpha: 0.15) + : theme.backgroundDeep, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: enabled ? color : theme.panelBorder, + width: 1.5, + ), + ), + child: Text( + label, + style: TextStyle( + color: enabled ? color : theme.textDisabled, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + ), + ); + } +} diff --git a/display/lib/services/concentrador_service.dart b/display/lib/services/concentrador_service.dart new file mode 100644 index 0000000..47ee7be --- /dev/null +++ b/display/lib/services/concentrador_service.dart @@ -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? _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 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 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 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(); + } +} diff --git a/display/lib/services/parp_codec.dart b/display/lib/services/parp_codec.dart new file mode 100644 index 0000000..985787f --- /dev/null +++ b/display/lib/services/parp_codec.dart @@ -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,,,,,*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,,,*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, + }; +} diff --git a/display/pubspec.yaml b/display/pubspec.yaml index b5bfed5..dc79a1e 100644 --- a/display/pubspec.yaml +++ b/display/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: provider: ^6.1.2 # Theme persistence — stores selected theme id locally on the display device shared_preferences: ^2.3.2 + # USB serial communication with AR-Concentrador (CH340N virtual COM ports) + flutter_libserialport: ^0.2.1 dev_dependencies: flutter_test: