// lib/services/ntrip_service.dart import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; class NtripException implements Exception { final String message; NtripException(this.message); @override String toString() => message; } /// NTRIP kapcsolatot kezelő singleton service. /// /// Felelőssége: /// - Socket kapcsolat az NTRIP casterhez /// - RTCM adatok fogadása és továbbítása a GNSS vevőnek /// - GGA mondatok küldése a casternek (5 másodpercenként) /// - Beállítások tárolása SharedPreferences-ben class NtripService extends GetxService { static NtripService get to => Get.find(); // ── Reaktív állapot ─────────────────────────────────────────────── final isConnected = false.obs; final receivedBytes = 0.obs; final packetCount = 0.obs; final ggaSentCount = 0.obs; final ggaLastSentTime = ''.obs; final lastError = ''.obs; final lastRtcmAt = Rxn(); final connectedSince = Rxn(); // ── Beállítások ─────────────────────────────────────────────────── final host = ''.obs; // gnssnet.hu IP final port = 2101.obs; final mountpoint = ''.obs; final username = ''.obs; final password = ''.obs; final autoConnect = false.obs; bool get hasCompleteSettings => host.value.trim().isNotEmpty && mountpoint.value.trim().isNotEmpty; // // ── UI controllerek (beállítás dialóghoz) ──────────────────────── // final hostController = TextEditingController(); // final portController = TextEditingController(); // final mountpointController = TextEditingController(); // final usernameController = TextEditingController(); // final passwordController = TextEditingController(); // ── Belső állapot ──────────────────────────────────────────────── static const _secure = FlutterSecureStorage(); Socket? _socket; StreamSubscription? _socketSub; String _lastGgaMessage = ''; DateTime _lastGgaSentTime = DateTime.now().subtract(const Duration(seconds: 30)); bool _headerValidated = false; final List _headerBytes = []; Completer? _connectCompleter; Function(Uint8List)? onRtcmData; // ── Inicializálás ──────────────────────────────────────────────── @override Future onInit() async { super.onInit(); await _loadSettings(); // _syncControllersFromValues(); _wireGnssListener(); } void _wireGnssListener() { if (!Get.isRegistered()) { Future.delayed(const Duration(seconds: 2), _wireGnssListener); } ever(GnssService.to.connectionState, (state) { if (state == GnssConnectionState.connected) { final type = GnssService.to.activeConnectionType.value; final isExternal = type == GnssConnectionType.btSerial || type == GnssConnectionType.ble; if (isExternal && autoConnect.value && hasCompleteSettings && !isConnected.value) { unawaited(connect().catchError((e) { lastError.value = e.tostring(); })); } } else if (state == GnssConnectionState.disconnected || state == GnssConnectionState.error) { if (isConnected.value) { unawaited(disconnect()); } } }); } void _ensureReceiverReady() { if (!Get.isRegistered() || GnssService.to.connectionState.value != GnssConnectionState.connected) { throw NtripException( 'Nincs csatlakoztatott GNSS vevő - előbb csatlakozz az eszközhöz, ' 'utána indítható az NTRIP korrekció.'); } final type = GnssService.to.activeConnectionType.value; if (type == GnssConnectionType.phoneGps) { throw NtripException( 'A telefon beépített GPS-e nem tud RTK korrekciókat fogadni - ' 'az NTRIP használatához külső GNSS vevő szükséges'); } } @override void onClose() { disconnect(); // hostController.dispose(); // portController.dispose(); // mountpointController.dispose(); // usernameController.dispose(); // passwordController.dispose(); super.onClose(); } // ── Kapcsolat ──────────────────────────────────────────────────── /// Kapcsolódás a casterhez. Megvárja a caster válaszát, és /// [NtripException]-t dob értelmes üzenettel, ha valami nem stimmel /// (rossz jelszó, rossz mountpoint, időtúllépés, hálózati hiba). Future connect({bool bypassReceiverCheck = false}) async { if (isConnected.value) return; if (!hasCompleteSettings) { throw NtripException('Hiányzó NTRIP beállítások (host / mountpoint).'); } if (!bypassReceiverCheck) { _ensureReceiverReady(); } lastError.value = ''; _headerValidated = false; _headerBytes.clear(); receivedBytes.value = 0; packetCount.value = 0; _connectCompleter = Completer(); try { // FONTOS: String host → a Socket.connect DNS-feloldást is végez, // így hostname (pl. www.gnssnet.hu) és IP-cím is működik. // (A korábbi InternetAddress(host) csak IP-literált fogadott el.) _socket = await Socket.connect( host.value.trim(), port.value, timeout: const Duration(seconds: 5), ); _socket!.add(_toUint8List(_buildNtripHeader())); _socketSub = _socket!.listen( _onData, onError: _onError, onDone: _onDone, ); // Megvárjuk, hogy a caster válaszoljon és a fejléc validálódjon. await _connectCompleter!.future.timeout( const Duration(seconds: 8), onTimeout: () => throw NtripException('Időtúllépés — a caster nem válaszolt.'), ); isConnected.value = true; connectedSince.value = DateTime.now(); } on NtripException { await _teardown(); rethrow; } on SocketException catch (e) { await _teardown(); throw NtripException('Hálózati hiba: ${e.message}'); } catch (e) { await _teardown(); throw NtripException('Nem sikerült csatlakozni: $e'); } finally { _connectCompleter = null; } } Future disconnect() async { if (!isConnected.value && _socket == null) return; await _teardown(); } Future _teardown() async { await _socketSub?.cancel(); _socketSub = null; _socket?.destroy(); _socket = null; isConnected.value = false; connectedSince.value = null; lastRtcmAt.value = null; _headerValidated = false; _headerBytes.clear(); } void reconnect() async { await disconnect(); await Future.delayed(const Duration(seconds: 1)); await connect(); } Future testConnection() async { if (isConnected.value) { throw NtripException('Már van élő NTRIP kapcsolat.'); } try { await connect(bypassReceiverCheck: true); return 'A caster elfogadta a kapcsolatot - ' 'a beállítások (jelszó, mountpoint) rendben vannak.'; } finally { await disconnect(); } } // ── GGA küldés ─────────────────────────────────────────────────── /// Az NMEA feldolgozó hívja minden GGA mondatnál. /// 5 másodpercenként küld egyet az NTRIP casternek. void onGgaReceived(String ggaLine, String utcTime) { _lastGgaMessage = ggaLine; if (!isConnected.value) return; if (ggaLine.isEmpty) return; final elapsed = DateTime.now().difference(_lastGgaSentTime).inSeconds; if (elapsed < 5) return; _sendGga(ggaLine); ggaSentCount.value++; ggaLastSentTime.value = utcTime; _lastGgaSentTime = DateTime.now(); } void _sendGga(String ggaMessage) { if (_socket == null || !isConnected.value) return; _socket!.add(_toUint8List('$ggaMessage\r\n')); } // ── Belső adatfogadás ──────────────────────────────────────────── void _onData(Uint8List data) { // Amíg a fejléc nincs validálva, a beérkező byte-okat puffereljük // és a caster válaszát elemezzük. Így a "ICY 200 OK" / HTTP fejléc // vagy egy hibaüzenet SOHA nem kerül RTCM-ként a GNSS vevőbe. if (!_headerValidated) { _handleHeaderBytes(data); return; } _forwardRtcm(data); } void _handleHeaderBytes(Uint8List data) { _headerBytes.addAll(data); // latin1: minden byte dekódolható, 1 byte = 1 karakter, // így a szöveg-index megegyezik a byte-indexszel. final text = latin1.decode(_headerBytes); // Várunk, amíg legalább az első sor megérkezik. final firstLineEnd = text.indexOf('\r\n'); if (firstLineEnd == -1) { if (_headerBytes.length > 4096) { _failConnect('Érvénytelen caster válasz (nincs fejléc).'); } return; } final firstLine = text.substring(0, firstLineEnd).trim(); // Rossz mountpoint → a caster a forrástáblát küldi. if (firstLine.contains('SOURCETABLE')) { _failConnect( 'Hibás mountpoint: "${mountpoint.value}" — a caster forrástáblát küldött.'); return; } // Hibás hitelesítés. if (firstLine.contains('401') || firstLine.contains('403')) { _failConnect('Hibás felhasználónév vagy jelszó (${firstLine.trim()}).'); return; } final isOk = (firstLine.startsWith('ICY') || firstLine.startsWith('HTTP/')) && firstLine.contains('200'); if (!isOk) { _failConnect('Váratlan caster válasz: "$firstLine"'); return; } // Siker — meghatározzuk, hol ér véget a fejléc, mert az azt követő // byte-ok már RTCM adatok, amiket tovább kell adni a vevőnek. int headerEnd; if (firstLine.startsWith('ICY')) { // NTRIP v1: "ICY 200 OK\r\n" (esetleg + üres sor) headerEnd = firstLineEnd + 2; if (text.length >= headerEnd + 2 && text.substring(headerEnd, headerEnd + 2) == '\r\n') { headerEnd += 2; } } else { // NTRIP v2 / HTTP: teljes fejléc "\r\n\r\n"-ig final i = text.indexOf('\r\n\r\n'); if (i == -1) { // A fejléc még nem teljes — várunk a következő csomagra. if (_headerBytes.length > 8192) { _failConnect('A caster fejléce túl hosszú / hibás.'); } return; } headerEnd = i + 4; } _headerValidated = true; if (!(_connectCompleter?.isCompleted ?? true)) { _connectCompleter!.complete(); } // A fejléc után már megérkezett RTCM byte-ok továbbítása. if (_headerBytes.length > headerEnd) { final rest = Uint8List.fromList(_headerBytes.sublist(headerEnd)); _headerBytes.clear(); _forwardRtcm(rest); } else { _headerBytes.clear(); } } void _forwardRtcm(Uint8List data) { receivedBytes.value += data.length; // kumulált (a korábbi = felülírt) packetCount.value++; onRtcmData?.call(data); } void _failConnect(String message) { lastError.value = message; if (!(_connectCompleter?.isCompleted ?? true)) { _connectCompleter!.completeError(NtripException(message)); } // A teardown-t a connect() catch ága végzi el. } void _onError(dynamic error) { // Kapcsolódás közben: a connect() kapja meg a hibát. if (!(_connectCompleter?.isCompleted ?? true)) { _connectCompleter! .completeError(NtripException('Kapcsolati hiba: $error')); return; } // Élő kapcsolat közben: bontás + értesítés. _teardown(); lastError.value = error.toString(); Get.snackbar( 'NTRIP kapcsolat megszakadt', error.toString(), backgroundColor: const Color(0xFFB71C1C), colorText: const Color(0xFFFFFFFF), ); } void _onDone() async { if (!(_connectCompleter?.isCompleted ?? true)) { _connectCompleter! .completeError(NtripException('A caster bontotta a kapcsolatot.')); return; } _teardown(); } // ── HTTP fejléc összeállítás ───────────────────────────────────── String _buildNtripHeader() { final auth = _toBase64('${username.value}:${password.value}'); final hostHeader = '${host.value}:${port.value}'; return 'GET /${mountpoint.value} HTTP/1.1\r\n' 'User-Agent: SharpGps iter.dk\r\n' 'Accept: */*\r\n' 'Connection: close\r\n' 'Authorization: Basic $auth\r\n' 'Host: $hostHeader\r\n' 'Ntrip-Version: Ntrip/2.0\r\n' '\r\n'; } // ── Beállítások mentése / betöltése ────────────────────────────── /// A NtripSettingsController és bármely más UI ezt hívja. Future updateSettings({ required String host, required int port, required String mountpoint, required String username, required String password, bool? autoConnect, }) async { this.host.value = host.trim(); this.port.value = port; this.mountpoint.value = mountpoint.trim(); this.username.value = username.trim(); this.password.value = password; if (autoConnect != null) this.autoConnect.value = autoConnect; // _syncControllersFromValues(); final prefs = await SharedPreferences.getInstance(); await prefs.setString('ntrip_host', this.host.value); await prefs.setInt('ntrip_port', this.port.value); await prefs.setString('ntrip_mountpoint', this.mountpoint.value); await prefs.setString('ntrip_username', this.username.value); await prefs.setBool('ntrip_auto_connect', this.autoConnect.value); // A jelszó KIZÁRÓLAG secure storage-ba kerül. await _secure.write(key: 'ntrip_password', value: password); // Ha korábban plain textben volt a prefs-ben, azt töröljük. await prefs.remove('ntrip_password'); } // Future saveSettings() async { // await updateSettings( // host: hostController.text, // port: int.tryParse(portController.text) ?? 2101, // mountpoint: mountpointController.text, // username: usernameController.text, // password: passwordController.text.isNotEmpty // ? passwordController.text // : password.value, // ); // } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); host.value = prefs.getString('ntrip_host') ?? ''; port.value = prefs.getInt('ntrip_port') ?? 2101; mountpoint.value = prefs.getString('ntrip_mountpoint') ?? ''; username.value = prefs.getString('ntrip_username') ?? ''; password.value = await _secure.read(key: 'ntrip_password') ?? ''; autoConnect.value = prefs.getBool('ntrip_auto_connect') ?? false; } // void _syncControllersFromValues() { // hostController.text = host.value; // portController.text = port.value.toString(); // mountpointController.text = mountpoint.value; // usernameController.text = username.value; // // Jelszót nem pre-töltjük biztonsági okokból // } // ── Segédfüggvények ────────────────────────────────────────────── String _toBase64(String str) => base64.encode(ascii.encode(str)); Uint8List _toUint8List(String str) => Uint8List.fromList(str.codeUnits); }