diff --git a/AgroBase/AgroBase/Models/Variaveis.cs b/AgroBase/AgroBase/Models/Variaveis.cs index 1be8349f9..f4f4e1469 100644 --- a/AgroBase/AgroBase/Models/Variaveis.cs +++ b/AgroBase/AgroBase/Models/Variaveis.cs @@ -887,7 +887,7 @@ namespace AgroBase.Models get { double diametro = - Variaveis.OperacaoEmAndamento.DispMvd != null ? + Variaveis.OperacaoEmAndamento?.DispMvd != null ? Variaveis.OperacaoEmAndamento.DispMvd.Dados.DiametroRoda : 0.3556; return diametro; diff --git a/AgroBase/AgroBase/Services/GPSService.cs b/AgroBase/AgroBase/Services/GPSService.cs index 26cb7ac4a..e1c8b64ca 100644 --- a/AgroBase/AgroBase/Services/GPSService.cs +++ b/AgroBase/AgroBase/Services/GPSService.cs @@ -63,6 +63,117 @@ namespace AgroBase.Services private static volatile bool LoopRTK_Ntrip = false; public static volatile bool CorrecaoRTK_Ntrip = false; + /// + /// Indica que uma sessão NTRIP foi aceita pelo caster e está ativa. + /// CorrecaoRTK_Ntrip continua representando a solicitação/configuração. + /// + public static volatile bool Ntrip_Conectado = false; + + /// + /// Estratégia de provedores NTRIP do rover. + /// + public enum NtripProviderMode + { + IbgeOnly = 0, + Rtk2goOnly = 1, + IbgeThenRtk2go = 2 + } + + /// + /// Por padrão tenta primeiro IBGE e, se os candidatos válidos falharem, + /// tenta RTK2go. + /// + public static NtripProviderMode NtripMode { get; set; } = + NtripProviderMode.IbgeThenRtk2go; + + /// + /// Quando true, baixa a sourcetable e ordena as estações por distância + /// da posição GGA atual do rover. + /// + public static bool NtripAutoSelecionarMountpoint { get; set; } = true; + + /// + /// Quantidade máxima de estações tentadas por provedor em cada rodada. + /// + public static int NtripMaxCandidatosPorProvedor { get; set; } = 5; + + /// + /// Limite conservador para uma estação física de referência. + /// O rover sempre tenta as mais próximas primeiro. + /// + public static double NtripDistanciaMaximaKm { get; set; } = 120.0; + + /// + /// Tempo máximo aguardando uma posição GGA antes de desistir + /// da descoberta automática e aplicar o fallback seguro. + /// + public static int NtripEsperaPosicaoAutoSegundos { get; set; } = 15; + + /// + /// Porta interna do UM982 na qual o rover publica GGA para o computador. + /// Deve acompanhar ConfigurarModuloRover(), cujo padrão é com3. + /// + public static string NtripPortaGgaAutoSelecao { get; set; } = "com3"; + + /// + /// Cache local da sourcetable para evitar downloads em cada reconnect. + /// + public static int NtripSourcetableCacheSegundos { get; set; } = 600; + + /// + /// Um HTTP 200 sem nenhum byte de correção dentro desta janela é tratado + /// como sessão sem dados e o próximo candidato é tentado. + /// + public static int NtripPrimeirosDadosTimeoutSegundos { get; set; } = 8; + + /// + /// Mountpoints que falham ficam temporariamente suspensos para não + /// martelar repetidamente a mesma estação. + /// + public static int NtripCooldownMountpointSegundos { get; set; } = 60; + + // IBGE / RBMC-IP. Mountpoint manual é mantido apenas como override/fallback. + public static string NtripHost { get; set; } = "gps-ntrip.ibge.gov.br"; + public static int NtripPort { get; set; } = 2101; + public static string NtripMountpoint { get; set; } = "EESC0"; + public static string NtripUsername { get; set; } = + Environment.GetEnvironmentVariable("AGRO_NTRIP_USERNAME") ?? string.Empty; + public static string NtripPassword { get; set; } = + Environment.GetEnvironmentVariable("AGRO_NTRIP_PASSWORD") ?? string.Empty; + + // RTK2go. O usuário é normalmente um e-mail; a senha padrão do caster é "none". + public static string NtripRtk2goHost { get; set; } = "rtk2go.com"; + public static int NtripRtk2goPort { get; set; } = 2101; + public static string NtripRtk2goMountpoint { get; set; } = "SPLI1"; + public static string NtripRtk2goUsername { get; set; } = + Environment.GetEnvironmentVariable("AGRO_RTK2GO_EMAIL") ?? string.Empty; + public static string NtripRtk2goPassword { get; set; } = "none"; + + /// + /// Diagnóstico do caster efetivamente utilizado. + /// + public static string NtripProviderAtivo { get; private set; } = string.Empty; + public static string NtripMountpointAtivo { get; private set; } = string.Empty; + public static double? NtripDistanciaMountpointAtivoKm { get; private set; } + + /// + /// Permite configurar credenciais apenas em memória, sem gravá-las no fonte. + /// + public static 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(); + } + /// /// Mantido para compatibilidade e diagnóstico humano. /// Watchdogs internos usam Stopwatch. @@ -107,6 +218,17 @@ namespace AgroBase.Services private static Task _ntripTask; private static TcpClient _ntripClient; + private static readonly object _ntripSourcetableLock = new object(); + private static readonly Dictionary + _ntripSourcetableCache = + new Dictionary( + StringComparer.OrdinalIgnoreCase); + + private static readonly object _ntripCooldownLock = new object(); + private static readonly Dictionary _ntripCooldownAteUtc = + new Dictionary( + StringComparer.OrdinalIgnoreCase); + private static long _portGeneration; // ============================================================ @@ -1564,6 +1686,17 @@ namespace AgroBase.Services public static void AplicarCorrecaoRTK_Mqtt(byte[] correcao, int bytesRead) { + /* + * Uma solução RTK não pode receber correções simultâneas de duas + * bases/referências diferentes. Se NTRIP estiver efetivamente + * conectado, ele é a fonte ativa e RTCM via MQTT é ignorado. + * + * Se o NTRIP cair, Ntrip_Conectado volta a false e o MQTT pode + * assumir novamente sem exigir reinicialização do serviço. + */ + if (Ntrip_Conectado) + return; + if (correcao == null || bytesRead <= 0 || bytesRead > correcao.Length || bytesRead > RtcmMaxPayloadBytes) { Interlocked.Increment(ref _rtcmDroppedInvalid); @@ -1746,15 +1879,13 @@ namespace AgroBase.Services } // ============================================================ - // NTRIP + // NTRIP: FAILOVER + AUTOSELEÇÃO DE MOUNTPOINT // ============================================================ public static async Task IniciarNtripAsync() { if (!Variaveis.IsAgroBase || !CorrecaoRTK_Ntrip) - { return; - } lock (_workerLock) { @@ -1765,10 +1896,12 @@ namespace AgroBase.Services } _ntripCts = new CancellationTokenSource(); + _ntripTask = Task.Run( () => AplicarCorrecaoRTK_Ntrip( _ntripCts.Token - ) + ), + _ntripCts.Token ); } @@ -1793,22 +1926,33 @@ namespace AgroBase.Services } CorrecaoRTK_Ntrip = false; + Ntrip_Conectado = false; + LimparNtripAtivo(); if (cts != null) { - try { cts.Cancel(); } catch { } + try { cts.Cancel(); } + catch { } } if (client != null) { - try { client.Close(); } catch { } - try { client.Dispose(); } catch { } + try { client.Close(); } + catch { } + + try { client.Dispose(); } + catch { } } if (task != null) { - try { await task.ConfigureAwait(false); } - catch (OperationCanceledException) { } + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } catch (Exception ex) { RecordError( @@ -1822,340 +1966,2401 @@ namespace AgroBase.Services cts.Dispose(); } - private static async Task AplicarCorrecaoRTK_Ntrip(CancellationToken cancellationToken) + private static void LimparNtripAtivo() { - if (!Iniciado || LoopRTK_Ntrip || !APIService.HasInternet) + NtripProviderAtivo = string.Empty; + NtripMountpointAtivo = string.Empty; + NtripDistanciaMountpointAtivoKm = null; + } + + private static List + ObterSequenciaProvedoresNtrip() + { + var ibge = new NtripProviderConfig { - return; + Nome = "IBGE", + Host = NtripHost, + Port = NtripPort, + MountpointManual = NtripMountpoint, + Username = NtripUsername, + Password = NtripPassword, + UsarNtripV2 = true, + Comunitario = false, + ExigeUsuario = true, + ExigeSenha = true, + FiltroPais = "BRA", + PermitirFallbackManualSeAutoFalhar = true + }; + + var rtk2go = new NtripProviderConfig + { + Nome = "RTK2go", + Host = NtripRtk2goHost, + Port = NtripRtk2goPort, + MountpointManual = NtripRtk2goMountpoint, + Username = NtripRtk2goUsername, + Password = NtripRtk2goPassword, + UsarNtripV2 = true, + Comunitario = true, + ExigeUsuario = true, + ExigeSenha = false, + FiltroPais = "BRA", + + // Em modo automático nunca usamos SPLI1 silenciosamente + // caso a descoberta do RTK2go falhe. + PermitirFallbackManualSeAutoFalhar = false + }; + + List providers; + + switch (NtripMode) + { + case NtripProviderMode.Rtk2goOnly: + providers = + new List { rtk2go }; + break; + + case NtripProviderMode.IbgeThenRtk2go: + providers = + new List + { + ibge, + rtk2go + }; + break; + + default: + providers = + new List { ibge }; + break; } + var validos = new List(); + + foreach (NtripProviderConfig provider in providers) + { + if (!ConfiguracaoNtripEstruturalValida(provider)) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + (provider != null ? 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." + : "Defina AGRO_NTRIP_USERNAME."; + + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "] Usuário não configurado. " + + dica + ); + + continue; + } + + if (provider.ExigeSenha && + string.IsNullOrWhiteSpace(provider.Password)) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "] Senha não configurada. " + + "Defina AGRO_NTRIP_PASSWORD." + ); + + continue; + } + + validos.Add(provider); + } + + return validos; + } + + private static bool ConfiguracaoNtripEstruturalValida( + NtripProviderConfig provider) + { + return + provider != null && + !string.IsNullOrWhiteSpace(provider.Host) && + provider.Port > 0; + } + + private static async Task AplicarCorrecaoRTK_Ntrip( + CancellationToken cancellationToken) + { + if (!Iniciado || LoopRTK_Ntrip) + return; + LoopRTK_Ntrip = true; - string host = Environment.GetEnvironmentVariable( - "AGRO_NTRIP_HOST" - ) ?? "gps-ntrip.ibge.gov.br"; - - int port = 2101; - int portEnv; - - if (int.TryParse( - Environment.GetEnvironmentVariable( - "AGRO_NTRIP_PORT" - ), - out portEnv)) - { - port = portEnv; - } - - string mountpoint = - Environment.GetEnvironmentVariable( - "AGRO_NTRIP_MOUNTPOINT" - ) ?? "EESC0"; - - string username = "Zendion"; - //Environment.GetEnvironmentVariable( - // "AGRO_NTRIP_USERNAME" - //) ?? string.Empty; - - string password = "kY3zd$*5"; - //Environment.GetEnvironmentVariable( - // "AGRO_NTRIP_PASSWORD" - //) ?? string.Empty; - - int backoffMs = 1000; - var random = new Random(); + double backoffSeconds = 1.0; try { - while (!cancellationToken.IsCancellationRequested && - CorrecaoRTK_Ntrip && - APIService.HasInternet) + while ( + !cancellationToken.IsCancellationRequested && + CorrecaoRTK_Ntrip) { - TcpClient client = null; - - try + /* + * Diferente da versão antiga, não encerramos o worker + * apenas porque a internet caiu momentaneamente. + * Assim o rover se recupera sozinho quando o link volta. + */ + if (!APIService.HasInternet) { - client = new TcpClient(); + Ntrip_Conectado = false; + LimparNtripAtivo(); - lock (_workerLock) - _ntripClient = client; - - Task connectTask = - client.ConnectAsync(host, port); - - Task completed = await Task.WhenAny( - connectTask, - Task.Delay( - 10000, - cancellationToken - ) - ).ConfigureAwait(false); - - if (completed != connectTask) - throw new TimeoutException( - "Timeout ao conectar ao NTRIP." - ); - - await connectTask.ConfigureAwait(false); - - NetworkStream stream = - client.GetStream(); - - string credentials = - string.IsNullOrEmpty(username) - ? string.Empty - : Convert.ToBase64String( - Encoding.ASCII.GetBytes( - username + ":" + password - ) - ); - - string request = - "GET /" + mountpoint + - " HTTP/1.0\r\n" + - "User-Agent: NTRIP AgroBase/2.0\r\n" + - "Accept: */*\r\n" + - "Connection: close\r\n" + - ( - string.IsNullOrEmpty(credentials) - ? string.Empty - : "Authorization: Basic " + - credentials + "\r\n" - ) + - "\r\n"; - - byte[] requestBytes = - Encoding.ASCII.GetBytes(request); - - await stream.WriteAsync( - requestBytes, - 0, - requestBytes.Length, + await Task.Delay( + TimeSpan.FromSeconds(2), cancellationToken ).ConfigureAwait(false); - NtripHeaderResult header = - await ReadNtripHeaderAsync( - stream, - cancellationToken - ).ConfigureAwait(false); + continue; + } - if (!header.Success) - { - throw new IOException( - "Resposta NTRIP inválida: " + - header.StatusLine - ); - } + List providers = + ObterSequenciaProvedoresNtrip(); + + if (providers.Count == 0) + { + Ntrip_Conectado = false; + LimparNtripAtivo(); Variaveis.MostrarLog( - "[GPSService.NTRIP] Conectado a " + - host + "/" + mountpoint + "[GPSService.NTRIP] " + + "Nenhum provedor possui configuração válida." ); - backoffMs = 1000; + await Task.Delay( + TimeSpan.FromSeconds(5), + cancellationToken + ).ConfigureAwait(false); - if (header.RemainingBytes != null && - header.RemainingBytes.Length > 0) + continue; + } + + foreach (NtripProviderConfig provider in providers) + { + if ( + cancellationToken.IsCancellationRequested || + !CorrecaoRTK_Ntrip) { - Interlocked.Add( - ref _ntripBytesReceived, - header.RemainingBytes.Length - ); - - EnfileirarRtcm( - header.RemainingBytes, - RtcmSource.Ntrip - ); + break; } - byte[] buffer = new byte[4096]; + List candidatos; - while (!cancellationToken - .IsCancellationRequested && - CorrecaoRTK_Ntrip) + try { - int bytesRead = await stream.ReadAsync( - buffer, - 0, - buffer.Length, - cancellationToken - ).ConfigureAwait(false); - - if (bytesRead <= 0) - break; - - byte[] bloco = new byte[bytesRead]; - - Buffer.BlockCopy( - buffer, - 0, - bloco, - 0, - bytesRead - ); - - Interlocked.Add( - ref _ntripBytesReceived, - bytesRead - ); - - EnfileirarRtcm( - bloco, - RtcmSource.Ntrip - ); + candidatos = + await ObterCandidatosNtripAsync( + provider, + cancellationToken + ).ConfigureAwait(false); } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Interlocked.Increment( - ref _ntripErrors - ); - - RecordError( - "[GPSService.NTRIP] " + ex.Message - ); - } - finally - { - lock (_workerLock) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) { - if (ReferenceEquals( - _ntripClient, - client)) + return; + } + catch (Exception ex) + { + Interlocked.Increment( + ref _ntripErrors + ); + + RecordError( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Falha preparando candidatos: " + + ex.Message + ); + + continue; + } + + if (candidatos.Count == 0) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "] Nenhum mountpoint elegível." + ); + + continue; + } + + for (int i = 0; + i < candidatos.Count; + i++) + { + if ( + cancellationToken.IsCancellationRequested || + !CorrecaoRTK_Ntrip) { - _ntripClient = null; + break; + } + + NtripCandidate candidato = + candidatos[i]; + + TimeSpan cooldownRestante; + + if (TryObterCooldownRestante( + provider.Nome, + candidato.Mountpoint, + out cooldownRestante)) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][COOLDOWN] " + + provider.Nome + + "/" + + candidato.Mountpoint + + " ignorado por mais " + + Math.Ceiling( + cooldownRestante.TotalSeconds + ).ToString("F0", + CultureInfo.InvariantCulture) + + "s." + ); + + continue; + } + + try + { + string distanciaTexto = + candidato.DistanciaKm.HasValue + ? " | " + + candidato + .DistanciaKm + .Value + .ToString( + "F1", + CultureInfo + .InvariantCulture + ) + + " km" + : string.Empty; + + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "] Tentando candidato " + + (i + 1) + + "/" + + candidatos.Count + + ": " + + candidato.Mountpoint + + distanciaTexto + + "..." + ); + + await ExecutarSessaoNtripAsync( + provider, + candidato, + cancellationToken + ).ConfigureAwait(false); + + /* + * Retorno normal só ocorre quando a sessão foi + * encerrada por cancelamento/desativação. + */ + } + catch (OperationCanceledException) + when (cancellationToken + .IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + Ntrip_Conectado = false; + LimparNtripAtivo(); + + Interlocked.Increment( + ref _ntripErrors + ); + + RecordError( + "[GPSService.NTRIP][" + + provider.Nome + + "/" + + candidato.Mountpoint + + "] Falha: " + + ex.Message + ); + + MarcarMountpointEmCooldown( + provider.Nome, + candidato.Mountpoint, + ex + ); + + if (i + 1 < candidatos.Count) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][FALLBACK] " + + "Tentando próximo mountpoint de " + + provider.Nome + + ": " + + candidatos[i + 1].Mountpoint + + "." + ); + } } } - if (client != null) + if ( + !Ntrip_Conectado && + !cancellationToken.IsCancellationRequested && + CorrecaoRTK_Ntrip) { - try { client.Close(); } catch { } - try { client.Dispose(); } catch { } + Variaveis.MostrarLog( + "[GPSService.NTRIP][FALLBACK] " + + "Candidatos de " + + provider.Nome + + " esgotados." + ); } } - if (cancellationToken.IsCancellationRequested || + if ( + cancellationToken.IsCancellationRequested || !CorrecaoRTK_Ntrip) { break; } - Interlocked.Increment(ref _ntripReconnects); + Interlocked.Increment( + ref _ntripReconnects + ); - int jitter = random.Next(0, 350); + double jitter = + ((uint)Environment.TickCount % 500) / + 1000.0; await Task.Delay( - backoffMs + jitter, + TimeSpan.FromSeconds( + backoffSeconds + jitter + ), cancellationToken ).ConfigureAwait(false); - backoffMs = Math.Min( - 30000, - (int)(backoffMs * 1.8) - ); + backoffSeconds = + Math.Min( + 20.0, + backoffSeconds * 1.8 + ); } } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } finally { + Ntrip_Conectado = false; + LimparNtripAtivo(); LoopRTK_Ntrip = false; } } - private static async Task ReadNtripHeaderAsync(NetworkStream stream, CancellationToken cancellationToken) + private static async Task> + ObterCandidatosNtripAsync( + NtripProviderConfig provider, + CancellationToken cancellationToken) { - var bytes = new List(2048); - byte[] buffer = new byte[512]; - - while (bytes.Count < 16 * 1024) + if (!NtripAutoSelecionarMountpoint) { - int read = await stream.ReadAsync( - buffer, - 0, - buffer.Length, + if (string.IsNullOrWhiteSpace( + provider.MountpointManual)) + { + return new List(); + } + + return new List + { + new NtripCandidate + { + Mountpoint = + provider.MountpointManual.Trim(), + DistanciaKm = null, + Formato = string.Empty, + SistemasGnss = string.Empty, + OrigemAuto = false + } + }; + } + + NtripApproxPosition pos = + await AguardarPosicaoParaSelecaoNtripAsync( cancellationToken ).ConfigureAwait(false); - if (read <= 0) - break; + if (pos == null) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Sem posição GGA recente " + + "para ordenar estações." + ); - for (int i = 0; i < read; i++) - bytes.Add(buffer[i]); + return CriarFallbackManualSePermitido( + provider + ); + } - int headerEnd = FindHeaderEnd(bytes); + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Posição aproximada: lat=" + + pos.Latitude.ToString( + "F6", + CultureInfo.InvariantCulture + ) + + ", lon=" + + pos.Longitude.ToString( + "F6", + CultureInfo.InvariantCulture + ) + + "." + ); - if (headerEnd >= 0) + List streams; + + try + { + streams = + await ObterSourcetableAsync( + provider, + cancellationToken + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Sourcetable indisponível: " + + ex.Message + ); + + return CriarFallbackManualSePermitido( + provider + ); + } + + double maxKm = + 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 + ) + ) + /* + * IBGE e os mountpoints físicos do RTK2go usados + * atualmente não exigem GGA para o caster. + * Streams VRS/Network que exijam GGA ficam de fora + * até implementarmos o envio periódico ao caster. + */ + .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) + { + Variaveis.MostrarLog( + "[GPSService.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) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Estação mais próxima é " + + maisProxima.Mountpoint + + " a " + + maisProxima.DistanciaKm.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + " km, acima do limite de " + + maxKm.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + " km." + ); + + /* + * Em auto-seleção não furamos o limite com um + * mountpoint manual silencioso. + */ + return new List(); + } + + int maxCandidates = + Math.Max( + 1, + NtripMaxCandidatosPorProvedor + ); + + List dentroDoLimite = + ranqueados + .Where( + x => x.DistanciaKm <= maxKm + ) + .ToList(); + + var elegiveis = + new List( + maxCandidates + ); + + foreach ( + NtripMountpointInfo item + in dentroDoLimite) + { + TimeSpan restante; + + if (TryObterCooldownRestante( + provider.Nome, + item.Mountpoint, + out restante)) { - byte[] all = bytes.ToArray(); - string headerText = Encoding.ASCII.GetString( - all, - 0, - headerEnd + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO][COOLDOWN] " + + item.Mountpoint + + " (" + + item.DistanciaKm.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + " km) ignorado por mais " + + Math.Ceiling( + restante.TotalSeconds + ).ToString( + "F0", + CultureInfo.InvariantCulture + ) + + "s." ); - string statusLine = headerText - .Split(new[] { "\r\n" }, - StringSplitOptions.None) - .FirstOrDefault() ?? string.Empty; + continue; + } - bool success = - statusLine.IndexOf( - "200", + elegiveis.Add(item); + + if (elegiveis.Count >= maxCandidates) + break; + } + + if (elegiveis.Count == 0) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Todas as estações dentro de " + + maxKm.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + " km estão temporariamente em cooldown." + ); + + return new List(); + } + + string ranking = + string.Join( + " | ", + elegiveis.Select( + (x, i) => + (i + 1) + + "." + + x.Mountpoint + + " " + + x.DistanciaKm.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + "km" + ) + ); + + Variaveis.MostrarLog( + "[GPSService.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 ?? string.Empty).Trim() + + "::" + + (mountpoint ?? string.Empty).Trim(); + } + + private static 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) + { + DateTime ateUtc; + + if (!_ntripCooldownAteUtc.TryGetValue( + chave, + out ateUtc)) + { + return false; + } + + DateTime agora = + DateTime.UtcNow; + + if (ateUtc <= agora) + { + _ntripCooldownAteUtc.Remove( + chave + ); + + return false; + } + + restante = + ateUtc - agora; + + return true; + } + } + + private static 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; + + Variaveis.MostrarLog( + "[GPSService.NTRIP][COOLDOWN] " + + providerNome + + "/" + + mountpoint + + " suspenso por " + + segundos + + "s" + + motivoTexto + + "." + ); + } + + private static List + CriarFallbackManualSePermitido( + NtripProviderConfig provider) + { + if ( + !provider + .PermitirFallbackManualSeAutoFalhar || + string.IsNullOrWhiteSpace( + provider.MountpointManual)) + { + return new List(); + } + + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "][AUTO] Usando mountpoint manual " + + provider.MountpointManual + + " apenas como fallback de descoberta." + ); + + return new List + { + new NtripCandidate + { + Mountpoint = + provider.MountpointManual.Trim(), + DistanciaKm = null, + Formato = string.Empty, + SistemasGnss = string.Empty, + OrigemAuto = false + } + }; + } + + private static async Task + AguardarPosicaoParaSelecaoNtripAsync( + CancellationToken cancellationToken) + { + 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; + + Variaveis.MostrarLog( + "[GPSService.NTRIP][AUTO] " + + "Aguardando posição GGA válida por até " + + esperaSegundos + + "s antes de selecionar mountpoints." + ); + + while ( + !cancellationToken.IsCancellationRequested && + DateTime.UtcNow <= deadlineUtc) + { + NtripApproxPosition pos = + TryGetApproxPositionForNtrip(); + + if (pos != null) + { + double decorrido = + Math.Max( + 0, + ( + DateTime.UtcNow - + inicioUtc + ).TotalSeconds + ); + + Variaveis.MostrarLog( + "[GPSService.NTRIP][AUTO] " + + "Posição GGA disponível após " + + decorrido.ToString( + "F1", + CultureInfo.InvariantCulture + ) + + "s." + ); + + return pos; + } + + if (DateTime.UtcNow >= proximoReforcoUtc) + { + string porta = + string.IsNullOrWhiteSpace( + NtripPortaGgaAutoSelecao + ) + ? "com3" + : NtripPortaGgaAutoSelecao.Trim(); + + /* + * Preserva a frequência configurada no rover. + * Não reduzimos o GGA de 5 Hz para 1 Hz só para + * realizar a descoberta NTRIP. + */ + string freq = + ( + 1.0 / + Math.Max( + 1, + TaxaAmostragemHz + ) + ) + .ToString( + "0.0", + CultureInfo.InvariantCulture + ); + + try + { + await EnviarComandoSerialNtripAsync( + "gngga " + + porta + + " " + + freq + + "\r\n", + cancellationToken + ).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (cancellationToken + .IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][AUTO] " + + "Não foi possível reforçar GGA em " + + porta + + ": " + + ex.Message + ); + } + + proximoReforcoUtc = + DateTime.UtcNow.AddSeconds(3); + } + + await Task.Delay( + 200, + cancellationToken + ).ConfigureAwait(false); + } + + Variaveis.MostrarLog( + "[GPSService.NTRIP][AUTO] " + + "Nenhuma posição GGA válida recebida em " + + esperaSegundos + + "s. A descoberta automática usará o fallback seguro." + ); + + return null; + } + + private static NtripApproxPosition + TryGetApproxPositionForNtrip() + { + double ggaAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastValidGgaMono + ) + ); + + /* + * Para ordenar bases basta uma posição autônoma recente. + * Trinta segundos ainda é amplamente suficiente para + * diferenciar estações a quilômetros de distância. + */ + if ( + double.IsInfinity(ggaAgeMs) || + ggaAgeMs < 0 || + ggaAgeMs > 30_000) + { + return null; + } + + lock (_stateLock) + { + double lat = + UltimaLeitura.Latitude; + + double lon = + UltimaLeitura.Longitude; + + if (!CoordenadaValida(lat, lon)) + { + lat = + UltimaLeitura.LatitudeAnt; + + lon = + UltimaLeitura.LongitudeAnt; + } + + if (!CoordenadaValida(lat, lon)) + return null; + + return new NtripApproxPosition + { + Latitude = lat, + Longitude = lon + }; + } + } + + private static async Task + EnviarComandoSerialNtripAsync( + string comando, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(comando)) + return; + + byte[] bytes = + Encoding.ASCII.GetBytes( + comando + ); + + await _serialWriteLock + .WaitAsync( + cancellationToken + ) + .ConfigureAwait(false); + + try + { + WriteSerialUnsafe( + bytes, + bytes.Length + ); + } + finally + { + _serialWriteLock.Release(); + } + } + + private static async Task> + ObterSourcetableAsync( + NtripProviderConfig provider, + CancellationToken cancellationToken) + { + string cacheKey = + provider.Host + + ":" + + provider.Port; + + int cacheSeconds = + Math.Max( + 30, + NtripSourcetableCacheSegundos + ); + + lock (_ntripSourcetableLock) + { + NtripSourcetableCacheEntry cached; + + if ( + _ntripSourcetableCache.TryGetValue( + cacheKey, + out cached + ) && + ( + DateTime.UtcNow - + cached.ObtidaUtc + ).TotalSeconds <= cacheSeconds) + { + return + cached + .Streams + .Select( + CloneMountpointInfo + ) + .ToList(); + } + } + + List streams = + await BaixarSourcetableAsync( + provider, + cancellationToken + ).ConfigureAwait(false); + + lock (_ntripSourcetableLock) + { + _ntripSourcetableCache[cacheKey] = + new NtripSourcetableCacheEntry + { + ObtidaUtc = + DateTime.UtcNow, + + Streams = + streams + .Select( + CloneMountpointInfo + ) + .ToList() + }; + } + + Variaveis.MostrarLog( + "[GPSService.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 static async Task> + BaixarSourcetableAsync( + NtripProviderConfig provider, + CancellationToken cancellationToken) + { + using (var client = new TcpClient()) + { + await ConnectTcpWithTimeoutAsync( + client, + provider.Host, + provider.Port, + TimeSpan.FromSeconds(8), + cancellationToken + ).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 AgroRover/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, + cancellationToken + ).ConfigureAwait(false); + + await stream.FlushAsync( + cancellationToken + ).ConfigureAwait(false); + + Tuple response = + await ReadHttpHeaderAsync( + stream, + 32 * 1024, + cancellationToken + ).ConfigureAwait(false); + + string header = + response.Item1; + + byte[] firstBodyBytes = + response.Item2; + + string firstLine = + header + .Split( + new[] + { + "\r\n", + "\n" + }, + StringSplitOptions.None + ) + .FirstOrDefault() ?? + string.Empty; + + bool accepted = + firstLine.IndexOf( + "200 OK", + StringComparison + .OrdinalIgnoreCase + ) >= 0 || + firstLine.StartsWith( + "SOURCETABLE 200", + StringComparison + .OrdinalIgnoreCase + ); + + if (!accepted) + { + throw new IOException( + "sourcetable recusada: " + + firstLine + ); + } + + const int maxBodyBytes = + 2 * 1024 * 1024; + + var body = + new List( + 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), + cancellationToken + ).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] + ); + } + + byte[] bodyArray = + body.ToArray(); + + int tailSize = + Math.Min( + bodyArray.Length, + 256 + ); + + string tail = + Encoding.ASCII + .GetString( + bodyArray, + bodyArray.Length - + tailSize, + tailSize + ); + + if (tail.IndexOf( + "ENDSOURCETABLE", + StringComparison + .OrdinalIgnoreCase + ) >= 0) + { + 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) + { + var result = + new List(); + + 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; + + double lat; + double lon; + + if ( + !double.TryParse( + p[9], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out lat + ) || + !double.TryParse( + p[10], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out 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 static async Task ExecutarSessaoNtripAsync( + NtripProviderConfig provider, + NtripCandidate candidato, + CancellationToken cancellationToken) + { + if (provider == null) + throw new ArgumentNullException( + nameof(provider) + ); + + if ( + candidato == null || + string.IsNullOrWhiteSpace( + candidato.Mountpoint)) + { + throw new ArgumentException( + "Candidato NTRIP inválido.", + nameof(candidato) + ); + } + + TcpClient client = null; + + try + { + client = + new TcpClient(); + + lock (_workerLock) + { + _ntripClient = + client; + } + + await ConnectTcpWithTimeoutAsync( + client, + provider.Host, + provider.Port, + TimeSpan.FromSeconds(8), + cancellationToken + ).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 AgroRover/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 AgroRover/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, + cancellationToken + ).ConfigureAwait(false); + + await stream.FlushAsync( + cancellationToken + ).ConfigureAwait(false); + + Tuple response = + await ReadHttpHeaderAsync( + stream, + 16 * 1024, + cancellationToken + ).ConfigureAwait(false); + + string header = + response.Item1; + + byte[] firstBodyBytes = + response.Item2; + + string firstLine = + header + .Split( + new[] + { + "\r\n", + "\n" + }, + StringSplitOptions.None + ) + .FirstOrDefault() ?? + string.Empty; + + bool accepted = + firstLine.IndexOf( + "200 OK", StringComparison.OrdinalIgnoreCase ) >= 0 || - statusLine.StartsWith( + firstLine.StartsWith( "ICY 200", StringComparison.OrdinalIgnoreCase ); - int payloadStart = headerEnd + 4; - int remainingLength = - all.Length - payloadStart; - - byte[] remaining = - remainingLength > 0 - ? all.Skip(payloadStart) - .Take(remainingLength) - .ToArray() - : new byte[0]; - - return new NtripHeaderResult + if (!accepted) { - Success = success, - StatusLine = statusLine, - RemainingBytes = remaining - }; + throw new IOException( + "caster recusou conexão: " + + firstLine + ); + } + + if (HeaderTemTransferEncodingChunked( + header)) + { + throw new NotSupportedException( + "caster respondeu com Transfer-Encoding: chunked. " + + "O stream binário não pode ser encaminhado ao UM982 " + + "sem dechunking contínuo." + ); + } + + /* + * Antes de assumir a nova referência NTRIP, removemos + * correções ainda pendentes da fonte anterior. Isso evita + * que alguns frames MQTT antigos sejam escritos no UM982 + * logo antes dos primeiros frames do novo caster. + */ + lock (_rtcmQueueLock) + { + _rtcmQueue.Clear(); + } + + Ntrip_Conectado = true; + + NtripProviderAtivo = + provider.Nome; + + NtripMountpointAtivo = + candidato.Mountpoint; + + NtripDistanciaMountpointAtivoKm = + candidato.DistanciaKm; + + string distanciaTexto = + candidato.DistanciaKm.HasValue + ? " | distância=" + + candidato + .DistanciaKm + .Value + .ToString( + "F1", + CultureInfo + .InvariantCulture + ) + + " km" + : string.Empty; + + Variaveis.MostrarLog( + "[GPSService.NTRIP][" + + provider.Nome + + "] Conexão estabelecida em " + + provider.Host + + ":" + + provider.Port + + "/" + + candidato.Mountpoint + + distanciaTexto + + "." + ); + + if (provider.Comunitario) + { + Variaveis.MostrarLog( + "[GPSService.NTRIP][RTK2go][AVISO] " + + "Fonte comunitária ativa. Distância não valida " + + "datum, coordenada publicada nem qualidade geodésica." + ); + } + + bool recebeuDados = false; + + if ( + firstBodyBytes != null && + firstBodyBytes.Length > 0) + { + Interlocked.Add( + ref _ntripBytesReceived, + firstBodyBytes.Length + ); + + EnfileirarRtcm( + firstBodyBytes, + RtcmSource.Ntrip + ); + + recebeuDados = true; + } + + byte[] buffer = + new byte[4096]; + + while ( + !cancellationToken.IsCancellationRequested && + CorrecaoRTK_Ntrip) + { + int bytesRead; + + if (!recebeuDados) + { + bytesRead = + await ReadNetworkWithTimeoutAsync( + stream, + buffer, + TimeSpan.FromSeconds( + Math.Max( + 2, + NtripPrimeirosDadosTimeoutSegundos + ) + ), + cancellationToken + ).ConfigureAwait(false); + + if (bytesRead <= 0) + { + throw new TimeoutException( + "sessão foi aceita, mas nenhum byte RTCM " + + "chegou no prazo." + ); + } + } + else + { + bytesRead = + await stream.ReadAsync( + buffer, + 0, + buffer.Length, + cancellationToken + ).ConfigureAwait(false); + } + + if (bytesRead <= 0) + { + throw new IOException( + "caster encerrou a conexão." + ); + } + + byte[] bloco = + new byte[bytesRead]; + + Buffer.BlockCopy( + buffer, + 0, + bloco, + 0, + bytesRead + ); + + Interlocked.Add( + ref _ntripBytesReceived, + bytesRead + ); + + /* + * Preserva a arquitetura do rover: + * rede nunca escreve diretamente no UM982. + * NTRIP e MQTT passam pela mesma fila limitada + * e pelo mesmo writer serial. + */ + EnfileirarRtcm( + bloco, + RtcmSource.Ntrip + ); + + recebeuDados = true; + } + } + } + finally + { + Ntrip_Conectado = false; + LimparNtripAtivo(); + + if (client != null) + { + try { client.Close(); } + catch { } + + try { client.Dispose(); } + catch { } + } + + lock (_workerLock) + { + if (ReferenceEquals( + _ntripClient, + client)) + { + _ntripClient = null; + } + } + } + } + + private static async Task + ReadNetworkWithTimeoutAsync( + NetworkStream stream, + byte[] buffer, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using ( + CancellationTokenSource linked = + CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken + )) + { + linked.CancelAfter( + timeout + ); + + try + { + return await stream.ReadAsync( + buffer, + 0, + buffer.Length, + linked.Token + ).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken + .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 new NtripHeaderResult - { - Success = false, - StatusLine = "Cabeçalho NTRIP incompleto.", - RemainingBytes = new byte[0] - }; + return false; } - private static int FindHeaderEnd(List bytes) + private static byte[] + DecodeChunkedHttpBody( + byte[] raw) { - for (int i = 3; i < bytes.Count; i++) + if (raw == null || raw.Length == 0) + return new byte[0]; + + var output = + new List(); + + int pos = 0; + + while (pos < raw.Length) { - if (bytes[i - 3] == 13 && - bytes[i - 2] == 10 && - bytes[i - 1] == 13 && - bytes[i] == 10) + int lineEnd = + IndexOfCrlf( + raw, + pos + ); + + if (lineEnd < 0) { - return i - 3; + 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 + ); + } + + int size; + + if (!int.TryParse( + sizeLine, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out 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 cancellationToken) + { + Task connectTask = + client.ConnectAsync( + host, + port + ); + + Task timeoutTask = + Task.Delay( + timeout, + cancellationToken + ); + + Task completed = + await Task.WhenAny( + connectTask, + timeoutTask + ).ConfigureAwait(false); + + if (completed != connectTask) + { + cancellationToken + .ThrowIfCancellationRequested(); + + throw new TimeoutException( + "Timeout conectando em " + + host + + ":" + + port + + "." + ); + } + + await connectTask + .ConfigureAwait(false); + } + + private static async Task> + ReadHttpHeaderAsync( + NetworkStream stream, + int maxHeaderBytes, + CancellationToken cancellationToken) + { + var data = + new List(1024); + + byte[] temp = + new byte[512]; + + int headerEnd = -1; + int delimiterLength = 0; + + while (headerEnd < 0) + { + int read = + await stream.ReadAsync( + temp, + 0, + temp.Length, + cancellationToken + ).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." + ); + } + + Tuple headerInfo = + FindNtripHeaderEnd( + data + ); + + headerEnd = + headerInfo.Item1; + + delimiterLength = + headerInfo.Item2; + } + + 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() + : new byte[0]; + + return Tuple.Create( + header, + body + ); + } + + private static Tuple + 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 Tuple.Create( + i, + 4 + ); + } + } + + /* + * NTRIP Rev1 / SNIP pode responder apenas: + * ICY 200 OK\r\n + * seguido imediatamente do RTCM. + */ + 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 Tuple.Create( + i, + 2 + ); + } + } + } + + return Tuple.Create( + -1, + 0 + ); + } + // ============================================================ // SNAPSHOT E ESTADO GNSS // ============================================================ @@ -3305,6 +5510,11 @@ namespace AgroBase.Services ), NtripRunning = LoopRTK_Ntrip, + NtripConnected = Ntrip_Conectado, + NtripProvider = NtripProviderAtivo, + NtripMountpoint = NtripMountpointAtivo, + NtripMountpointDistanceKm = + NtripDistanciaMountpointAtivoKm, NtripReconnects = Interlocked.Read( @@ -3543,6 +5753,59 @@ namespace AgroBase.Services } } + private sealed class NtripProviderConfig + { + public string Nome { get; set; } + public string Host { get; set; } + public int Port { get; set; } + public string MountpointManual { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public bool UsarNtripV2 { get; set; } + public bool Comunitario { get; set; } + public bool ExigeUsuario { get; set; } + public bool ExigeSenha { get; set; } + public string FiltroPais { get; set; } + public bool PermitirFallbackManualSeAutoFalhar { get; set; } + } + + private sealed class NtripCandidate + { + public string Mountpoint { get; set; } + public double? DistanciaKm { get; set; } + public string Formato { get; set; } + public string SistemasGnss { get; set; } + public bool OrigemAuto { get; set; } + } + + private sealed class NtripMountpointInfo + { + public string Mountpoint { get; set; } + public string Identificador { get; set; } + public string Formato { get; set; } + public string SistemasGnss { get; set; } + public string Rede { get; set; } + public string Pais { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public bool ExigeGgaCliente { get; set; } + public string Autenticacao { get; set; } + public int TaxaBits { get; set; } + public double DistanciaKm { get; set; } + } + + private sealed class NtripSourcetableCacheEntry + { + public DateTime ObtidaUtc { get; set; } + public List Streams { get; set; } + } + + private sealed class NtripApproxPosition + { + public double Latitude { get; set; } + public double Longitude { get; set; } + } + private sealed class RtcmEnvelope { public byte[] Bytes { get; set; } @@ -3602,6 +5865,10 @@ namespace AgroBase.Services public long ConfigurationErrors { get; set; } public bool NtripRunning { get; set; } + public bool NtripConnected { get; set; } + public string NtripProvider { get; set; } + public string NtripMountpoint { get; set; } + public double? NtripMountpointDistanceKm { get; set; } public long NtripReconnects { get; set; } public long NtripBytesReceived { get; set; } public long NtripErrors { get; set; } diff --git a/AgroBase/OperationControl/Services/GpsService.cs b/AgroBase/OperationControl/Services/GpsService.cs index 1e67c0765..bac4b7a6f 100644 --- a/AgroBase/OperationControl/Services/GpsService.cs +++ b/AgroBase/OperationControl/Services/GpsService.cs @@ -122,14 +122,140 @@ namespace OperationControl.Services public GeoLeverArm LeverArm = new GeoLeverArm(); /// - /// Configure usuário e senha por variável de ambiente ou externamente. - /// Nunca mantenha a senha NTRIP no código-fonte. + /// 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; } = "Zendion"; // Environment.GetEnvironmentVariable("AGRO_NTRIP_USERNAME") ?? string.Empty; - public string NtripPassword { get; set; } = "kY3zd$*5"; //Environment.GetEnvironmentVariable("AGRO_NTRIP_PASSWORD") ?? string.Empty; + 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; @@ -205,6 +331,9 @@ namespace OperationControl.Services NtripBytesReceived = Interlocked.Read(ref _ntripBytesReceived), NtripReconnects = Interlocked.Read(ref _ntripReconnects), NtripErrors = Interlocked.Read(ref _ntripErrors), + NtripProvider = NtripProviderAtivo, + NtripMountpoint = NtripMountpointAtivo, + NtripMountpointDistanceKm = NtripDistanciaMountpointAtivoKm, ExpectedRole = _expectedRole.ToString(), }; } @@ -649,7 +778,7 @@ namespace OperationControl.Services portaSaida: portaSaida, portaEntrada: portaEntrada, baseId: baseId, - startNtrip: StartNtripAsync, + startNtrip: () => StartNtripAsync(paraFixacaoBase: true), stopNtrip: StopNtripAsync, ct: _lifetimeCts.Token).ConfigureAwait(false); break; @@ -1752,14 +1881,23 @@ namespace OperationControl.Services } // ========================================================= - // NTRIP COM CANCELAMENTO REAL + // NTRIP: FAILOVER DE PROVEDOR + AUTOSELEÇÃO DE MOUNTPOINT // ========================================================= private CancellationTokenSource _ntripCts; private Task _ntripTask; private TcpClient _ntripClient; + private bool _ntripParaFixacaoBase; - private Task StartNtripAsync() + 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) { @@ -1767,6 +1905,7 @@ namespace OperationControl.Services return Task.CompletedTask; CorrecaoRTK_Ntrip = true; + _ntripParaFixacaoBase = paraFixacaoBase; _ntripCts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token); _ntripTask = Task.Run( () => AplicarCorrecaoRTK_NtripAsync(_ntripCts.Token), @@ -1804,10 +1943,123 @@ namespace OperationControl.Services _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 = true, + 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 = true, + 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 || !APIService.HasInternet) @@ -1820,118 +2072,1138 @@ namespace OperationControl.Services if (!APIService.HasInternet) { Ntrip_Conectado = false; + LimparNtripAtivo(); await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); continue; } - TcpClient client = null; + List providers = ObterSequenciaProvedoresNtrip(); - try + if (providers.Count == 0) { - client = new TcpClient(); - lock (_ntripLock) - _ntripClient = client; + Ntrip_Conectado = false; + LimparNtripAtivo(); - await ConnectTcpWithTimeoutAsync( - client, - NtripHost, - NtripPort, - TimeSpan.FromSeconds(8), - ct).ConfigureAwait(false); + Models.Variaveis.MostrarLog( + "[NTRIP] Nenhum provedor possui configuração válida."); - using NetworkStream stream = client.GetStream(); + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + continue; + } - string credentials = string.IsNullOrWhiteSpace(NtripUsername) - ? string.Empty - : Convert.ToBase64String( - Encoding.ASCII.GetBytes($"{NtripUsername}:{NtripPassword}")); + bool algumaSessaoFoiEstabelecida = false; - string request = - $"GET /{NtripMountpoint} HTTP/1.0\r\n" + - "User-Agent: NTRIP AgroBase/1.0\r\n" + - "Accept: */*\r\n" + - "Connection: keep-alive\r\n" + - (!string.IsNullOrEmpty(credentials) - ? $"Authorization: Basic {credentials}\r\n" - : string.Empty) + - "\r\n"; + foreach (NtripProviderConfig provider in providers) + { + if (ct.IsCancellationRequested || !CorrecaoRTK_Ntrip) + break; - byte[] requestBytes = Encoding.ASCII.GetBytes(request); - await stream.WriteAsync(requestBytes, 0, requestBytes.Length, ct).ConfigureAwait(false); - await stream.FlushAsync(ct).ConfigureAwait(false); + List candidatos; - (string header, byte[] firstBodyBytes) = - await ReadHttpHeaderAsync(stream, 16 * 1024, ct).ConfigureAwait(false); - - string firstLine = header - .Split(new[] { "\r\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($"NTRIP recusou conexão: {firstLine}"); - - Ntrip_Conectado = true; - backoffSeconds = 1.0; - Models.Variaveis.MostrarLog("Conexão NTRIP estabelecida."); - - if (firstBodyBytes.Length > 0) + try { - await WriteSerialAsync(firstBodyBytes, ct).ConfigureAwait(false); - Interlocked.Add(ref _ntripBytesReceived, firstBodyBytes.Length); + 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; } - byte[] buffer = new byte[4096]; - - while (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip) + if (candidatos.Count == 0) { - int read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false); - if (read <= 0) - throw new IOException("Caster NTRIP encerrou a conexão."); + Models.Variaveis.MostrarLog( + $"[NTRIP][{provider.Nome}] Nenhum mountpoint elegível."); + continue; + } - byte[] serialChunk = new byte[read]; - Buffer.BlockCopy(buffer, 0, serialChunk, 0, read); - await WriteSerialAsync(serialChunk, ct).ConfigureAwait(false); - Interlocked.Add(ref _ntripBytesReceived, read); + 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."); } } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { + + if (ct.IsCancellationRequested || !CorrecaoRTK_Ntrip) break; - } - catch (Exception ex) - { - Ntrip_Conectado = false; - Interlocked.Increment(ref _ntripErrors); - Models.Variaveis.MostrarLog($"Erro na conexão NTRIP: {ex.Message}"); - } - finally - { - Ntrip_Conectado = false; - try { client?.Close(); } catch { } - lock (_ntripLock) - { - if (ReferenceEquals(_ntripClient, client)) - _ntripClient = null; - } - } + Interlocked.Increment(ref _ntripReconnects); - if (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip) - { - Interlocked.Increment(ref _ntripReconnects); - double jitter = ((uint)Environment.TickCount % 500) / 1000.0; - await Task.Delay( - TimeSpan.FromSeconds(backoffSeconds + jitter), - ct).ConfigureAwait(false); - backoffSeconds = Math.Min(20.0, backoffSeconds * 1.8); - } + 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( @@ -1966,22 +3238,31 @@ namespace OperationControl.Services while (headerEnd < 0) { - int read = await stream.ReadAsync(temp, 0, temp.Length, ct).ConfigureAwait(false); + 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."); + 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."); + 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(); @@ -1989,27 +3270,32 @@ namespace OperationControl.Services return (header, body); } - private static (int HeaderEnd, int DelimiterLength) FindNtripHeaderEnd(List data) + private static (int HeaderEnd, int DelimiterLength) FindNtripHeaderEnd( + List data) { - // Resposta HTTP/NTRIP v2: cabeçalho termina em CRLF CRLF. 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') + if (data[i] == '\r' && + data[i + 1] == '\n' && + data[i + 2] == '\r' && + data[i + 3] == '\n') { return (i, 4); } } - // Alguns casters NTRIP v1 respondem apenas "ICY 200 OK\r\n" - // e iniciam o corpo binário imediatamente depois. if (data.Count >= 3 && - data[0] == (byte)'I' && data[1] == (byte)'C' && data[2] == (byte)'Y') + 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') + if (data[i] == '\r' && + data[i + 1] == '\n') + { return (i, 2); + } } } @@ -2256,6 +3542,14 @@ namespace OperationControl.Services } + public enum NtripProviderMode + { + IbgeOnly = 0, + Rtk2goOnly = 1, + IbgeThenRtk2go = 2, + } + + public enum GnssPapelEstacao { Indefinido = 0, @@ -2302,6 +3596,60 @@ namespace OperationControl.Services } + 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; } @@ -2792,6 +4140,9 @@ namespace OperationControl.Services 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; } } }