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; }
}
}