483 lines
16 KiB
Dart
483 lines
16 KiB
Dart
// 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<DateTime>();
|
|
final connectedSince = Rxn<DateTime>();
|
|
|
|
// ── 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<int> _headerBytes = [];
|
|
Completer<void>? _connectCompleter;
|
|
|
|
Function(Uint8List)? onRtcmData;
|
|
|
|
// ── Inicializálás ────────────────────────────────────────────────
|
|
|
|
@override
|
|
Future<void> onInit() async {
|
|
super.onInit();
|
|
await _loadSettings();
|
|
// _syncControllersFromValues();
|
|
_wireGnssListener();
|
|
}
|
|
|
|
void _wireGnssListener() {
|
|
if (!Get.isRegistered<GnssService>()) {
|
|
Future.delayed(const Duration(seconds: 2), _wireGnssListener);
|
|
}
|
|
|
|
ever<GnssConnectionState>(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>() ||
|
|
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<void> 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<void>();
|
|
|
|
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<void> disconnect() async {
|
|
if (!isConnected.value && _socket == null) return;
|
|
await _teardown();
|
|
}
|
|
|
|
Future<void> _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<String> 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++;
|
|
lastRtcmAt.value = DateTime.now();
|
|
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<void> 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<void> 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<void> _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);
|
|
}
|