using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Ports; using System.Linq; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Timers; using System.Windows; using AgroBase.Models; using AgroBase.Services; using OperationControl.Models; using static AgroBase.Models.Enums; using static OperationControl.Services.BaseFixService; using Application = System.Windows.Application; namespace OperationControl.Services { /// /// Serviço do UM982 da base. /// /// Princípios desta implementação: /// 1) O callback serial apenas lê, separa e enfileira. Nunca publica na rede. /// 2) NMEA e RTCM são demultiplexados por uma única máquina de estados. /// 3) RTCM só é encaminhado após validação CRC24Q. /// 4) A fila RTCM mantém apenas a mensagem mais nova de cada tipo. /// 5) Mensagens RTCM antigas são descartadas antes da publicação. /// 6) Reconectar a USB não altera silenciosamente o papel do GNSS. /// 7) Todas as escritas no UM982 passam por um único lock. /// 8) O cliente NTRIP possui cancelamento real e não mistura StreamReader com dados binários. /// public sealed class GpsService : IDisposable { // ========================================================= // CONFIGURAÇÃO GERAL // ========================================================= private const int DefaultBaudRate = 115200; private static readonly TimeSpan SerialSilenceReconnectTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan SerialReconnectGrace = TimeSpan.FromSeconds(10); private static readonly TimeSpan RtcmAssemblyTimeout = TimeSpan.FromSeconds(1.5); private static readonly TimeSpan NmeaAssemblyTimeout = TimeSpan.FromSeconds(2.0); private static readonly TimeSpan RtcmMaxQueueAge = TimeSpan.FromSeconds(2.5); private readonly System.Timers.Timer _tmrCheck = new(2000) { AutoReset = true }; private readonly SemaphoreSlim _scanGate = new(1, 1); private readonly SemaphoreSlim _serialWriteGate = new(1, 1); private readonly object _portLock = new(); private readonly object _parserLock = new(); private readonly object _modelLock = new(); private readonly object _rtcmQueueLock = new(); private readonly object _ntripLock = new(); private readonly CancellationTokenSource _lifetimeCts = new(); private readonly SemaphoreSlim _rtcmSignal = new(0); private readonly Dictionary _rtcmLatestByType = new(); private readonly Task _rtcmPublisherTask; private int _timerRunning; private int _disposed; private int _uiUpdatePending; private double _serialConnectedMono; private double _lastSerialRxMono; private double _lastValidNmeaMono; private double _lastValidGgaMono; private double _lastValidRtcmMono; private double _lastForwardedRtcmMono; private long _ggaSequence; private long _rtcmSequence; public GpsService() { BaseFix = new BaseFixService(null, this); _rtcmPublisherTask = Task.Run( () => RtcmPublisherLoopAsync(_lifetimeCts.Token), _lifetimeCts.Token); _tmrCheck.Elapsed += (_, __) => _ = CheckConnectionAsync(); _tmrCheck.Start(); } // ========================================================= // ESTADO PÚBLICO / COMPATIBILIDADE // ========================================================= public SerialPort PortaGps { get; private set; } public bool IsConnected { get { lock (_portLock) return PortaGps != null && PortaGps.IsOpen; } } public string PortName { get { lock (_portLock) return PortaGps?.PortName; } } private bool InverterHeading = false; private volatile bool CorrecaoRTK_Ntrip = false; private int TempoSurveryIn = 120; public volatile bool Ntrip_Conectado = false; public int TaxaAmostragemHz = 5; private int rtk_timeout = 60; public GPSModel UltimaLeitura = new GPSModel(); public BaseFixService BaseFix; public GeoLeverArm LeverArm = new GeoLeverArm(); /// /// Estratégia de fonte NTRIP. /// /// IbgeOnly: /// usa somente o RBMC-IP. /// /// Rtk2goOnly: /// usa somente o RTK2go. /// /// IbgeThenRtk2go: /// tenta primeiro todos os candidatos válidos do IBGE, em ordem de distância; /// se nenhum permanecer ativo, tenta RTK2go também em ordem de distância. /// public NtripProviderMode NtripMode { get; set; } = NtripProviderMode.IbgeThenRtk2go; /// /// Quando true, baixa a sourcetable do caster e escolhe automaticamente /// os mountpoints mais próximos da posição GNSS atual. /// /// Quando false, usa apenas NtripMountpoint / NtripRtk2goMountpoint. /// public bool NtripAutoSelecionarMountpoint { get; set; } = true; /// /// Quantidade máxima de estações que serão tentadas por provedor, /// sempre ordenadas da menor para a maior distância. /// public int NtripMaxCandidatosPorProvedor { get; set; } = 5; /// /// Distância máxima para NTRIP em levantamento móvel. /// É deliberadamente maior que o limite usado para fixação absoluta. /// public double NtripDistanciaMaximaKm { get; set; } = 120.0; /// /// Distância máxima para usar uma estação na FIXAÇÃO ABSOLUTA DA BASE. /// Mantida conservadora porque queremos coordenada absoluta confiável. /// public double NtripDistanciaMaximaFixacaoBaseKm { get; set; } = 80.0; /// /// Tempo máximo aguardando uma posição GGA recente antes de selecionar /// automaticamente as estações. /// /// Durante esta janela o serviço também reforça o comando GGA no UM982 /// caso ainda não tenha recebido uma posição válida. /// public int NtripEsperaPosicaoAutoSegundos { get; set; } = 15; /// /// Porta interna do UM982 usada para solicitar GGA durante a descoberta /// automática de mountpoints. Deve acompanhar a porta USB configurada /// no restante do serviço. /// public string NtripPortaGgaAutoSelecao { get; set; } = "com2"; /// /// Cache da sourcetable. A lista de estações muda pouco e não precisa /// ser baixada em todo reconnect. /// public int NtripSourcetableCacheSegundos { get; set; } = 600; /// /// Se uma sessão responder HTTP 200 mas não entregar nenhum byte de /// correção dentro deste prazo, ela é abandonada e o próximo candidato /// é tentado. /// public int NtripPrimeirosDadosTimeoutSegundos { get; set; } = 8; /// /// Tempo durante o qual um mountpoint que falhou fica temporariamente /// fora da lista de tentativas. Evita repetir 401/404/503/timeouts em /// sequência contra a mesma estação. /// /// Use 0 para desabilitar o cooldown. /// public int NtripCooldownMountpointSegundos { get; set; } = 60; /// /// Libera RTK2go como fallback também durante FixarBaseViaNtripAsync(). /// Mesmo liberado, o seletor automático ainda aplica o limite /// NtripDistanciaMaximaFixacaoBaseKm. /// public bool NtripRtk2goPermitidoParaFixacaoBase { get; set; } = false; /// /// Configuração IBGE / RBMC-IP. /// NtripMountpoint é mantido como override/fallback manual. /// public string NtripHost { get; set; } = "gps-ntrip.ibge.gov.br"; public int NtripPort { get; set; } = 2101; public string NtripMountpoint { get; set; } = "EESC0"; public string NtripUsername { get; set; } = Environment.GetEnvironmentVariable("AGRO_NTRIP_USERNAME") ?? string.Empty; public string NtripPassword { get; set; } = Environment.GetEnvironmentVariable("AGRO_NTRIP_PASSWORD") ?? string.Empty; /// /// Configuração RTK2go. /// NtripRtk2goMountpoint é mantido como override manual para diagnóstico. /// Com seleção automática ativa ele normalmente não será usado. /// public string NtripRtk2goHost { get; set; } = "rtk2go.com"; public int NtripRtk2goPort { get; set; } = 2101; public string NtripRtk2goMountpoint { get; set; } = "SPLI1"; public string NtripRtk2goUsername { get; set; } = Environment.GetEnvironmentVariable("AGRO_RTK2GO_EMAIL") ?? string.Empty; public string NtripRtk2goPassword { get; set; } = "none"; /// /// Provedor/mountpoint atualmente conectados. /// public string NtripProviderAtivo { get; private set; } = string.Empty; public string NtripMountpointAtivo { get; private set; } = string.Empty; public double? NtripDistanciaMountpointAtivoKm { get; private set; } /// /// Configura credenciais somente em memória para a sessão atual. /// Nenhum valor é gravado em arquivo por este método. /// public void ConfigurarCredenciaisNtripEmMemoria( string ibgeUsuario = null, string ibgeSenha = null, string rtk2goEmail = null) { if (ibgeUsuario != null) NtripUsername = ibgeUsuario.Trim(); if (ibgeSenha != null) NtripPassword = ibgeSenha; if (rtk2goEmail != null) NtripRtk2goUsername = rtk2goEmail.Trim(); } private GnssExpectedRole _expectedRole = GnssExpectedRole.PreserveCurrentConfiguration; private BaseFixedConfiguration _lastBaseConfiguration; // ========================================================= // MÉTRICAS // ========================================================= private long _serialBytesReceived; private long _serialReadErrors; private long _nmeaValid; private long _nmeaInvalid; private long _nmeaChecksumErrors; private long _rtcmValid; private long _rtcmCrcErrors; private long _rtcmInvalidLength; private long _rtcmResyncs; private long _rtcmQueued; private long _rtcmReplaced; private long _rtcmDroppedStale; private long _rtcmForwarded; private long _rtcmPublishErrors; private long _serialReconnects; private long _ntripBytesReceived; private long _ntripReconnects; private long _ntripErrors; public GpsTransportMetrics GetTransportMetrics() { int queueDepth; double oldestQueueAgeMs = 0; lock (_rtcmQueueLock) { queueDepth = _rtcmLatestByType.Count; if (queueDepth > 0) { double now = MonotonicNow(); oldestQueueAgeMs = _rtcmLatestByType.Values .Max(x => Math.Max(0, (now - x.ReceivedMono) * 1000.0)); } } double nowMono = MonotonicNow(); return new GpsTransportMetrics { SerialConnected = IsConnected, PortName = PortName, SerialLastRxAgeMs = AgeMs(nowMono, _lastSerialRxMono), LastValidNmeaAgeMs = AgeMs(nowMono, _lastValidNmeaMono), LastValidGgaAgeMs = AgeMs(nowMono, _lastValidGgaMono), LastValidRtcmAgeMs = AgeMs(nowMono, _lastValidRtcmMono), LastForwardedRtcmAgeMs = AgeMs(nowMono, _lastForwardedRtcmMono), SerialBytesReceived = Interlocked.Read(ref _serialBytesReceived), SerialReadErrors = Interlocked.Read(ref _serialReadErrors), NmeaValid = Interlocked.Read(ref _nmeaValid), NmeaInvalid = Interlocked.Read(ref _nmeaInvalid), NmeaChecksumErrors = Interlocked.Read(ref _nmeaChecksumErrors), RtcmValid = Interlocked.Read(ref _rtcmValid), RtcmCrcErrors = Interlocked.Read(ref _rtcmCrcErrors), RtcmInvalidLength = Interlocked.Read(ref _rtcmInvalidLength), RtcmResyncs = Interlocked.Read(ref _rtcmResyncs), RtcmQueued = Interlocked.Read(ref _rtcmQueued), RtcmReplaced = Interlocked.Read(ref _rtcmReplaced), RtcmDroppedStale = Interlocked.Read(ref _rtcmDroppedStale), RtcmForwarded = Interlocked.Read(ref _rtcmForwarded), RtcmPublishErrors = Interlocked.Read(ref _rtcmPublishErrors), RtcmQueueDepth = queueDepth, RtcmOldestQueueAgeMs = oldestQueueAgeMs, SerialReconnects = Interlocked.Read(ref _serialReconnects), NtripConnected = Ntrip_Conectado, NtripBytesReceived = Interlocked.Read(ref _ntripBytesReceived), NtripReconnects = Interlocked.Read(ref _ntripReconnects), NtripErrors = Interlocked.Read(ref _ntripErrors), NtripProvider = NtripProviderAtivo, NtripMountpoint = NtripMountpointAtivo, NtripMountpointDistanceKm = NtripDistanciaMountpointAtivoKm, ExpectedRole = _expectedRole.ToString(), }; } // ========================================================= // DESCOBERTA / RECONEXÃO SERIAL // ========================================================= private async Task CheckConnectionAsync() { if (Interlocked.CompareExchange(ref _timerRunning, 1, 0) != 0) return; try { if (Volatile.Read(ref _disposed) != 0) return; bool connected = IsConnected; double now = MonotonicNow(); if (connected) { bool graceEnded = _serialConnectedMono > 0 && (now - _serialConnectedMono) >= SerialReconnectGrace.TotalSeconds; double lastActivity = _lastSerialRxMono > 0 ? _lastSerialRxMono : _serialConnectedMono; bool silentTooLong = lastActivity > 0 && (now - lastActivity) >= SerialSilenceReconnectTimeout.TotalSeconds; if (!graceEnded || !silentTooLong) return; Models.Variaveis.MostrarLog( $"GNSS sem dados há {(now - _lastSerialRxMono):F1}s. Reiniciando conexão serial."); DisconnectInternal(); } bool found = await ScanAndConnectAsync( timeoutPorPortaMs: 1500, ct: _lifetimeCts.Token).ConfigureAwait(false); if (found) { Models.Variaveis.MostrarLog($"GPS conectado na porta {PortName}"); await RestoreExpectedRoleAfterReconnectAsync(_lifetimeCts.Token).ConfigureAwait(false); } else { Models.Variaveis.MostrarLog("Nenhum GPS UM982 encontrado."); } } catch (OperationCanceledException) { } catch (Exception ex) { Models.Variaveis.MostrarLog($"Erro ao verificar conexão do GNSS: {ex.Message}"); } finally { Interlocked.Exchange(ref _timerRunning, 0); } } public async Task ScanAndConnectAsync( int timeoutPorPortaMs = 1500, CancellationToken ct = default) { if (IsConnected) return true; await _scanGate.WaitAsync(ct).ConfigureAwait(false); try { if (IsConnected) return true; string[] portas = SerialPort.GetPortNames() .OrderBy(p => p) .ToArray(); foreach (string portName in portas) { ct.ThrowIfCancellationRequested(); if (await TryConnectOnPortAsync(portName, timeoutPorPortaMs, ct) .ConfigureAwait(false)) { Interlocked.Increment(ref _serialReconnects); return true; } } return false; } finally { _scanGate.Release(); } } private async Task TryConnectOnPortAsync( string portName, int timeoutMs, CancellationToken ct) { SerialPort porta = null; try { porta = new SerialPort(portName, DefaultBaudRate) { ReadTimeout = timeoutMs, WriteTimeout = timeoutMs, NewLine = "\n", DtrEnable = false, RtsEnable = false, ReadBufferSize = 64 * 1024, WriteBufferSize = 16 * 1024, }; porta.Open(); if (!await EhUm982PorVersionAsync(porta, timeoutMs, ct).ConfigureAwait(false)) { // Fallback não persistente: pede apenas GGA para confirmar que é GNSS. byte[] nmea = Encoding.ASCII.GetBytes("gngga com2 1\r\n"); porta.Write(nmea, 0, nmea.Length); await Task.Delay(200, ct).ConfigureAwait(false); string sample = await LerAmostraAsync(porta, timeoutMs, ct).ConfigureAwait(false); if (!EhGpsUm982OuNmea(sample)) { porta.Close(); porta.Dispose(); return false; } } RegisterConnectedPort(porta); return true; } catch (OperationCanceledException) { try { porta?.Close(); } catch { } try { porta?.Dispose(); } catch { } throw; } catch { try { porta?.Close(); } catch { } try { porta?.Dispose(); } catch { } return false; } } private void RegisterConnectedPort(SerialPort porta) { DisconnectInternal(); lock (_portLock) { PortaGps = porta; PortaGps.DataReceived -= PortaGPS_DataReceived; PortaGps.DataReceived += PortaGPS_DataReceived; } ResetParsers(); _serialConnectedMono = MonotonicNow(); _lastSerialRxMono = 0; } private async Task LerAmostraAsync( SerialPort porta, int timeoutMs, CancellationToken ct) { double start = MonotonicNow(); StringBuilder buffer = new(); while ((MonotonicNow() - start) * 1000.0 < timeoutMs) { ct.ThrowIfCancellationRequested(); try { int available = porta.BytesToRead; if (available > 0) { byte[] temp = new byte[available]; int read = porta.Read(temp, 0, temp.Length); if (read > 0) { buffer.Append(Encoding.ASCII.GetString(temp, 0, read)); string text = buffer.ToString(); if (text.Contains("UM982", StringComparison.OrdinalIgnoreCase) || text.Contains("$GP", StringComparison.OrdinalIgnoreCase) || text.Contains("$GN", StringComparison.OrdinalIgnoreCase)) { break; } } } } catch { break; } await Task.Delay(50, ct).ConfigureAwait(false); } return buffer.ToString(); } private static bool EhGpsUm982OuNmea(string recebido) { if (string.IsNullOrEmpty(recebido)) return false; return recebido.Contains("UM982", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPTXT", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPRMC", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPGGA", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPGLL", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GNGGA", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPVTG", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GPGSV", StringComparison.OrdinalIgnoreCase) || recebido.Contains("$GNTHS", StringComparison.OrdinalIgnoreCase); } private async Task EhUm982PorVersionAsync( SerialPort porta, int timeoutMs, CancellationToken ct) { try { porta.DiscardInBuffer(); byte[] cmd = Encoding.ASCII.GetBytes("version\r\n"); porta.Write(cmd, 0, cmd.Length); await Task.Delay(100, ct).ConfigureAwait(false); string response = await LerAmostraAsync(porta, timeoutMs, ct).ConfigureAwait(false); return !string.IsNullOrEmpty(response) && response.Contains("UM982", StringComparison.OrdinalIgnoreCase); } catch { return false; } } public void Disconnect() { DisconnectInternal(); } private void DisconnectInternal() { SerialPort port; lock (_portLock) { port = PortaGps; PortaGps = null; } if (port == null) return; try { port.DataReceived -= PortaGPS_DataReceived; } catch { } try { if (port.IsOpen) port.Close(); } catch { } try { port.Dispose(); } catch { } } private async Task RestoreExpectedRoleAfterReconnectAsync(CancellationToken ct) { // A regra mais importante é: uma reconexão nunca muda o papel do módulo // para rover por conta própria. if (_expectedRole == GnssExpectedRole.BaseFixed && _lastBaseConfiguration != null) { Models.Variaveis.MostrarLog("Restaurando configuração conhecida da base fixa após reconexão."); await BaseFix.AplicarBaseFixAsync( _lastBaseConfiguration.PortaUsb, _lastBaseConfiguration.PortaSaida, _lastBaseConfiguration.BaseId, _lastBaseConfiguration.Latitude, _lastBaseConfiguration.Longitude, _lastBaseConfiguration.AltitudeElipsoidal, ct).ConfigureAwait(false); } else if (_expectedRole == GnssExpectedRole.RoverTemporary) { Models.Variaveis.MostrarLog("Restaurando modo rover temporário após reconexão."); await BaseFix.ConfigurarComoRoverParadoAsync("com2", "com2", ct) .ConfigureAwait(false); } // PreserveCurrentConfiguration: não envia unlog, mode rover ou saveconfig. } // ========================================================= // ESCRITA SERIAL SERIALIZADA // ========================================================= internal async Task WriteSerialAsync( byte[] data, CancellationToken ct = default) { if (data == null || data.Length == 0) return; await _serialWriteGate.WaitAsync(ct).ConfigureAwait(false); try { SerialPort port; lock (_portLock) port = PortaGps; if (port == null || !port.IsOpen) throw new IOException("Porta do GNSS não está conectada."); port.Write(data, 0, data.Length); } finally { _serialWriteGate.Release(); } } internal Task WriteSerialCommandAsync( string command, CancellationToken ct = default) { return WriteSerialAsync(Encoding.ASCII.GetBytes(command), ct); } internal async Task SendCommandsAsync( IEnumerable commands, int delayMs, CancellationToken ct = default) { foreach (string command in commands) { ct.ThrowIfCancellationRequested(); await WriteSerialCommandAsync(command, ct).ConfigureAwait(false); if (delayMs > 0) await Task.Delay(delayMs, ct).ConfigureAwait(false); } } // ========================================================= // CONFIGURAÇÃO DO MÓDULO // ========================================================= public async Task ConfigurarModulo( bool fixar, MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip, double? lat = null, double? lon = null, double? alt = null, bool? offset = false) { Models.Variaveis.MostrarLog("Iniciando configuração do módulo GPS..."); BaseFix ??= new BaseFixService(PortaGps, this); BaseFix.FixLiberado = fixar; const string portaUsb = "com2"; const string portaSaida = "com2"; const string portaEntrada = "com2"; const string baseId = "957"; if (!fixar) { Models.Variaveis.MostrarLog( "Configurando explicitamente o módulo como rover parado." ); await BaseFix .ConfigurarComoRoverParadoAsync( portaUsb, portaEntrada, _lifetimeCts.Token ) .ConfigureAwait(false); DefinirPapelEsperado( GnssExpectedRole.RoverTemporary ); return; } /* * Vamos iniciar uma configuração de BASE. * * Se estávamos fazendo levantamento móvel * com NTRIP, encerramos a injeção RTCM antes * de começar a enviar os comandos da base. * * No método NTRIP, FixarBaseViaNtripAsync() * iniciará novamente a correção no momento * correto. */ await StopNtripAsync().ConfigureAwait(false); bool sucesso = false; string mensagem; try { BaseFix.fimProcesso = null; BaseFix.inicioProcesso = DateTime.UtcNow; BaseFix.inicioFix = DateTime.UtcNow; BaseFix.CorrecaoEmAndamento = true; Models.Variaveis.MostrarLog($"Aplicando posição fixa da base com o método {metodo}..."); switch (metodo) { case MetodoFixacaoBase.Ntrip: BaseFix.DefinirTempos( segsFixEstavel: TempoSurveryIn, segsJanelaSegs: 600, metodo: metodo); sucesso = await BaseFix.FixarBaseViaNtripAsync( portaUsb: portaUsb, portaSaida: portaSaida, portaEntrada: portaEntrada, baseId: baseId, startNtrip: () => StartNtripAsync(paraFixacaoBase: true), stopNtrip: StopNtripAsync, ct: _lifetimeCts.Token).ConfigureAwait(false); break; case MetodoFixacaoBase.SurveyIn: BaseFix.DefinirTempos( segsFixEstavel: TempoSurveryIn, segsJanelaSegs: TempoSurveryIn + 30, metodo: metodo); await ConfigurarModuloBase( tempo_fixacao: TempoSurveryIn, porta_usb: portaUsb, porta_saida: portaSaida, ct: _lifetimeCts.Token).ConfigureAwait(false); DateTime surveyDeadline = DateTime.UtcNow.AddSeconds(TempoSurveryIn + 30); while (DateTime.UtcNow < surveyDeadline && GetFixQualitySnapshot() != TiposCorrecaoGPS.BaseFix) { await Task.Delay(500, _lifetimeCts.Token).ConfigureAwait(false); } sucesso = GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix; break; case MetodoFixacaoBase.Manual: if (lat.HasValue && lon.HasValue && alt.HasValue) { BaseFix.DefinirTempos(10, 10, metodo); await BaseFix.AplicarBaseFixAsync( portaUsb, portaSaida, baseId, lat.Value, lon.Value, alt.Value, _lifetimeCts.Token).ConfigureAwait(false); sucesso = true; } break; } if (sucesso) { DateTime deadline = DateTime.UtcNow.AddSeconds(10); while (DateTime.UtcNow < deadline && GetFixQualitySnapshot() != TiposCorrecaoGPS.BaseFix) { await Task.Delay(500, _lifetimeCts.Token).ConfigureAwait(false); } sucesso = GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix; } } catch (OperationCanceledException) { sucesso = false; } catch (Exception ex) { sucesso = false; Models.Variaveis.MostrarLog($"Erro ao definir posição da base: {ex.Message}"); } finally { BaseFix.fimProcesso = DateTime.UtcNow; BaseFix.FixLiberado = false; BaseFix.CorrecaoEmAndamento = false; if (sucesso) { var snapshot = GetPositionSnapshot(); DefinirPapelEsperado( GnssExpectedRole.BaseFixed ); _lastBaseConfiguration = new BaseFixedConfiguration { PortaUsb = portaUsb, PortaSaida = portaSaida, BaseId = baseId, Latitude = snapshot.Latitude, Longitude = snapshot.Longitude, AltitudeElipsoidal = snapshot.AltitudeElipsoidal, }; if (!(offset ?? false)) { BaseFix.LatitudeFix = snapshot.Latitude; BaseFix.LongitudeFix = snapshot.Longitude; BaseFix.AltitudeElpsoidalFix = snapshot.AltitudeElipsoidal; BaseFix.OrientacaoFix = snapshot.Orientacao; } } if (!(offset ?? false) && AppShell.Mock && lat.HasValue && lon.HasValue && alt.HasValue) { lock (_modelLock) { UltimaLeitura.Latitude = lat.Value; UltimaLeitura.Longitude = lon.Value; UltimaLeitura.AltitudeElipsoidal = alt.Value; } AtualizarCoordenadasGPS(); sucesso = true; } mensagem = sucesso ? $"Posição da base aplicada com sucesso com o método {metodo}." : $"Falha ao aplicar posição da base com o método {metodo}."; Models.Variaveis.MostrarLog(mensagem); Models.Variaveis.Dock?._vm?.FinalizarFixacaoBase(sucesso, mensagem); } } private async Task ConfigurarModuloBase( string porta_usb = "com3", string porta_saida = "com2", int tempo_fixacao = 60, CancellationToken ct = default) { const string baseId = "957"; const string distanciaMin = "0"; string[] commands = { "config com1 115200\r\n", "config com2 115200\r\n", "config com3 115200\r\n", "unlog com1\r\n", "unlog com2\r\n", "unlog com3\r\n", $"mode base {baseId} time {tempo_fixacao} {distanciaMin}\r\n", $"RTCM1006 {porta_saida} 10\r\n", $"RTCM1033 {porta_saida} 30\r\n", $"RTCM1074 {porta_saida} 1\r\n", $"RTCM1084 {porta_saida} 1\r\n", $"RTCM1094 {porta_saida} 1\r\n", $"RTCM1124 {porta_saida} 1\r\n", $"RTCM1230 {porta_saida} 10\r\n", $"gngga {porta_usb} 1\r\n", "saveconfig\r\n", }; await Task.Delay(1000, ct).ConfigureAwait(false); await SendCommandsAsync(commands, 300, ct).ConfigureAwait(false); if (AppShell.Mock) _expectedRole = GnssExpectedRole.BaseFixed; } public async Task ConfigurarComoMovelParaLevantamentoAsync(bool usarNtrip = true) { if (!IsConnected) { throw new InvalidOperationException( "O GNSS não está conectado." ); } if ( BaseFix?.CorrecaoEmAndamento == true ) { throw new InvalidOperationException( "Existe uma fixação da base em andamento." ); } Models.Variaveis.MostrarLog( "[GNSS][PAPEL] " + "Alterando para modo móvel / levantamento..." ); /* * Se existia uma sessão NTRIP anterior, * encerramos antes de enviar comandos ASCII. * * Isso evita RTCM binário concorrendo com * configuração do UM982. */ await StopNtripAsync() .ConfigureAwait(false); BaseFix?.ReiniciarFix(); /* * ConfigurarModulo(false): * * - unlog * - mode rover uav * - GGA * - heading * - papel esperado = RoverTemporary */ await ConfigurarModulo( fixar: false ) .ConfigureAwait(false); /* * Para levantamento queremos precisão RTK. * * Portanto o receptor volta a receber * correções do caster depois de estar em * modo rover. */ if (usarNtrip) { await StartNtripAsync() .ConfigureAwait(false); } Models.Variaveis.MostrarLog( "[GNSS][PAPEL] " + $"Modo móvel ativo | " + $"NTRIP={(usarNtrip ? "SOLICITADO" : "NÃO")}" ); } // ========================================================= // LEITURA SERIAL E DEMULTIPLEXAÇÃO // ========================================================= private ParserMode _parserMode = ParserMode.Idle; private readonly List _rtcmBuffer = new(1100); private readonly List _nmeaBuffer = new(256); private int _rtcmExpectedBytes = -1; private double _frameStartedMono; private void PortaGPS_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { SerialPort port; lock (_portLock) port = PortaGps; if (port == null || !port.IsOpen) return; int available = port.BytesToRead; if (available <= 0) return; byte[] buffer = new byte[available]; int bytesRead = port.Read(buffer, 0, buffer.Length); if (bytesRead <= 0) return; double now = MonotonicNow(); _lastSerialRxMono = now; Interlocked.Add(ref _serialBytesReceived, bytesRead); List nmeaLines = new(); List rtcmMessages = new(); lock (_parserLock) { for (int i = 0; i < bytesRead; i++) { ProcessIncomingByte(buffer[i], nmeaLines, rtcmMessages); } } foreach (string line in nmeaLines) ProcessNmeaSafely(line); foreach (byte[] message in rtcmMessages) EnqueueRtcm(message); lock (_modelLock) { UltimaLeitura.Momento = DateTime.Now; UltimaLeitura.Inicializado = true; } } catch (Exception ex) { Interlocked.Increment(ref _serialReadErrors); Models.Variaveis.MostrarLog($"Erro ao processar dados da porta serial: {ex.Message}"); } } private void ProcessIncomingByte( byte b, List nmeaLines, List rtcmMessages) { double now = MonotonicNow(); if (_parserMode == ParserMode.Rtcm && (now - _frameStartedMono) > RtcmAssemblyTimeout.TotalSeconds) { ResetParserState(); Interlocked.Increment(ref _rtcmResyncs); } else if (_parserMode == ParserMode.Nmea && (now - _frameStartedMono) > NmeaAssemblyTimeout.TotalSeconds) { ResetParserState(); Interlocked.Increment(ref _nmeaInvalid); } switch (_parserMode) { case ParserMode.Idle: StartFrameIfApplicable(b, now); break; case ParserMode.Nmea: // Uma sentença quebrada não pode esconder um RTCM novo. if (b == 0xD3) { Interlocked.Increment(ref _nmeaInvalid); StartRtcm(now); return; } if (b == (byte)'$') { // Reinicia na sentença NMEA mais recente. StartNmea(now); return; } if (b == (byte)'\n') { _nmeaBuffer.Add(b); string line = Encoding.ASCII.GetString(_nmeaBuffer.ToArray()).Trim(); ResetParserState(); if (!string.IsNullOrWhiteSpace(line)) nmeaLines.Add(line); return; } if (b == (byte)'\r' || (b >= 0x20 && b <= 0x7E)) { _nmeaBuffer.Add(b); } else { ResetParserState(); Interlocked.Increment(ref _nmeaInvalid); StartFrameIfApplicable(b, now); return; } if (_nmeaBuffer.Count > 512) { ResetParserState(); Interlocked.Increment(ref _nmeaInvalid); } break; case ParserMode.Rtcm: _rtcmBuffer.Add(b); if (_rtcmBuffer.Count == 3) { // Os seis bits superiores do byte 1 são reservados e devem ser zero. if ((_rtcmBuffer[1] & 0xFC) != 0) { Interlocked.Increment(ref _rtcmInvalidLength); ResetAndResyncFromCurrentBuffer(now); return; } int payloadLength = ((_rtcmBuffer[1] & 0x03) << 8) | _rtcmBuffer[2]; _rtcmExpectedBytes = 3 + payloadLength + 3; if (payloadLength <= 0 || _rtcmExpectedBytes > 1029) { Interlocked.Increment(ref _rtcmInvalidLength); ResetAndResyncFromCurrentBuffer(now); return; } } if (_rtcmExpectedBytes > 0 && _rtcmBuffer.Count == _rtcmExpectedBytes) { byte[] message = _rtcmBuffer.ToArray(); ResetParserState(); if (ValidateRtcmCrc24Q(message)) { Interlocked.Increment(ref _rtcmValid); _lastValidRtcmMono = now; rtcmMessages.Add(message); } else { Interlocked.Increment(ref _rtcmCrcErrors); Interlocked.Increment(ref _rtcmResyncs); } } else if (_rtcmExpectedBytes > 0 && _rtcmBuffer.Count > _rtcmExpectedBytes) { Interlocked.Increment(ref _rtcmResyncs); ResetAndResyncFromCurrentBuffer(now); } break; } } private void StartFrameIfApplicable(byte b, double now) { if (b == 0xD3) StartRtcm(now); else if (b == (byte)'$') StartNmea(now); } private void StartNmea(double now) { ResetParserState(); _parserMode = ParserMode.Nmea; _frameStartedMono = now; _nmeaBuffer.Add((byte)'$'); } private void StartRtcm(double now) { ResetParserState(); _parserMode = ParserMode.Rtcm; _frameStartedMono = now; _rtcmBuffer.Add(0xD3); } private void ResetAndResyncFromCurrentBuffer(double now) { byte[] candidate = _rtcmBuffer.Skip(1).ToArray(); ResetParserState(); Interlocked.Increment(ref _rtcmResyncs); foreach (byte b in candidate) { if (_parserMode == ParserMode.Idle) { if (b == 0xD3) { StartRtcm(now); } else if (b == (byte)'$') { StartNmea(now); } } else if (_parserMode == ParserMode.Rtcm) { _rtcmBuffer.Add(b); if (_rtcmBuffer.Count == 3) { int len = ((_rtcmBuffer[1] & 0x03) << 8) | _rtcmBuffer[2]; _rtcmExpectedBytes = 3 + len + 3; } } else if (_parserMode == ParserMode.Nmea) { if (b == (byte)'\n') { ResetParserState(); } else if (b == (byte)'\r' || (b >= 0x20 && b <= 0x7E)) { _nmeaBuffer.Add(b); } } } } private void ResetParsers() { lock (_parserLock) ResetParserState(); } private void ResetParserState() { _parserMode = ParserMode.Idle; _rtcmBuffer.Clear(); _nmeaBuffer.Clear(); _rtcmExpectedBytes = -1; _frameStartedMono = 0; } // ========================================================= // RTCM: VALIDAÇÃO, FILA E PUBLICAÇÃO // ========================================================= private void EnqueueRtcm(byte[] message) { int type = GetRtcmMessageType(message); if (type < 0) { Interlocked.Increment(ref _rtcmInvalidLength); return; } RtcmEnvelope envelope = new() { Type = type, Data = message, ReceivedMono = MonotonicNow(), Sequence = Interlocked.Increment(ref _rtcmSequence), }; bool shouldSignal; lock (_rtcmQueueLock) { shouldSignal = _rtcmLatestByType.Count == 0; if (_rtcmLatestByType.ContainsKey(type)) Interlocked.Increment(ref _rtcmReplaced); _rtcmLatestByType[type] = envelope; Interlocked.Increment(ref _rtcmQueued); } if (shouldSignal) { try { _rtcmSignal.Release(); } catch (SemaphoreFullException) { } } } private async Task RtcmPublisherLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await _rtcmSignal.WaitAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } while (!ct.IsCancellationRequested) { List batch; lock (_rtcmQueueLock) { if (_rtcmLatestByType.Count == 0) break; batch = _rtcmLatestByType.Values .OrderBy(x => x.Sequence) .ToList(); _rtcmLatestByType.Clear(); } foreach (RtcmEnvelope item in batch) { if (ct.IsCancellationRequested) break; double age = MonotonicNow() - item.ReceivedMono; if (age > RtcmMaxQueueAge.TotalSeconds) { Interlocked.Increment(ref _rtcmDroppedStale); continue; } try { // A chamada externa fica isolada neste worker. Se MQTT bloquear, // a serial continua lendo e a fila mantém somente o RTCM mais novo por tipo. VariaveisControleOperacao.EnviarDadosCorrecaoRTCM(item.Data); _lastForwardedRtcmMono = MonotonicNow(); Interlocked.Increment(ref _rtcmForwarded); } catch (Exception ex) { Interlocked.Increment(ref _rtcmPublishErrors); Models.Variaveis.MostrarLog( $"Erro ao encaminhar RTCM tipo {item.Type}: {ex.Message}"); } } } } } private static int GetRtcmMessageType(byte[] message) { if (message == null || message.Length < 8 || message[0] != 0xD3) return -1; return (message[3] << 4) | (message[4] >> 4); } private static bool ValidateRtcmCrc24Q(byte[] message) { if (message == null || message.Length < 6) return false; uint calculated = ComputeCrc24Q(message, 0, message.Length - 3); uint received = ((uint)message[^3] << 16) | ((uint)message[^2] << 8) | message[^1]; return calculated == received; } private static uint ComputeCrc24Q(byte[] data, int offset, int count) { uint crc = 0; for (int i = offset; i < offset + count; i++) { crc ^= (uint)data[i] << 16; for (int bit = 0; bit < 8; bit++) { crc <<= 1; if ((crc & 0x1000000) != 0) crc ^= 0x1864CFB; } } return crc & 0xFFFFFF; } // ========================================================= // NMEA // ========================================================= private void ProcessNmeaSafely(string sentence) { if (string.IsNullOrWhiteSpace(sentence)) return; try { if (!TryValidateNmeaChecksum(sentence, out bool checksumPresent)) { Interlocked.Increment(ref _nmeaChecksumErrors); Interlocked.Increment(ref _nmeaInvalid); return; } ProcessarDadosNMEA(sentence); _lastValidNmeaMono = MonotonicNow(); Interlocked.Increment(ref _nmeaValid); if (sentence.StartsWith("$GNGGA", StringComparison.Ordinal) || sentence.StartsWith("$GPGGA", StringComparison.Ordinal) || sentence.StartsWith("$GLGGA", StringComparison.Ordinal)) { _lastValidGgaMono = MonotonicNow(); Interlocked.Increment(ref _ggaSequence); } } catch (Exception ex) { Interlocked.Increment(ref _nmeaInvalid); Models.Variaveis.MostrarLog($"Sentença NMEA inválida descartada: {ex.Message}"); } } private static bool TryValidateNmeaChecksum(string sentence, out bool checksumPresent) { checksumPresent = false; if (string.IsNullOrWhiteSpace(sentence) || sentence[0] != '$') return false; int star = sentence.IndexOf('*'); if (star < 0) { // Alguns comandos/respostas do fabricante podem não trazer checksum. return true; } checksumPresent = true; if (star + 2 >= sentence.Length) return false; byte checksum = 0; for (int i = 1; i < star; i++) checksum ^= (byte)sentence[i]; string expectedText = sentence.Substring(star + 1, 2); return byte.TryParse( expectedText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte expected) && checksum == expected; } private void ProcessarDadosNMEA(string sentenca) { DateTime agora = DateTime.Now; if (sentenca.StartsWith("$GPGGA", StringComparison.Ordinal)) { ProcessarGPGGA(sentenca); AtualizarCoordenadasGPS(); } else if (sentenca.StartsWith("$GNGGA", StringComparison.Ordinal) || sentenca.StartsWith("$GLGGA", StringComparison.Ordinal)) { ProcessarGNGGA(sentenca); AtualizarCoordenadasGPS(); } else if (sentenca.StartsWith("$GNRMC", StringComparison.Ordinal)) { ProcessarGxRMC(sentenca); } else if (sentenca.StartsWith("$GNVTG", StringComparison.Ordinal) || sentenca.StartsWith("$GPVTG", StringComparison.Ordinal)) { ProcessarGNVTG(sentenca); } else if (sentenca.StartsWith("$GPGSV", StringComparison.Ordinal) || sentenca.StartsWith("$GLGSV", StringComparison.Ordinal) || sentenca.StartsWith("$GBGSV", StringComparison.Ordinal) || sentenca.StartsWith("$GAGSV", StringComparison.Ordinal)) { ProcessarGSV(sentenca); } else if (sentenca.StartsWith("$GNTHS", StringComparison.Ordinal) || sentenca.StartsWith("$GPTHS", StringComparison.Ordinal) || sentenca.StartsWith("$GATHS", StringComparison.Ordinal)) { ProcessarGNTHS(sentenca); } else if (sentenca.Length > 6 && sentenca[3] == 'G' && sentenca[4] == 'L' && sentenca[5] == 'L') { ProcessarGNGLL(sentenca); } else if (sentenca.Length > 6 && sentenca[3] == 'G' && sentenca[4] == 'S' && sentenca[5] == 'A') { ProcessarGxGSA(sentenca); } else if (sentenca.Length > 6 && sentenca[3] == 'R' && sentenca[4] == 'M' && sentenca[5] == 'C') { ProcessarGxRMC(sentenca); } lock (_modelLock) UltimaLeitura.UltimoComandoRespondido = agora; } private void ProcessarGPGGA(string sentenca) { string[] parts = sentenca.Split(','); if (parts.Length < 10) throw new FormatException("GPGGA incompleta."); lock (_modelLock) { UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; UltimaLeitura.Momento = DateTime.Now; if (!string.IsNullOrWhiteSpace(parts[2]) && !string.IsNullOrWhiteSpace(parts[3])) UltimaLeitura.Latitude = GPSUtils.ConvertToDecimalDegrees(parts[2], parts[3], 2); if (!string.IsNullOrWhiteSpace(parts[4]) && !string.IsNullOrWhiteSpace(parts[5])) UltimaLeitura.Longitude = GPSUtils.ConvertToDecimalDegrees(parts[4], parts[5], 3); UltimaLeitura.NumeroSatelites = ParseInt(parts[7], 0); UltimaLeitura.PrecisaoHorizontal = ParseDouble(parts[8], 0); UltimaLeitura.Altitude = ParseDouble(parts[9], 0); } } private void ProcessarGNGGA(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 15) throw new FormatException("GNGGA incompleta."); string horaUtc = campos[1]; string latitudeRaw = campos[2]; string hemisferioLat = campos[3]; string longitudeRaw = campos[4]; string hemisferioLon = campos[5]; int fixCode = ParseInt(campos[6], 0); int satelites = ParseInt(campos[7], 0); double hdop = ParseDouble(campos[8], 99.9); double altMsl = ParseDouble(campos[9], 0); double geoidSep = ParseDouble(campos[11], 0); string idadeRaw = campos[13]; string baseId = campos[14].Split('*')[0]; double latitude = ParseDmm(latitudeRaw, hemisferioLat, 2); double longitude = ParseDmm(longitudeRaw, hemisferioLon, 3); double idadeCorrecao = -1; if (!string.IsNullOrWhiteSpace(idadeRaw) && double.TryParse( idadeRaw, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedAge)) { idadeCorrecao = parsedAge; } double altElipsoidal = altMsl + geoidSep; lock (_modelLock) { UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; UltimaLeitura.Momento = DateTime.Now; if (!double.IsNaN(latitude)) { UltimaLeitura.LatitudeAnt = UltimaLeitura.Latitude; UltimaLeitura.Latitude = latitude; } if (!double.IsNaN(longitude)) { UltimaLeitura.LongitudeAnt = UltimaLeitura.Longitude; UltimaLeitura.Longitude = longitude; } UltimaLeitura.Altitude = altMsl; UltimaLeitura.AltitudeElipsoidal = altElipsoidal; UltimaLeitura.PrecisaoHorizontal = hdop; UltimaLeitura.NumeroSatelites = satelites; UltimaLeitura.QualidadeFix = (TiposCorrecaoGPS)fixCode; UltimaLeitura.IdadeCorrecao = idadeCorrecao; UltimaLeitura.BaseID = baseId; if (TryParseNmeaTime(horaUtc, out TimeSpan tod)) UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime(); if (!UltimaLeitura.EnuOriginSet && UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.RTKFixo && !double.IsNaN(latitude) && !double.IsNaN(longitude)) { UltimaLeitura.Lat0 = latitude; UltimaLeitura.Lon0 = longitude; UltimaLeitura.EnuOriginSet = true; } if (UltimaLeitura.EnuOriginSet && !double.IsNaN(latitude) && !double.IsNaN(longitude)) { (double latCor, double lonCor) = LeverArm.FixLeverArmLatLon_Fast( latitude, longitude, UltimaLeitura.OrientacaoReal, UltimaLeitura.TimestampOri.frequencia); UltimaLeitura.Latitude = latCor; UltimaLeitura.Longitude = lonCor; } } } private void ProcessarGNVTG(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 8) throw new FormatException("VTG incompleta."); double curso = ParseDouble(campos[1], 0); double velocidadeKmh = ParseDouble(campos[7].Split('*')[0], 0); lock (_modelLock) { UltimaLeitura.Momento = DateTime.Now; UltimaLeitura.CursoVerdadeiro = curso; UltimaLeitura.Velocidade = velocidadeKmh; } } private void ProcessarGSV(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 4 || sentenca.Length < 3) throw new FormatException("GSV incompleta."); string sistema = sentenca.Substring(1, 2); int total = ParseInt(campos[1], 0); int atual = ParseInt(campos[2], 0); int visiveis = ParseInt(campos[3], 0); lock (_modelLock) { UltimaLeitura.Momento = DateTime.Now; var leitura = UltimaLeitura.SatelitesEmVista .FirstOrDefault(x => x.TipoSistema == sistema); if (leitura == null) { leitura = new GPSSatelitesEmVistaModel { TipoSistema = sistema, Sentencas = new List(), }; UltimaLeitura.SatelitesEmVista.Add(leitura); } var item = leitura.Sentencas.FirstOrDefault(x => x.SentencaAtual == atual); if (item == null) { item = new GPSSatelitesEmVistaSentencaModel { SentencaAtual = atual, SentencasTotal = total, QuantidadeSatelites = visiveis, Dados = new List(), }; leitura.Sentencas.Add(item); } item.SentencasTotal = total; item.QuantidadeSatelites = visiveis; item.Dados = new List(); for (int i = 4; i + 3 < campos.Length; i += 4) { item.Dados.Add(new GPSSatelitesEmVistaDadosModel { PRN = campos[i], Elevacao = ParseDouble(campos[i + 1], 0), Azimute = ParseDouble(campos[i + 2], 0), QualidadeSinal = ParseDouble(campos[i + 3].Split('*')[0], 0), }); } } } private void ProcessarGNTHS(string sentenca) { string[] campos = sentenca.TrimStart('$').Split(','); if (campos.Length < 3) throw new FormatException("THS incompleta."); string status = campos[2].Split('*')[0]; bool headingValido = double.TryParse( campos[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double heading); lock (_modelLock) { UltimaLeitura.TimestampOri.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; UltimaLeitura.Momento = DateTime.Now; UltimaLeitura.TipoOrientacao = status; if (headingValido) { UltimaLeitura.OrientacaoReal = InverterHeading ? GPSUtils.NormalizarAngulo(heading - 180.0) : heading; } else { // Mantém o último heading numérico, mas atualiza o status V/A. // Assim a saúde consegue distinguir dado inválido de ausência de sentença. } } } private void ProcessarGNGLL(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 7) throw new FormatException("GLL incompleta."); string mode = campos.Length > 7 ? campos[7].Split('*')[0] : string.Empty; bool valido = campos[6] == "A" && mode != "N"; lock (_modelLock) { if (valido) { double lat = ParseDmm(campos[1], campos[2], 2); double lon = ParseDmm(campos[3], campos[4], 3); if (!double.IsNaN(lat)) UltimaLeitura.Latitude = lat; if (!double.IsNaN(lon)) UltimaLeitura.Longitude = lon; } if (TryParseNmeaTime(campos[5], out TimeSpan tod)) UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime(); ApplyModeToFixQuality(mode); } AtualizarCoordenadasGPS(); } private void ProcessarGxGSA(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 17) throw new FormatException("GSA incompleta."); int satsUsados = 0; for (int i = 3; i <= 14 && i < campos.Length; i++) if (!string.IsNullOrWhiteSpace(campos[i])) satsUsados++; double pdop = ParseDouble(campos[15], double.NaN); double hdop = ParseDouble(campos[16], double.NaN); double vdop = campos.Length > 17 ? ParseDouble(campos[17].Split('*')[0], double.NaN) : double.NaN; var fixDim = (TiposDimensaoCorrecaoGPS)ParseInt(campos[2], 1); lock (_modelLock) { if (!double.IsNaN(hdop)) UltimaLeitura.PrecisaoHorizontal = hdop; if (satsUsados > 0) UltimaLeitura.NumeroSatelites = Math.Max(UltimaLeitura.NumeroSatelites, satsUsados); if (!double.IsNaN(pdop)) UltimaLeitura.PDOP = pdop; if (!double.IsNaN(vdop)) UltimaLeitura.VDOP = vdop; UltimaLeitura.FixDimensao = fixDim; } } private void ProcessarGxRMC(string sentenca) { string[] campos = sentenca.Split(','); if (campos.Length < 12) throw new FormatException("RMC incompleta."); string mode = string.Empty; if (campos.Length > 12) mode = campos[12].Split('*')[0].Trim(); bool valido = campos[2] == "A"; lock (_modelLock) { if (valido) { double lat = ParseDmm(campos[3], campos[4], 2); double lon = ParseDmm(campos[5], campos[6], 3); if (!double.IsNaN(lat)) UltimaLeitura.Latitude = lat; if (!double.IsNaN(lon)) UltimaLeitura.Longitude = lon; } if (double.TryParse(campos[7], NumberStyles.Float, CultureInfo.InvariantCulture, out double speedKnots)) UltimaLeitura.Velocidade = speedKnots * 1.852; // km/h if (double.TryParse(campos[8], NumberStyles.Float, CultureInfo.InvariantCulture, out double course)) UltimaLeitura.CursoVerdadeiro = course; if (TryParseRmcDateTime(campos[1], campos[9], out DateTime dt)) UltimaLeitura.DataHora = dt.ToLocalTime(); if (double.TryParse(campos[10], NumberStyles.Float, CultureInfo.InvariantCulture, out double magVar)) { if (campos[11].StartsWith("W", StringComparison.OrdinalIgnoreCase)) magVar = -magVar; UltimaLeitura.VariacaoMagnetica = magVar; } ApplyModeToFixQuality(mode); } AtualizarCoordenadasGPS(); } private void ApplyModeToFixQuality(string mode) { if (string.IsNullOrWhiteSpace(mode)) return; UltimaLeitura.QualidadeFix = mode switch { "R" => TiposCorrecaoGPS.RTKFixo, "F" => TiposCorrecaoGPS.RTKFlutuante, "D" => TiposCorrecaoGPS.DGPS, "E" => TiposCorrecaoGPS.DeadReckoing, "A" => TiposCorrecaoGPS.Autonomo, "N" => TiposCorrecaoGPS.SemCorrecao, _ => UltimaLeitura.QualidadeFix, }; } // ========================================================= // NTRIP: FAILOVER DE PROVEDOR + AUTOSELEÇÃO DE MOUNTPOINT // ========================================================= private CancellationTokenSource _ntripCts; private Task _ntripTask; private TcpClient _ntripClient; private bool _ntripParaFixacaoBase; private readonly object _ntripSourcetableLock = new(); private readonly Dictionary _ntripSourcetableCache = new(StringComparer.OrdinalIgnoreCase); private readonly object _ntripCooldownLock = new(); private readonly Dictionary _ntripCooldownAteUtc = new(StringComparer.OrdinalIgnoreCase); private Task StartNtripAsync(bool paraFixacaoBase = false) { lock (_ntripLock) { if (_ntripTask != null && !_ntripTask.IsCompleted) return Task.CompletedTask; CorrecaoRTK_Ntrip = true; _ntripParaFixacaoBase = paraFixacaoBase; _ntripCts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token); _ntripTask = Task.Run( () => AplicarCorrecaoRTK_NtripAsync(_ntripCts.Token), _ntripCts.Token); } return Task.CompletedTask; } private async Task StopNtripAsync() { Task task; TcpClient client; lock (_ntripLock) { CorrecaoRTK_Ntrip = false; _ntripCts?.Cancel(); client = _ntripClient; task = _ntripTask; } try { client?.Close(); } catch { } if (task != null) { try { await task.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } } lock (_ntripLock) { _ntripClient = null; _ntripTask = null; _ntripCts?.Dispose(); _ntripCts = null; _ntripParaFixacaoBase = false; Ntrip_Conectado = false; LimparNtripAtivo(); } } private void LimparNtripAtivo() { NtripProviderAtivo = string.Empty; NtripMountpointAtivo = string.Empty; NtripDistanciaMountpointAtivoKm = null; } private List ObterSequenciaProvedoresNtrip() { NtripProviderConfig ibge = new() { Nome = "IBGE", Host = NtripHost, Port = NtripPort, MountpointManual = NtripMountpoint, Username = NtripUsername, Password = NtripPassword, UsarNtripV2 = false, Comunitario = false, ExigeUsuario = true, ExigeSenha = true, FiltroPais = "BRA", PermitirFallbackManualSeAutoFalhar = true, }; NtripProviderConfig rtk2go = new() { Nome = "RTK2go", Host = NtripRtk2goHost, Port = NtripRtk2goPort, MountpointManual = NtripRtk2goMountpoint, Username = NtripRtk2goUsername, Password = NtripRtk2goPassword, UsarNtripV2 = false, Comunitario = true, ExigeUsuario = true, ExigeSenha = false, FiltroPais = "BRA", // Para RTK2go não usamos SPLI1 silenciosamente se a descoberta falhar. // O mountpoint manual continua disponível quando AutoSelecionar=false. PermitirFallbackManualSeAutoFalhar = false, }; List providers = NtripMode switch { NtripProviderMode.Rtk2goOnly => new List { rtk2go }, NtripProviderMode.IbgeThenRtk2go => new List { ibge, rtk2go }, _ => new List { ibge }, }; if (_ntripParaFixacaoBase && !NtripRtk2goPermitidoParaFixacaoBase) { int removidos = providers.RemoveAll(x => x.Comunitario); if (removidos > 0) { Models.Variaveis.MostrarLog( "[NTRIP][SEGURANÇA] RTK2go removido da fixação absoluta. " + "Para liberar, defina NtripRtk2goPermitidoParaFixacaoBase=true."); } } List validos = new(); foreach (NtripProviderConfig provider in providers) { if (!ConfiguracaoNtripEstruturalValida(provider)) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider?.Nome ?? "?"}] Configuração inválida. " + "Verifique host e porta."); continue; } if (provider.ExigeUsuario && string.IsNullOrWhiteSpace(provider.Username)) { string dica = provider.Nome == "RTK2go" ? "Defina AGRO_RTK2GO_EMAIL ou NtripRtk2goUsername." : "Defina AGRO_NTRIP_USERNAME ou NtripUsername."; Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}] Usuário não configurado. {dica}"); continue; } if (provider.ExigeSenha && string.IsNullOrWhiteSpace(provider.Password)) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}] Senha não configurada. " + "Defina AGRO_NTRIP_PASSWORD ou NtripPassword."); continue; } validos.Add(provider); } return validos; } private static bool ConfiguracaoNtripEstruturalValida(NtripProviderConfig provider) { return provider != null && !string.IsNullOrWhiteSpace(provider.Host) && provider.Port > 0; } private async Task AplicarCorrecaoRTK_NtripAsync(CancellationToken ct) { if (!IsConnected) return; double backoffSeconds = 1.0; while (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip) { List providers = ObterSequenciaProvedoresNtrip(); if (providers.Count == 0) { Ntrip_Conectado = false; LimparNtripAtivo(); Models.Variaveis.MostrarLog( "[NTRIP] Nenhum provedor possui configuração válida."); await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); continue; } bool algumaSessaoFoiEstabelecida = false; foreach (NtripProviderConfig provider in providers) { if (ct.IsCancellationRequested || !CorrecaoRTK_Ntrip) break; List candidatos; try { candidatos = await ObterCandidatosNtripAsync(provider, ct) .ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) { Interlocked.Increment(ref _ntripErrors); Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Falha preparando candidatos: {ex.Message}"); continue; } if (candidatos.Count == 0) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}] Nenhum mountpoint elegível."); continue; } for (int i = 0; i < candidatos.Count; i++) { if (ct.IsCancellationRequested || !CorrecaoRTK_Ntrip) break; NtripCandidate candidato = candidatos[i]; if (TryObterCooldownRestante( provider.Nome, candidato.Mountpoint, out TimeSpan cooldownRestante)) { Models.Variaveis.MostrarLog( $"[NTRIP][COOLDOWN] {provider.Nome}/{candidato.Mountpoint} " + $"ignorado por mais {Math.Ceiling(cooldownRestante.TotalSeconds):F0}s."); continue; } try { string distanciaTexto = candidato.DistanciaKm.HasValue ? $" | {candidato.DistanciaKm.Value:F1} km" : string.Empty; Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}] Tentando candidato {i + 1}/{candidatos.Count}: " + $"{candidato.Mountpoint}{distanciaTexto}..."); await ExecutarSessaoNtripAsync(provider, candidato, ct) .ConfigureAwait(false); algumaSessaoFoiEstabelecida = true; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) { Ntrip_Conectado = false; LimparNtripAtivo(); Interlocked.Increment(ref _ntripErrors); Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}/{candidato.Mountpoint}] Falha: {ex.Message}"); MarcarMountpointEmCooldown( provider.Nome, candidato.Mountpoint, ex); if (i + 1 < candidatos.Count) { Models.Variaveis.MostrarLog( $"[NTRIP][FALLBACK] Tentando próximo mountpoint de {provider.Nome}: " + $"{candidatos[i + 1].Mountpoint}."); } } } if (!Ntrip_Conectado && !ct.IsCancellationRequested && CorrecaoRTK_Ntrip) { Models.Variaveis.MostrarLog( $"[NTRIP][FALLBACK] Candidatos de {provider.Nome} esgotados."); } } if (ct.IsCancellationRequested || !CorrecaoRTK_Ntrip) break; Interlocked.Increment(ref _ntripReconnects); double jitter = ((uint)Environment.TickCount % 500) / 1000.0; if (algumaSessaoFoiEstabelecida) backoffSeconds = 1.0; await Task.Delay( TimeSpan.FromSeconds(backoffSeconds + jitter), ct).ConfigureAwait(false); backoffSeconds = Math.Min(20.0, backoffSeconds * 1.8); } Ntrip_Conectado = false; LimparNtripAtivo(); } private async Task> ObterCandidatosNtripAsync( NtripProviderConfig provider, CancellationToken ct) { if (!NtripAutoSelecionarMountpoint) { if (string.IsNullOrWhiteSpace(provider.MountpointManual)) return new List(); return new List { new() { Mountpoint = provider.MountpointManual.Trim(), DistanciaKm = null, Formato = string.Empty, SistemasGnss = string.Empty, OrigemAuto = false, } }; } NtripApproxPosition pos = await AguardarPosicaoParaSelecaoNtripAsync(ct) .ConfigureAwait(false); if (pos == null) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Sem posição GGA recente para ordenar estações."); return CriarFallbackManualSePermitido(provider); } Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Posição aproximada: " + $"lat={pos.Latitude:F6}, lon={pos.Longitude:F6}."); List streams; try { streams = await ObterSourcetableAsync(provider, ct).ConfigureAwait(false); } catch (Exception ex) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Sourcetable indisponível: {ex.Message}"); return CriarFallbackManualSePermitido(provider); } double maxKm = _ntripParaFixacaoBase ? Math.Max(1.0, NtripDistanciaMaximaFixacaoBaseKm) : Math.Max(1.0, NtripDistanciaMaximaKm); List ranqueados = streams .Where(x => CoordenadaValida(x.Latitude, x.Longitude)) .Where(x => string.IsNullOrWhiteSpace(provider.FiltroPais) || string.Equals( x.Pais, provider.FiltroPais, StringComparison.OrdinalIgnoreCase)) .Where(x => FormatoRtcm3Compativel(x.Formato)) .Where(x => !x.ExigeGgaCliente) .Select(x => { x.DistanciaKm = HaversineKm( pos.Latitude, pos.Longitude, x.Latitude, x.Longitude); return x; }) .OrderBy(x => x.DistanciaKm) .ToList(); if (ranqueados.Count == 0) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Nenhuma estação RTCM 3.x " + "com coordenada válida foi encontrada."); return CriarFallbackManualSePermitido(provider); } NtripMountpointInfo maisProxima = ranqueados[0]; if (maisProxima.DistanciaKm > maxKm) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Estação mais próxima é " + $"{maisProxima.Mountpoint} a {maisProxima.DistanciaKm:F1} km, " + $"acima do limite de {maxKm:F1} km."); // Em seleção automática nunca furamos o limite por um fallback manual silencioso. return new List(); } int maxCandidates = Math.Max(1, NtripMaxCandidatosPorProvedor); List dentroDoLimite = ranqueados .Where(x => x.DistanciaKm <= maxKm) .ToList(); List elegiveis = new(maxCandidates); foreach (NtripMountpointInfo item in dentroDoLimite) { if (TryObterCooldownRestante( provider.Nome, item.Mountpoint, out TimeSpan restante)) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO][COOLDOWN] " + $"{item.Mountpoint} ({item.DistanciaKm:F1} km) ignorado por " + $"mais {Math.Ceiling(restante.TotalSeconds):F0}s."); continue; } elegiveis.Add(item); if (elegiveis.Count >= maxCandidates) break; } if (elegiveis.Count == 0) { Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Todas as estações dentro de " + $"{maxKm:F1} km estão temporariamente em cooldown."); return new List(); } string ranking = string.Join( " | ", elegiveis.Select( (x, i) => $"{i + 1}.{x.Mountpoint} {x.DistanciaKm:F1}km")); Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Candidatos: {ranking}"); return elegiveis .Select(x => new NtripCandidate { Mountpoint = x.Mountpoint, DistanciaKm = x.DistanciaKm, Formato = x.Formato, SistemasGnss = x.SistemasGnss, OrigemAuto = true, }) .ToList(); } private static string CriarChaveCooldownNtrip( string providerNome, string mountpoint) { return $"{providerNome?.Trim()}::{mountpoint?.Trim()}"; } private bool TryObterCooldownRestante( string providerNome, string mountpoint, out TimeSpan restante) { restante = TimeSpan.Zero; if (NtripCooldownMountpointSegundos <= 0 || string.IsNullOrWhiteSpace(providerNome) || string.IsNullOrWhiteSpace(mountpoint)) { return false; } string chave = CriarChaveCooldownNtrip( providerNome, mountpoint); lock (_ntripCooldownLock) { if (!_ntripCooldownAteUtc.TryGetValue( chave, out DateTime ateUtc)) { return false; } DateTime agora = DateTime.UtcNow; if (ateUtc <= agora) { _ntripCooldownAteUtc.Remove(chave); return false; } restante = ateUtc - agora; return true; } } private void MarcarMountpointEmCooldown( string providerNome, string mountpoint, Exception motivo = null) { int segundos = Math.Max( 0, NtripCooldownMountpointSegundos); if (segundos <= 0 || string.IsNullOrWhiteSpace(providerNome) || string.IsNullOrWhiteSpace(mountpoint)) { return; } DateTime ateUtc = DateTime.UtcNow.AddSeconds(segundos); string chave = CriarChaveCooldownNtrip( providerNome, mountpoint); lock (_ntripCooldownLock) { _ntripCooldownAteUtc[chave] = ateUtc; } string motivoTexto = motivo == null ? string.Empty : $" | motivo={motivo.GetType().Name}"; Models.Variaveis.MostrarLog( $"[NTRIP][COOLDOWN] {providerNome}/{mountpoint} suspenso por " + $"{segundos}s{motivoTexto}."); } private List CriarFallbackManualSePermitido( NtripProviderConfig provider) { if (!provider.PermitirFallbackManualSeAutoFalhar || string.IsNullOrWhiteSpace(provider.MountpointManual)) { return new List(); } Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Usando mountpoint manual " + $"{provider.MountpointManual} apenas como fallback de descoberta."); return new List { new() { Mountpoint = provider.MountpointManual.Trim(), DistanciaKm = null, Formato = string.Empty, SistemasGnss = string.Empty, OrigemAuto = false, } }; } private async Task AguardarPosicaoParaSelecaoNtripAsync( CancellationToken ct) { // Se já existe um GGA suficientemente recente, não introduz atraso. NtripApproxPosition existente = TryGetApproxPositionForNtrip(); if (existente != null) return existente; int esperaSegundos = Math.Max( 3, NtripEsperaPosicaoAutoSegundos); DateTime inicioUtc = DateTime.UtcNow; DateTime deadlineUtc = inicioUtc.AddSeconds(esperaSegundos); DateTime proximoReforcoUtc = DateTime.MinValue; Models.Variaveis.MostrarLog( $"[NTRIP][AUTO] Aguardando posição GGA válida por até " + $"{esperaSegundos}s antes de selecionar mountpoints."); while (!ct.IsCancellationRequested && DateTime.UtcNow <= deadlineUtc) { NtripApproxPosition pos = TryGetApproxPositionForNtrip(); if (pos != null) { double decorrido = Math.Max( 0, (DateTime.UtcNow - inicioUtc).TotalSeconds); Models.Variaveis.MostrarLog( $"[NTRIP][AUTO] Posição GGA disponível após " + $"{decorrido:F1}s."); return pos; } /* * O UM982 acabou de ser colocado em modo rover temporário * antes desta etapa. Em alguns boots/reconfigurações o primeiro * GGA pode demorar ou o comando anterior pode não ter produzido * saída imediatamente. * * Como ainda NÃO existe sessão NTRIP injetando RTCM neste ponto, * é seguro reforçar periodicamente apenas o comando de saída GGA. */ if (DateTime.UtcNow >= proximoReforcoUtc) { string porta = string.IsNullOrWhiteSpace( NtripPortaGgaAutoSelecao) ? "com2" : NtripPortaGgaAutoSelecao.Trim(); try { await WriteSerialCommandAsync( $"gngga {porta} 1\r\n", ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { // Não aborta a descoberta por uma falha pontual de escrita. // A reconexão serial / fallback existente continua responsável // pela recuperação do transporte. Models.Variaveis.MostrarLog( $"[NTRIP][AUTO] Não foi possível reforçar GGA em " + $"{porta}: {ex.Message}"); } // Evita martelar o receptor enquanto esperamos a primeira leitura. proximoReforcoUtc = DateTime.UtcNow.AddSeconds(3); } await Task.Delay(200, ct).ConfigureAwait(false); } Models.Variaveis.MostrarLog( $"[NTRIP][AUTO] Nenhuma posição GGA válida recebida em " + $"{esperaSegundos}s. A descoberta automática usará o fallback seguro."); return null; } private NtripApproxPosition TryGetApproxPositionForNtrip() { double now = MonotonicNow(); double ggaAgeMs = AgeMs(now, _lastValidGgaMono); /* * Para escolher uma estação, não precisamos de precisão RTK. * Uma posição GGA autônoma com até 30 s é mais que suficiente * para distinguir uma base a 10 km de outra a 200 km. */ if (ggaAgeMs < 0 || ggaAgeMs > 30_000) return null; lock (_modelLock) { double lat = UltimaLeitura.Latitude; double lon = UltimaLeitura.Longitude; if (!CoordenadaValida(lat, lon)) return null; return new NtripApproxPosition { Latitude = lat, Longitude = lon, }; } } private async Task> ObterSourcetableAsync( NtripProviderConfig provider, CancellationToken ct) { string cacheKey = $"{provider.Host}:{provider.Port}"; int cacheSeconds = Math.Max(30, NtripSourcetableCacheSegundos); lock (_ntripSourcetableLock) { if (_ntripSourcetableCache.TryGetValue( cacheKey, out NtripSourcetableCacheEntry cached) && (DateTime.UtcNow - cached.ObtidaUtc).TotalSeconds <= cacheSeconds) { return cached.Streams .Select(CloneMountpointInfo) .ToList(); } } List streams = await BaixarSourcetableAsync(provider, ct) .ConfigureAwait(false); lock (_ntripSourcetableLock) { _ntripSourcetableCache[cacheKey] = new NtripSourcetableCacheEntry { ObtidaUtc = DateTime.UtcNow, Streams = streams .Select(CloneMountpointInfo) .ToList(), }; } Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}][AUTO] Sourcetable carregada: " + $"{streams.Count} streams."); return streams; } private static NtripMountpointInfo CloneMountpointInfo(NtripMountpointInfo x) { return new NtripMountpointInfo { Mountpoint = x.Mountpoint, Identificador = x.Identificador, Formato = x.Formato, SistemasGnss = x.SistemasGnss, Rede = x.Rede, Pais = x.Pais, Latitude = x.Latitude, Longitude = x.Longitude, ExigeGgaCliente = x.ExigeGgaCliente, Autenticacao = x.Autenticacao, TaxaBits = x.TaxaBits, DistanciaKm = x.DistanciaKm, }; } private async Task> BaixarSourcetableAsync( NtripProviderConfig provider, CancellationToken ct) { using TcpClient client = new(); await ConnectTcpWithTimeoutAsync( client, provider.Host, provider.Port, TimeSpan.FromSeconds(8), ct).ConfigureAwait(false); using NetworkStream stream = client.GetStream(); string credentials = string.IsNullOrWhiteSpace(provider.Username) ? string.Empty : Convert.ToBase64String( Encoding.UTF8.GetBytes( $"{provider.Username}:{provider.Password ?? string.Empty}")); string request = "GET / HTTP/1.1\r\n" + $"Host: {provider.Host}:{provider.Port}\r\n" + "Ntrip-Version: Ntrip/2.0\r\n" + "User-Agent: NTRIP AgroBase/2.1\r\n" + "Accept: */*\r\n" + (!string.IsNullOrEmpty(credentials) ? $"Authorization: Basic {credentials}\r\n" : string.Empty) + "Connection: close\r\n" + "\r\n"; byte[] requestBytes = Encoding.ASCII.GetBytes(request); await stream.WriteAsync( requestBytes, 0, requestBytes.Length, ct).ConfigureAwait(false); await stream.FlushAsync(ct).ConfigureAwait(false); (string header, byte[] firstBodyBytes) = await ReadHttpHeaderAsync(stream, 32 * 1024, ct).ConfigureAwait(false); string firstLine = header .Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) .FirstOrDefault() ?? string.Empty; bool accepted = firstLine.Contains("200 OK", StringComparison.OrdinalIgnoreCase) || firstLine.StartsWith("SOURCETABLE 200", StringComparison.OrdinalIgnoreCase); if (!accepted) throw new IOException( $"sourcetable recusada: {firstLine}"); const int maxBodyBytes = 2 * 1024 * 1024; List body = new(firstBodyBytes.Length + 64 * 1024); body.AddRange(firstBodyBytes); byte[] temp = new byte[8192]; while (body.Count < maxBodyBytes) { int read = await ReadNetworkWithTimeoutAsync( stream, temp, TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); if (read <= 0) break; int canTake = Math.Min(read, maxBodyBytes - body.Count); for (int i = 0; i < canTake; i++) body.Add(temp[i]); string tail = Encoding.ASCII.GetString( body.ToArray(), Math.Max(0, body.Count - Math.Min(body.Count, 256)), Math.Min(body.Count, 256)); if (tail.Contains("ENDSOURCETABLE", StringComparison.OrdinalIgnoreCase)) break; } byte[] payload = body.ToArray(); if (HeaderTemTransferEncodingChunked(header)) payload = DecodeChunkedHttpBody(payload); string sourcetable = Encoding.UTF8.GetString(payload); List parsed = ParseNtripSourcetable(sourcetable); if (parsed.Count == 0) throw new InvalidDataException( "sourcetable não contém registros STR utilizáveis."); return parsed; } private static List ParseNtripSourcetable(string text) { List result = new(); if (string.IsNullOrWhiteSpace(text)) return result; string[] lines = text.Replace("\r\n", "\n").Split('\n'); foreach (string raw in lines) { string line = raw.Trim(); if (!line.StartsWith("STR;", StringComparison.OrdinalIgnoreCase)) continue; string[] p = line.Split(';'); // STR;mount;identifier;format;details;carrier;nav;network;country; // lat;lon;nmea;solution;generator;compression;auth;fee;bitrate;misc if (p.Length < 18) continue; if (!double.TryParse( p[9], NumberStyles.Float, CultureInfo.InvariantCulture, out double lat) || !double.TryParse( p[10], NumberStyles.Float, CultureInfo.InvariantCulture, out double lon)) { continue; } bool exigeGga = p.Length > 11 && (p[11].Trim() == "1" || p[11].Trim().Equals("Y", StringComparison.OrdinalIgnoreCase)); int bitrate = 0; if (p.Length > 17) { int.TryParse( p[17].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out bitrate); } result.Add(new NtripMountpointInfo { Mountpoint = p[1].Trim(), Identificador = p[2].Trim(), Formato = p[3].Trim(), SistemasGnss = p.Length > 6 ? p[6].Trim() : string.Empty, Rede = p.Length > 7 ? p[7].Trim() : string.Empty, Pais = p.Length > 8 ? p[8].Trim() : string.Empty, Latitude = lat, Longitude = lon, ExigeGgaCliente = exigeGga, Autenticacao = p.Length > 15 ? p[15].Trim() : string.Empty, TaxaBits = bitrate, }); } return result; } private static bool FormatoRtcm3Compativel(string format) { if (string.IsNullOrWhiteSpace(format)) return false; return format.IndexOf( "RTCM 3", StringComparison.OrdinalIgnoreCase) >= 0 || format.IndexOf( "RTCM3", StringComparison.OrdinalIgnoreCase) >= 0; } private static bool CoordenadaValida(double lat, double lon) { return !double.IsNaN(lat) && !double.IsInfinity(lat) && !double.IsNaN(lon) && !double.IsInfinity(lon) && lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0 && (Math.Abs(lat) > 1e-9 || Math.Abs(lon) > 1e-9); } private static double HaversineKm( double lat1, double lon1, double lat2, double lon2) { const double earthRadiusKm = 6371.0088; double p1 = lat1 * Math.PI / 180.0; double p2 = lat2 * Math.PI / 180.0; double dLat = (lat2 - lat1) * Math.PI / 180.0; double dLon = (lon2 - lon1) * Math.PI / 180.0; double a = Math.Sin(dLat / 2.0) * Math.Sin(dLat / 2.0) + Math.Cos(p1) * Math.Cos(p2) * Math.Sin(dLon / 2.0) * Math.Sin(dLon / 2.0); return 2.0 * earthRadiusKm * Math.Asin(Math.Sqrt(a)); } private async Task ExecutarSessaoNtripAsync( NtripProviderConfig provider, NtripCandidate candidato, CancellationToken ct) { if (provider == null) throw new ArgumentNullException(nameof(provider)); if (candidato == null || string.IsNullOrWhiteSpace(candidato.Mountpoint)) { throw new ArgumentException( "Candidato NTRIP inválido.", nameof(candidato)); } if (provider.ExigeUsuario && string.IsNullOrWhiteSpace(provider.Username)) throw new InvalidOperationException( $"{provider.Nome}: usuário NTRIP não configurado."); if (provider.ExigeSenha && string.IsNullOrWhiteSpace(provider.Password)) throw new InvalidOperationException( $"{provider.Nome}: senha NTRIP não configurada."); TcpClient client = null; try { client = new TcpClient(); lock (_ntripLock) _ntripClient = client; await ConnectTcpWithTimeoutAsync( client, provider.Host, provider.Port, TimeSpan.FromSeconds(8), ct).ConfigureAwait(false); using NetworkStream stream = client.GetStream(); string credentials = string.IsNullOrWhiteSpace(provider.Username) ? string.Empty : Convert.ToBase64String( Encoding.UTF8.GetBytes( $"{provider.Username}:{provider.Password ?? string.Empty}")); string request; if (provider.UsarNtripV2) { request = $"GET /{candidato.Mountpoint} HTTP/1.1\r\n" + $"Host: {provider.Host}:{provider.Port}\r\n" + "Ntrip-Version: Ntrip/2.0\r\n" + "User-Agent: NTRIP AgroBase/2.1\r\n" + "Accept: */*\r\n" + (!string.IsNullOrEmpty(credentials) ? $"Authorization: Basic {credentials}\r\n" : string.Empty) + "Connection: close\r\n" + "\r\n"; } else { request = $"GET /{candidato.Mountpoint} HTTP/1.0\r\n" + "User-Agent: NTRIP AgroBase/1.0\r\n" + "Accept: */*\r\n" + (!string.IsNullOrEmpty(credentials) ? $"Authorization: Basic {credentials}\r\n" : string.Empty) + "Connection: close\r\n" + "\r\n"; } byte[] requestBytes = Encoding.ASCII.GetBytes(request); await stream.WriteAsync( requestBytes, 0, requestBytes.Length, ct).ConfigureAwait(false); await stream.FlushAsync(ct).ConfigureAwait(false); (string header, byte[] firstBodyBytes) = await ReadHttpHeaderAsync( stream, 16 * 1024, ct).ConfigureAwait(false); string firstLine = header .Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) .FirstOrDefault() ?? string.Empty; bool accepted = firstLine.Contains("200 OK", StringComparison.OrdinalIgnoreCase) || firstLine.StartsWith("ICY 200", StringComparison.OrdinalIgnoreCase); if (!accepted) throw new IOException( $"caster recusou conexão: {firstLine}"); if (HeaderTemTransferEncodingChunked(header)) { throw new NotSupportedException( "caster respondeu com Transfer-Encoding: chunked. " + "O dechunking do stream binário NTRIP ainda não está habilitado."); } Ntrip_Conectado = true; NtripProviderAtivo = provider.Nome; NtripMountpointAtivo = candidato.Mountpoint; NtripDistanciaMountpointAtivoKm = candidato.DistanciaKm; string distanciaTexto = candidato.DistanciaKm.HasValue ? $" | distância={candidato.DistanciaKm.Value:F1} km" : string.Empty; Models.Variaveis.MostrarLog( $"[NTRIP][{provider.Nome}] Conexão estabelecida em " + $"{provider.Host}:{provider.Port}/{candidato.Mountpoint}" + $"{distanciaTexto}."); if (provider.Comunitario) { Models.Variaveis.MostrarLog( "[NTRIP][RTK2go][AVISO] Fonte comunitária ativa. " + "A seleção por distância não valida datum, coordenada publicada " + "nem qualidade geodésica da estação."); } bool recebeuDados = false; if (firstBodyBytes.Length > 0) { await WriteSerialAsync(firstBodyBytes, ct).ConfigureAwait(false); Interlocked.Add(ref _ntripBytesReceived, firstBodyBytes.Length); recebeuDados = true; } byte[] buffer = new byte[4096]; while (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip) { int read; if (!recebeuDados) { read = await ReadNetworkWithTimeoutAsync( stream, buffer, TimeSpan.FromSeconds( Math.Max(2, NtripPrimeirosDadosTimeoutSegundos)), ct).ConfigureAwait(false); if (read <= 0) { throw new TimeoutException( "sessão foi aceita, mas nenhum byte RTCM chegou no prazo."); } } else { read = await stream.ReadAsync( buffer, 0, buffer.Length, ct).ConfigureAwait(false); } if (read <= 0) throw new IOException( "caster encerrou a conexão."); byte[] serialChunk = new byte[read]; Buffer.BlockCopy(buffer, 0, serialChunk, 0, read); await WriteSerialAsync(serialChunk, ct).ConfigureAwait(false); Interlocked.Add(ref _ntripBytesReceived, read); recebeuDados = true; } } finally { Ntrip_Conectado = false; LimparNtripAtivo(); try { client?.Close(); } catch { } lock (_ntripLock) { if (ReferenceEquals(_ntripClient, client)) _ntripClient = null; } } } private static async Task ReadNetworkWithTimeoutAsync( NetworkStream stream, byte[] buffer, TimeSpan timeout, CancellationToken ct) { using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(timeout); try { return await stream.ReadAsync( buffer.AsMemory(0, buffer.Length), linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return 0; } } private static bool HeaderTemTransferEncodingChunked(string header) { if (string.IsNullOrWhiteSpace(header)) return false; string[] lines = header.Split( new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { int idx = line.IndexOf(':'); if (idx <= 0) continue; string key = line.Substring(0, idx).Trim(); string value = line.Substring(idx + 1).Trim(); if (key.Equals( "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) && value.IndexOf( "chunked", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private static byte[] DecodeChunkedHttpBody(byte[] raw) { if (raw == null || raw.Length == 0) return Array.Empty(); List output = new(); int pos = 0; while (pos < raw.Length) { int lineEnd = IndexOfCrlf(raw, pos); if (lineEnd < 0) throw new InvalidDataException( "Chunked HTTP inválido: tamanho do chunk incompleto."); string sizeLine = Encoding.ASCII .GetString(raw, pos, lineEnd - pos) .Trim(); int semicolon = sizeLine.IndexOf(';'); if (semicolon >= 0) sizeLine = sizeLine.Substring(0, semicolon); if (!int.TryParse( sizeLine, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int size)) { throw new InvalidDataException( "Chunked HTTP inválido: tamanho de chunk ilegível."); } pos = lineEnd + 2; if (size == 0) break; if (size < 0 || pos + size > raw.Length) throw new InvalidDataException( "Chunked HTTP inválido: payload de chunk incompleto."); for (int i = 0; i < size; i++) output.Add(raw[pos + i]); pos += size; if (pos + 1 >= raw.Length || raw[pos] != '\r' || raw[pos + 1] != '\n') { throw new InvalidDataException( "Chunked HTTP inválido: CRLF ausente após payload."); } pos += 2; } return output.ToArray(); } private static int IndexOfCrlf(byte[] data, int start) { for (int i = Math.Max(0, start); i + 1 < data.Length; i++) { if (data[i] == '\r' && data[i + 1] == '\n') return i; } return -1; } private static async Task ConnectTcpWithTimeoutAsync( TcpClient client, string host, int port, TimeSpan timeout, CancellationToken ct) { Task connectTask = client.ConnectAsync(host, port); Task timeoutTask = Task.Delay(timeout, ct); Task completed = await Task.WhenAny(connectTask, timeoutTask).ConfigureAwait(false); if (completed != connectTask) { ct.ThrowIfCancellationRequested(); throw new TimeoutException($"Timeout conectando em {host}:{port}."); } await connectTask.ConfigureAwait(false); } private static async Task<(string Header, byte[] FirstBodyBytes)> ReadHttpHeaderAsync( NetworkStream stream, int maxHeaderBytes, CancellationToken ct) { List data = new(1024); byte[] temp = new byte[512]; int headerEnd = -1; int delimiterLength = 0; while (headerEnd < 0) { int read = await stream.ReadAsync( temp, 0, temp.Length, ct).ConfigureAwait(false); if (read <= 0) throw new IOException( "Conexão encerrada antes do cabeçalho NTRIP."); for (int i = 0; i < read; i++) data.Add(temp[i]); if (data.Count > maxHeaderBytes) throw new InvalidDataException( "Cabeçalho NTRIP excedeu o limite."); (headerEnd, delimiterLength) = FindNtripHeaderEnd(data); } byte[] all = data.ToArray(); string header = Encoding.ASCII.GetString(all, 0, headerEnd); int bodyStart = headerEnd + delimiterLength; byte[] body = bodyStart < all.Length ? all.Skip(bodyStart).ToArray() : Array.Empty(); return (header, body); } private static (int HeaderEnd, int DelimiterLength) FindNtripHeaderEnd( List data) { for (int i = 0; i <= data.Count - 4; i++) { if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') { return (i, 4); } } if (data.Count >= 3 && data[0] == (byte)'I' && data[1] == (byte)'C' && data[2] == (byte)'Y') { for (int i = 0; i <= data.Count - 2; i++) { if (data[i] == '\r' && data[i + 1] == '\n') { return (i, 2); } } } return (-1, 0); } // ========================================================= // SNAPSHOTS E UI // ========================================================= internal long GetGgaSequence() => Interlocked.Read(ref _ggaSequence); internal bool TryGetGgaSnapshot(long afterSequence, out long sequence, out GgaFix snapshot) { sequence = Interlocked.Read(ref _ggaSequence); snapshot = default; if (sequence <= afterSequence) return false; lock (_modelLock) { snapshot = new GgaFix( tsUtc: UltimaLeitura.DataHora.ToUniversalTime(), latDeg: UltimaLeitura.Latitude, lonDeg: UltimaLeitura.Longitude, altElipsoidalM: UltimaLeitura.AltitudeElipsoidal, headingDeg: UltimaLeitura.OrientacaoReal, fixQuality: UltimaLeitura.QualidadeFix); } return true; } private TiposCorrecaoGPS GetFixQualitySnapshot() { lock (_modelLock) return UltimaLeitura.QualidadeFix; } private PositionSnapshot GetPositionSnapshot() { lock (_modelLock) { return new PositionSnapshot { Latitude = UltimaLeitura.Latitude, Longitude = UltimaLeitura.Longitude, AltitudeElipsoidal = UltimaLeitura.AltitudeElipsoidal, Orientacao = UltimaLeitura.OrientacaoReal, }; } } private void AtualizarCoordenadasGPS() { lock (_modelLock) { UltimaLeitura.AnguloCarroDefinido = UltimaLeitura.OrientacaoReal; UltimaLeitura.Ntrip_ativado = CorrecaoRTK_Ntrip; UltimaLeitura.Heartbeat = (UltimaLeitura.Heartbeat + 1) % 10; } if (Interlocked.CompareExchange(ref _uiUpdatePending, 1, 0) != 0) return; Application.Current?.Dispatcher?.BeginInvoke(new Action(() => { try { Models.Variaveis.Dock?._vm?.AtualizarDadosGnss(UltimaLeitura); } catch (Exception ex) { Models.Variaveis.MostrarLog($"Erro ao atualizar UI GNSS: {ex.Message}"); } finally { Interlocked.Exchange(ref _uiUpdatePending, 0); } })); } // ========================================================= // HELPERS // ========================================================= private static double MonotonicNow() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; private static double AgeMs(double now, double timestamp) { if (timestamp <= 0) return -1; return Math.Max(0, (now - timestamp) * 1000.0); } private static int ParseInt(string text, int fallback) { return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) ? value : fallback; } private static double ParseDouble(string text, double fallback) { return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) ? value : fallback; } private static double ParseDmm(string raw, string hemisphere, int degreeDigits) { if (string.IsNullOrWhiteSpace(raw) || raw.Length <= degreeDigits) return double.NaN; if (!double.TryParse( raw.Substring(0, degreeDigits), NumberStyles.Float, CultureInfo.InvariantCulture, out double degrees) || !double.TryParse( raw.Substring(degreeDigits), NumberStyles.Float, CultureInfo.InvariantCulture, out double minutes)) { return double.NaN; } double value = degrees + minutes / 60.0; if (hemisphere.Equals("S", StringComparison.OrdinalIgnoreCase) || hemisphere.Equals("W", StringComparison.OrdinalIgnoreCase)) { value = -value; } return value; } private static bool TryParseNmeaTime(string text, out TimeSpan value) { value = default; if (string.IsNullOrWhiteSpace(text) || text.Length < 6) return false; if (!int.TryParse(text.Substring(0, 2), out int hh) || !int.TryParse(text.Substring(2, 2), out int mm) || !double.TryParse( text.Substring(4), NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds)) { return false; } int ss = (int)Math.Floor(seconds); int ms = (int)Math.Round((seconds - ss) * 1000.0); if (ms >= 1000) { ss++; ms = 0; } try { value = new TimeSpan(0, hh, mm, ss, ms); return true; } catch { return false; } } private static bool TryParseRmcDateTime( string timeText, string dateText, out DateTime utc) { utc = default; if (!TryParseNmeaTime(timeText, out TimeSpan tod) || string.IsNullOrWhiteSpace(dateText) || dateText.Length != 6) { return false; } if (!int.TryParse(dateText.Substring(0, 2), out int day) || !int.TryParse(dateText.Substring(2, 2), out int month) || !int.TryParse(dateText.Substring(4, 2), out int yy)) { return false; } try { utc = new DateTime(2000 + yy, month, day, 0, 0, 0, DateTimeKind.Utc).Add(tod); return true; } catch { return false; } } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; _tmrCheck.Stop(); _tmrCheck.Dispose(); try { StopNtripAsync().GetAwaiter().GetResult(); } catch { } _lifetimeCts.Cancel(); try { _rtcmSignal.Release(); } catch { } try { _rtcmPublisherTask.Wait(TimeSpan.FromSeconds(2)); } catch { } DisconnectInternal(); _scanGate.Dispose(); _serialWriteGate.Dispose(); _rtcmSignal.Dispose(); _lifetimeCts.Dispose(); } private enum ParserMode { Idle, Nmea, Rtcm, } private enum GnssExpectedRole { PreserveCurrentConfiguration, RoverTemporary, BaseFixed, } public enum NtripProviderMode { IbgeOnly = 0, Rtk2goOnly = 1, IbgeThenRtk2go = 2, } public enum GnssPapelEstacao { Indefinido = 0, Movel = 1, BaseFixa = 2 } public GnssPapelEstacao PapelEstacaoAtual { get { return _expectedRole switch { GnssExpectedRole.RoverTemporary => GnssPapelEstacao.Movel, GnssExpectedRole.BaseFixed => GnssPapelEstacao.BaseFixa, /* * Quando acabamos de abrir o programa, * ainda podemos estar em Preserve. * * Se o próprio UM982 estiver reportando * BASE FIX, conseguimos reconhecer que * ele está funcionando como base. */ _ when GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix => GnssPapelEstacao.BaseFixa, _ => GnssPapelEstacao.Indefinido }; } } private void DefinirPapelEsperado(GnssExpectedRole papel) { _expectedRole = papel; } private sealed class NtripProviderConfig { public string Nome { get; init; } public string Host { get; init; } public int Port { get; init; } public string MountpointManual { get; init; } public string Username { get; init; } public string Password { get; init; } public bool UsarNtripV2 { get; init; } public bool Comunitario { get; init; } public bool ExigeUsuario { get; init; } public bool ExigeSenha { get; init; } public string FiltroPais { get; init; } public bool PermitirFallbackManualSeAutoFalhar { get; init; } } private sealed class NtripCandidate { public string Mountpoint { get; init; } public double? DistanciaKm { get; init; } public string Formato { get; init; } public string SistemasGnss { get; init; } public bool OrigemAuto { get; init; } } private sealed class NtripMountpointInfo { public string Mountpoint { get; init; } public string Identificador { get; init; } public string Formato { get; init; } public string SistemasGnss { get; init; } public string Rede { get; init; } public string Pais { get; init; } public double Latitude { get; init; } public double Longitude { get; init; } public bool ExigeGgaCliente { get; init; } public string Autenticacao { get; init; } public int TaxaBits { get; init; } public double DistanciaKm { get; set; } } private sealed class NtripSourcetableCacheEntry { public DateTime ObtidaUtc { get; init; } public List Streams { get; init; } } private sealed class NtripApproxPosition { public double Latitude { get; init; } public double Longitude { get; init; } } private sealed class RtcmEnvelope { public int Type { get; init; } public byte[] Data { get; init; } public double ReceivedMono { get; init; } public long Sequence { get; init; } } private sealed class BaseFixedConfiguration { public string PortaUsb { get; init; } public string PortaSaida { get; init; } public string BaseId { get; init; } public double Latitude { get; init; } public double Longitude { get; init; } public double AltitudeElipsoidal { get; init; } } private sealed class PositionSnapshot { public double Latitude { get; init; } public double Longitude { get; init; } public double AltitudeElipsoidal { get; init; } public double Orientacao { get; init; } } } // ============================================================= // BASE FIX SERVICE // ============================================================= public class BaseFixService { public BaseFixService(SerialPort porta, GpsService service) { // O SerialPort recebido é mantido apenas por compatibilidade de assinatura. // As escritas usam sempre o GpsService para não reter uma porta antiga após reconexão. gpsService = service ?? throw new ArgumentNullException(nameof(service)); } private readonly GpsService gpsService; private long _lastGgaSequence; public List amostras_pos = new(1000); public DateTime? inicioProcesso = null; public DateTime? inicioFix = null; public DateTime? fimProcesso = null; private int segundosFixEstavel = 120; private int maxJanelaSegundos = 120; private MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip; public double Progresso { get { if (inicioFix is null) return 0; if (fimProcesso != null) return 100; return Math.Clamp( (DateTime.UtcNow - inicioFix.Value).TotalSeconds / Math.Max(1, segundosFixEstavel) * 100.0, 0, 100); } } public double ProgressoGeral { get { if (inicioProcesso is null) return 0; return Math.Clamp( (DateTime.UtcNow - inicioProcesso.Value).TotalSeconds / Math.Max(1, maxJanelaSegundos) * 100.0, 0, 100); } } public string ProgressoStr { get { if (CorrecaoEmAndamento) return $"Recebendo correção RTK via {metodo}. Progresso geral: {ProgressoGeral:F2}%, progresso da correção: {Progresso:F2}%"; if (PosicaoBaseFixada && CorrecaoAbsoluta && inicioProcesso.HasValue && fimProcesso.HasValue) return $"Correção absoluta concluída com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos"; if (PosicaoBaseFixada && !CorrecaoAbsoluta) return $"Correção relativa concluída em {segundosFixEstavel} segundos"; if (inicioProcesso.HasValue && fimProcesso.HasValue) return $"Correção absoluta falhou com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos"; return "Correção absoluta não realizada"; } } public bool PosicaoBaseFixada => gpsService.UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.BaseFix; public bool CorrecaoAbsoluta = false; public bool CorrecaoEmAndamento = false; public bool FixLiberado = false; public double LatitudeFix { get; set; } public double LongitudeFix { get; set; } public double AltitudeElpsoidalFix { get; set; } public double OrientacaoFix { get; set; } public void DefinirTempos( int segsFixEstavel, int segsJanelaSegs, MetodoFixacaoBase metodo) { segundosFixEstavel = Math.Max(1, segsFixEstavel); maxJanelaSegundos = Math.Max(segundosFixEstavel, segsJanelaSegs); this.metodo = metodo; } public void ReiniciarFix() { CorrecaoEmAndamento = false; CorrecaoAbsoluta = false; inicioFix = null; inicioProcesso = null; fimProcesso = null; FixLiberado = false; amostras_pos.Clear(); } public async Task FixarBaseViaNtripAsync( string portaUsb = "com3", string portaEntrada = "com2", string portaSaida = "com2", string baseId = "957", double madK = 3.5, Func startNtrip = null, Func stopNtrip = null, CancellationToken ct = default) { await ConfigurarComoRoverParadoAsync(portaUsb, portaEntrada, ct) .ConfigureAwait(false); if (startNtrip != null) await startNtrip().ConfigureAwait(false); try { List samples = await EsperarFixEAmostrarAsync(ct).ConfigureAwait(false); if (samples.Count < 10) { Models.Variaveis.MostrarLog( "Poucas amostras de RTK FIX coletadas. Verifique NTRIP e sinais GNSS."); CorrecaoAbsoluta = false; return false; } var (lat, lon, h, heading, count) = FiltrarEAgrupar(samples, madK); if (count < 5 || double.IsNaN(lat) || double.IsNaN(lon) || double.IsNaN(h)) { Models.Variaveis.MostrarLog("Filtro da posição da base não produziu amostras suficientes."); CorrecaoAbsoluta = false; return false; } // Para de injetar RTCM NTRIP e aguarda o socket realmente fechar // antes de enviar comandos ASCII de configuração ao UM982. if (stopNtrip != null) await stopNtrip().ConfigureAwait(false); await AplicarBaseFixAsync( portaUsb, portaSaida, baseId, lat, lon, h, ct).ConfigureAwait(false); Models.Variaveis.MostrarLog( $"[BASE/FIX] Coordenadas aplicadas (n={count}): lat={lat:0.000000000}, lon={lon:0.000000000}, h={h:0.000}, heading={heading:0.00}"); CorrecaoAbsoluta = true; return true; } finally { if (stopNtrip != null) { try { await stopNtrip().ConfigureAwait(false); } catch { } } } } public async Task ConfigurarComoRoverParadoAsync( string portaUsb, string portaEntrada, CancellationToken ct = default) { Models.Variaveis.MostrarLog("Configurando GNSS temporariamente como rover parado..."); string[] commands = { $"config {portaUsb} 115200\r\n", $"config {portaEntrada} 115200\r\n", "unlog com1\r\n", "unlog com2\r\n", "unlog com3\r\n", "mode rover uav\r\n", $"gngga {portaUsb} 1\r\n", $"gpths {portaUsb} 1\r\n", // Não salva a configuração rover temporária. }; await gpsService.SendCommandsAsync(commands, 250, ct).ConfigureAwait(false); } private async Task> EsperarFixEAmostrarAsync(CancellationToken ct) { Models.Variaveis.MostrarLog("Iniciando coleta de dados para fixação da base..."); amostras_pos = new List(1000); inicioProcesso = DateTime.UtcNow; inicioFix = null; _lastGgaSequence = gpsService.GetGgaSequence(); DateTime noFixDeadline = DateTime.UtcNow.AddMinutes(2); while (ProgressoGeral < 100 && FixLiberado) { ct.ThrowIfCancellationRequested(); if (inicioFix is null && DateTime.UtcNow >= noFixDeadline) { Models.Variaveis.MostrarLog("Timeout aguardando RTK FIX via NTRIP."); break; } GgaFix gga = await LerProximoGgaAsync(5000, ct).ConfigureAwait(false); if (gga is null) continue; bool valid = gga.FixQuality == TiposCorrecaoGPS.RTKFixo; if (!valid && inicioProcesso.HasValue && (DateTime.UtcNow - inicioProcesso.Value).TotalSeconds > 120) { valid = gga.FixQuality == TiposCorrecaoGPS.RTKFlutuante; } if (!FixLiberado || !valid) { inicioFix = null; amostras_pos.Clear(); continue; } if (inicioFix is null) { Models.Variaveis.MostrarLog("RTK válido definido. Iniciando janela de coleta estável..."); inicioFix = DateTime.UtcNow; amostras_pos.Clear(); } amostras_pos.Add(gga); if (Progresso >= 100) break; } return amostras_pos; } private async Task LerProximoGgaAsync( int timeoutMs = 5000, CancellationToken ct = default) { Stopwatch sw = Stopwatch.StartNew(); while (!ct.IsCancellationRequested && sw.ElapsedMilliseconds < timeoutMs) { if (gpsService.TryGetGgaSnapshot( _lastGgaSequence, out long sequence, out GgaFix snapshot)) { _lastGgaSequence = sequence; return snapshot; } await Task.Delay(50, ct).ConfigureAwait(false); } return null; } private (double lat, double lon, double h, double hdg, int n) FiltrarEAgrupar( List samples, double madK = 3.5) { Models.Variaveis.MostrarLog("Filtrando dados aferidos..."); if (samples == null || samples.Count == 0) return (double.NaN, double.NaN, double.NaN, double.NaN, 0); double medLat = Median(samples.Select(x => x.LatDeg)); double medLon = Median(samples.Select(x => x.LonDeg)); double medH = Median(samples.Select(x => x.AltElipsoidalM)); double[] validHeadings = samples .Select(x => x.HeadingDeg) .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) .ToArray(); bool useHeadingFilter = validHeadings.Length >= Math.Max(5, samples.Count / 2); double medHeading = useHeadingFilter ? CircularMedian(validHeadings) : double.NaN; double madLat = Math.Max(1e-12, Median(samples.Select(x => Math.Abs(x.LatDeg - medLat)))); double madLon = Math.Max(1e-12, Median(samples.Select(x => Math.Abs(x.LonDeg - medLon)))); double madH = Math.Max(1e-9, Median(samples.Select(x => Math.Abs(x.AltElipsoidalM - medH)))); double madHeading = useHeadingFilter ? Math.Max(0.01, Median(validHeadings.Select(x => AngularDistanceDegrees(x, medHeading)))) : 1.0; List filtered = samples.Where(x => Math.Abs(x.LatDeg - medLat) / madLat <= madK && Math.Abs(x.LonDeg - medLon) / madLon <= madK && Math.Abs(x.AltElipsoidalM - medH) / madH <= madK && (!useHeadingFilter || AngularDistanceDegrees(x.HeadingDeg, medHeading) / madHeading <= madK)) .ToList(); if (filtered.Count == 0) return (double.NaN, double.NaN, double.NaN, double.NaN, 0); double heading = CircularMean( filtered .Select(x => x.HeadingDeg) .Where(x => !double.IsNaN(x) && !double.IsInfinity(x))); return ( filtered.Average(x => x.LatDeg), filtered.Average(x => x.LonDeg), filtered.Average(x => x.AltElipsoidalM), heading, filtered.Count); } public async Task AplicarBaseFixAsync( string portaUsb, string portaSaida, string baseId, double latDeg, double lonDeg, double hEllipsM, CancellationToken ct = default) { Models.Variaveis.MostrarLog("Aplicando dados de correção da base..."); string lat = latDeg.ToString("0.000000000", CultureInfo.InvariantCulture); string lon = lonDeg.ToString("0.000000000", CultureInfo.InvariantCulture); string h = hEllipsM.ToString("0.000", CultureInfo.InvariantCulture); string[] commands = { "unlog com1\r\n", "unlog com2\r\n", "unlog com3\r\n", $"mode base {baseId} {lat} {lon} {h}\r\n", $"RTCM1006 {portaSaida} 10\r\n", $"RTCM1033 {portaSaida} 30\r\n", $"RTCM1074 {portaSaida} 1\r\n", $"RTCM1084 {portaSaida} 1\r\n", $"RTCM1094 {portaSaida} 1\r\n", $"RTCM1124 {portaSaida} 1\r\n", $"RTCM1230 {portaSaida} 10\r\n", $"gngga {portaUsb} 1\r\n", $"gpths {portaUsb} 1\r\n", "saveconfig\r\n", }; await gpsService.SendCommandsAsync(commands, 200, ct).ConfigureAwait(false); } private static double Median(IEnumerable values) { double[] ordered = values .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) .OrderBy(x => x) .ToArray(); if (ordered.Length == 0) return double.NaN; int middle = ordered.Length / 2; return ordered.Length % 2 == 1 ? ordered[middle] : (ordered[middle - 1] + ordered[middle]) / 2.0; } private static double CircularMean(IEnumerable headings) { double[] valid = headings .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) .ToArray(); if (valid.Length == 0) return double.NaN; double sin = valid.Sum(x => Math.Sin(x * Math.PI / 180.0)); double cos = valid.Sum(x => Math.Cos(x * Math.PI / 180.0)); double angle = Math.Atan2(sin, cos) * 180.0 / Math.PI; return (angle + 360.0) % 360.0; } private static double CircularMedian(IEnumerable headings) { double[] valid = headings .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) .Select(x => (x % 360.0 + 360.0) % 360.0) .ToArray(); if (valid.Length == 0) return double.NaN; return valid .OrderBy(candidate => valid.Sum(x => AngularDistanceDegrees(x, candidate))) .First(); } private static double AngularDistanceDegrees(double a, double b) { double diff = Math.Abs(((a - b + 540.0) % 360.0) - 180.0); return diff; } public enum MetodoFixacaoBase { Ntrip = 0, SurveyIn = 1, Manual = 2, } } public sealed class GpsTransportMetrics { public bool SerialConnected { get; init; } public string PortName { get; init; } public double SerialLastRxAgeMs { get; init; } public double LastValidNmeaAgeMs { get; init; } public double LastValidGgaAgeMs { get; init; } public double LastValidRtcmAgeMs { get; init; } public double LastForwardedRtcmAgeMs { get; init; } public long SerialBytesReceived { get; init; } public long SerialReadErrors { get; init; } public long NmeaValid { get; init; } public long NmeaInvalid { get; init; } public long NmeaChecksumErrors { get; init; } public long RtcmValid { get; init; } public long RtcmCrcErrors { get; init; } public long RtcmInvalidLength { get; init; } public long RtcmResyncs { get; init; } public long RtcmQueued { get; init; } public long RtcmReplaced { get; init; } public long RtcmDroppedStale { get; init; } public long RtcmForwarded { get; init; } public long RtcmPublishErrors { get; init; } public int RtcmQueueDepth { get; init; } public double RtcmOldestQueueAgeMs { get; init; } public long SerialReconnects { get; init; } public bool NtripConnected { get; init; } public long NtripBytesReceived { get; init; } public long NtripReconnects { get; init; } public long NtripErrors { get; init; } public string NtripProvider { get; init; } public string NtripMountpoint { get; init; } public double? NtripMountpointDistanceKm { get; init; } public string ExpectedRole { get; init; } } }