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:
@@ -2,9 +2,15 @@
|
|||||||
// data/autopilot_state.dart — Live autopilot data model
|
// data/autopilot_state.dart — Live autopilot data model
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
//
|
//
|
||||||
// Sprint 4: simulated demo data (vessel drifting / heading-hold correction).
|
// Dual-mode: demo simulation (Sprint 4) or live USB serial (Sprint 7).
|
||||||
// Sprint 7: internals replaced by Modbus RTU over USB serial.
|
//
|
||||||
// The public API (fields + methods) must not change between sprints.
|
// 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';
|
import 'dart:async';
|
||||||
@@ -12,48 +18,30 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../services/concentrador_service.dart';
|
||||||
|
import '../services/parp_codec.dart';
|
||||||
import '../widgets/themed/mode_selector.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 {
|
class AutopilotState extends ChangeNotifier {
|
||||||
// ── Navigation data ──────────────────────────────────────────────────────────
|
// ── Navigation data ──────────────────────────────────────────────────────────
|
||||||
/// Current vessel heading from NMEA 2000 backbone (degrees, magnetic, 0–359.9).
|
double headingDeg = 125.0;
|
||||||
double headingDeg = 125.0;
|
|
||||||
|
|
||||||
/// Operator-commanded heading setpoint sent to autopilot (degrees, magnetic).
|
|
||||||
double setpointDeg = 125.0;
|
double setpointDeg = 125.0;
|
||||||
|
double rudderDeg = 0.0;
|
||||||
/// Rudder angle in degrees. Negative = port, positive = starboard.
|
double sogKn = 6.2;
|
||||||
double rudderDeg = 0.0;
|
double cogDeg = 127.0;
|
||||||
|
double rotDpm = 0.0;
|
||||||
/// Speed over ground (knots).
|
double depthM = 42.5;
|
||||||
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;
|
|
||||||
|
|
||||||
// ── Autopilot state ──────────────────────────────────────────────────────────
|
// ── Autopilot state ──────────────────────────────────────────────────────────
|
||||||
/// Current autopilot mode as reported by the concentrador $PARP,STATUS.
|
|
||||||
AutopilotMode mode = AutopilotMode.standby;
|
AutopilotMode mode = AutopilotMode.standby;
|
||||||
|
|
||||||
/// True when the Modbus RTU link to the concentrador is active.
|
/// True when the USB serial link to the concentrador is active.
|
||||||
/// Sprint 4: always false (demo mode label shown in UI).
|
|
||||||
bool isConnected = false;
|
bool isConnected = false;
|
||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────────────
|
// ── Serial service ───────────────────────────────────────────────────────────
|
||||||
|
ConcentradorService? _service;
|
||||||
|
|
||||||
|
// ── Demo simulation ──────────────────────────────────────────────────────────
|
||||||
Timer? _demoTimer;
|
Timer? _demoTimer;
|
||||||
final _rng = math.Random();
|
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() {
|
void _startDemo() {
|
||||||
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
_demoTimer?.cancel();
|
||||||
_tick();
|
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tick() {
|
void _tick() {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case AutopilotMode.standby:
|
case AutopilotMode.standby:
|
||||||
// Vessel drifts slightly — small random walk on heading and rudder.
|
|
||||||
headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360;
|
headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360;
|
||||||
if (headingDeg < 0) headingDeg += 360;
|
if (headingDeg < 0) headingDeg += 360;
|
||||||
rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8)
|
rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8).clamp(-5.0, 5.0);
|
||||||
.clamp(-5.0, 5.0);
|
rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2;
|
||||||
rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2;
|
|
||||||
|
|
||||||
case AutopilotMode.headingHold:
|
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:
|
case AutopilotMode.trackKeep:
|
||||||
// Sprint 6: identical to headingHold for demo purposes.
|
|
||||||
final error = _angleDiff(setpointDeg, headingDeg);
|
final error = _angleDiff(setpointDeg, headingDeg);
|
||||||
rudderDeg =
|
rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5).clamp(-35.0, 35.0);
|
||||||
(error * 1.2).clamp(-35.0, 35.0);
|
headingDeg = (headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08) % 360;
|
||||||
headingDeg = (headingDeg + error * 0.025) % 360;
|
|
||||||
if (headingDeg < 0) headingDeg += 360;
|
if (headingDeg < 0) headingDeg += 360;
|
||||||
rotDpm = error * 0.4;
|
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;
|
if (cogDeg < 0) cogDeg += 360;
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signed difference in [−180, +180]: target − current.
|
|
||||||
double _angleDiff(double target, double current) {
|
double _angleDiff(double target, double current) {
|
||||||
double d = (target - current) % 360;
|
double d = (target - current) % 360;
|
||||||
if (d > 180) d -= 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() {
|
void engage() {
|
||||||
setpointDeg = headingDeg;
|
setpointDeg = headingDeg;
|
||||||
mode = AutopilotMode.headingHold;
|
mode = AutopilotMode.headingHold;
|
||||||
|
_service?.sendEngage(headingDeg);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return to STANDBY (manual helm).
|
|
||||||
void disengage() {
|
void disengage() {
|
||||||
mode = AutopilotMode.standby;
|
mode = AutopilotMode.standby;
|
||||||
|
_service?.sendDisengage();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adjust the heading setpoint while in heading-hold mode.
|
|
||||||
/// No-op in STANDBY or TRACK.
|
|
||||||
void adjustSetpoint(double deltaDeg) {
|
void adjustSetpoint(double deltaDeg) {
|
||||||
if (mode != AutopilotMode.headingHold) return;
|
if (mode != AutopilotMode.headingHold) return;
|
||||||
setpointDeg = (setpointDeg + deltaDeg) % 360;
|
setpointDeg = (setpointDeg + deltaDeg) % 360;
|
||||||
if (setpointDeg < 0) setpointDeg += 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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle ModeSelector taps.
|
|
||||||
void selectMode(AutopilotMode newMode) {
|
void selectMode(AutopilotMode newMode) {
|
||||||
switch (newMode) {
|
switch (newMode) {
|
||||||
case AutopilotMode.standby:
|
case AutopilotMode.standby:
|
||||||
@@ -152,16 +184,22 @@ class AutopilotState extends ChangeNotifier {
|
|||||||
case AutopilotMode.headingHold:
|
case AutopilotMode.headingHold:
|
||||||
engage();
|
engage();
|
||||||
case AutopilotMode.trackKeep:
|
case AutopilotMode.trackKeep:
|
||||||
// Sprint 6: engage track-keep mode (requires GPS cross-track error).
|
mode = AutopilotMode.trackKeep;
|
||||||
mode = AutopilotMode.trackKeep;
|
|
||||||
setpointDeg = headingDeg;
|
setpointDeg = headingDeg;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Port discovery (delegate to service layer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static List<String> availablePorts() => ConcentradorService.availablePorts();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_demoTimer?.cancel();
|
_demoTimer?.cancel();
|
||||||
|
_service?.disconnect();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-8
@@ -1,25 +1,48 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'data/autopilot_state.dart';
|
import 'data/autopilot_state.dart';
|
||||||
import 'theme/theme_provider.dart';
|
import 'theme/theme_provider.dart';
|
||||||
import 'screens/cockpit/cockpit_screen.dart';
|
import 'screens/cockpit/cockpit_screen.dart';
|
||||||
import 'screens/settings/appearance_settings.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<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Load persisted theme before first frame.
|
||||||
final themeProvider = await AutopilotThemeProvider.load();
|
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(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider<AutopilotThemeProvider>.value(
|
ChangeNotifierProvider<AutopilotThemeProvider>.value(
|
||||||
value: themeProvider,
|
value: themeProvider,
|
||||||
),
|
),
|
||||||
// AutopilotState drives the cockpit UI.
|
ChangeNotifierProvider<AutopilotState>.value(
|
||||||
// Sprint 4: internal demo timer (vessel simulation).
|
value: autopilotState,
|
||||||
// Sprint 7: replace with AutopilotStateModbus or wire Modbus RTU here.
|
|
||||||
ChangeNotifierProvider<AutopilotState>(
|
|
||||||
create: (_) => AutopilotState(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const ArAutopilotApp(),
|
child: const ArAutopilotApp(),
|
||||||
@@ -38,9 +61,9 @@ class ArAutopilotApp extends StatelessWidget {
|
|||||||
theme: ThemeData(useMaterial3: true),
|
theme: ThemeData(useMaterial3: true),
|
||||||
initialRoute: CockpitScreen.routeName,
|
initialRoute: CockpitScreen.routeName,
|
||||||
routes: {
|
routes: {
|
||||||
CockpitScreen.routeName: (_) => const CockpitScreen(),
|
CockpitScreen.routeName: (_) => const CockpitScreen(),
|
||||||
AppearanceSettingsScreen.routeName: (_) =>
|
AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(),
|
||||||
const AppearanceSettingsScreen(),
|
PortSettingsScreen.routeName: (_) => const PortSettingsScreen(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import '../../widgets/themed/mode_selector.dart';
|
|||||||
import '../../widgets/themed/rudder_indicator.dart';
|
import '../../widgets/themed/rudder_indicator.dart';
|
||||||
import '../../widgets/themed/status_chip.dart';
|
import '../../widgets/themed/status_chip.dart';
|
||||||
import '../settings/appearance_settings.dart';
|
import '../settings/appearance_settings.dart';
|
||||||
|
import '../settings/port_settings_screen.dart';
|
||||||
|
|
||||||
class CockpitScreen extends StatelessWidget {
|
class CockpitScreen extends StatelessWidget {
|
||||||
const CockpitScreen({super.key});
|
const CockpitScreen({super.key});
|
||||||
@@ -138,14 +139,47 @@ class _TopBar extends StatelessWidget {
|
|||||||
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
|
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
GestureDetector(
|
PopupMenuButton<String>(
|
||||||
onTap: () => Navigator.pushNamed(
|
icon: Icon(
|
||||||
context, AppearanceSettingsScreen.routeName),
|
|
||||||
child: Icon(
|
|
||||||
Icons.settings_outlined,
|
Icons.settings_outlined,
|
||||||
color: theme.textMuted,
|
color: theme.textMuted,
|
||||||
size: 22,
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<PortSettingsScreen> createState() => _PortSettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PortSettingsScreenState extends State<PortSettingsScreen> {
|
||||||
|
static const _kRxKey = 'port.rx';
|
||||||
|
static const _kTxKey = 'port.tx';
|
||||||
|
|
||||||
|
List<String> _ports = [];
|
||||||
|
String? _rxPort;
|
||||||
|
String? _txPort;
|
||||||
|
bool _connecting = false;
|
||||||
|
String? _errorMsg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPorts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _connect() async {
|
||||||
|
if (_rxPort == null || _txPort == null) return;
|
||||||
|
setState(() {
|
||||||
|
_connecting = true;
|
||||||
|
_errorMsg = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ap = context.read<AutopilotState>();
|
||||||
|
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<void> _disconnect() async {
|
||||||
|
final ap = context.read<AutopilotState>();
|
||||||
|
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<AutopilotThemeProvider>().current;
|
||||||
|
final ap = context.watch<AutopilotState>();
|
||||||
|
|
||||||
|
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<String> ports;
|
||||||
|
final ValueChanged<String?> 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<String>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ dependencies:
|
|||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
# Theme persistence — stores selected theme id locally on the display device
|
# Theme persistence — stores selected theme id locally on the display device
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
|
# USB serial communication with AR-Concentrador (CH340N virtual COM ports)
|
||||||
|
flutter_libserialport: ^0.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user