agrobot_base/AgroBase/OperationControl/Services/GpsService.cs

2644 lines
95 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Ports;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Windows;
using AgroBase.Models;
using AgroBase.Services;
using OperationControl.Models;
using static AgroBase.Models.Enums;
using static OperationControl.Services.BaseFixService;
using Application = System.Windows.Application;
namespace OperationControl.Services
{
/// <summary>
/// Serviço do UM982 da base.
///
/// Princípios desta implementação:
/// 1) O callback serial apenas lê, separa e enfileira. Nunca publica na rede.
/// 2) NMEA e RTCM são demultiplexados por uma única máquina de estados.
/// 3) RTCM só é encaminhado após validação CRC24Q.
/// 4) A fila RTCM mantém apenas a mensagem mais nova de cada tipo.
/// 5) Mensagens RTCM antigas são descartadas antes da publicação.
/// 6) Reconectar a USB não altera silenciosamente o papel do GNSS.
/// 7) Todas as escritas no UM982 passam por um único lock.
/// 8) O cliente NTRIP possui cancelamento real e não mistura StreamReader com dados binários.
/// </summary>
public sealed class GpsService : IDisposable
{
// =========================================================
// CONFIGURAÇÃO GERAL
// =========================================================
private const int DefaultBaudRate = 115200;
private static readonly TimeSpan SerialSilenceReconnectTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SerialReconnectGrace = TimeSpan.FromSeconds(10);
private static readonly TimeSpan RtcmAssemblyTimeout = TimeSpan.FromSeconds(1.5);
private static readonly TimeSpan NmeaAssemblyTimeout = TimeSpan.FromSeconds(2.0);
private static readonly TimeSpan RtcmMaxQueueAge = TimeSpan.FromSeconds(2.5);
private readonly System.Timers.Timer _tmrCheck = new(2000) { AutoReset = true };
private readonly SemaphoreSlim _scanGate = new(1, 1);
private readonly SemaphoreSlim _serialWriteGate = new(1, 1);
private readonly object _portLock = new();
private readonly object _parserLock = new();
private readonly object _modelLock = new();
private readonly object _rtcmQueueLock = new();
private readonly object _ntripLock = new();
private readonly CancellationTokenSource _lifetimeCts = new();
private readonly SemaphoreSlim _rtcmSignal = new(0);
private readonly Dictionary<int, RtcmEnvelope> _rtcmLatestByType = new();
private readonly Task _rtcmPublisherTask;
private int _timerRunning;
private int _disposed;
private int _uiUpdatePending;
private double _serialConnectedMono;
private double _lastSerialRxMono;
private double _lastValidNmeaMono;
private double _lastValidGgaMono;
private double _lastValidRtcmMono;
private double _lastForwardedRtcmMono;
private long _ggaSequence;
private long _rtcmSequence;
public GpsService()
{
BaseFix = new BaseFixService(null, this);
_rtcmPublisherTask = Task.Run(
() => RtcmPublisherLoopAsync(_lifetimeCts.Token),
_lifetimeCts.Token);
_tmrCheck.Elapsed += (_, __) => _ = CheckConnectionAsync();
_tmrCheck.Start();
}
// =========================================================
// ESTADO PÚBLICO / COMPATIBILIDADE
// =========================================================
public SerialPort PortaGps { get; private set; }
public bool IsConnected
{
get
{
lock (_portLock)
return PortaGps != null && PortaGps.IsOpen;
}
}
public string PortName
{
get
{
lock (_portLock)
return PortaGps?.PortName;
}
}
private bool InverterHeading = false;
private volatile bool CorrecaoRTK_Ntrip = false;
private int TempoSurveryIn = 120;
public volatile bool Ntrip_Conectado = false;
public int TaxaAmostragemHz = 5;
private int rtk_timeout = 60;
public GPSModel UltimaLeitura = new GPSModel();
public BaseFixService BaseFix;
public GeoLeverArm LeverArm = new GeoLeverArm();
/// <summary>
/// Configure usuário e senha por variável de ambiente ou externamente.
/// Nunca mantenha a senha NTRIP no código-fonte.
/// </summary>
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; } = "QD&m1p60"; //Environment.GetEnvironmentVariable("AGRO_NTRIP_PASSWORD") ?? string.Empty;
private GnssExpectedRole _expectedRole = GnssExpectedRole.PreserveCurrentConfiguration;
private BaseFixedConfiguration _lastBaseConfiguration;
// =========================================================
// MÉTRICAS
// =========================================================
private long _serialBytesReceived;
private long _serialReadErrors;
private long _nmeaValid;
private long _nmeaInvalid;
private long _nmeaChecksumErrors;
private long _rtcmValid;
private long _rtcmCrcErrors;
private long _rtcmInvalidLength;
private long _rtcmResyncs;
private long _rtcmQueued;
private long _rtcmReplaced;
private long _rtcmDroppedStale;
private long _rtcmForwarded;
private long _rtcmPublishErrors;
private long _serialReconnects;
private long _ntripBytesReceived;
private long _ntripReconnects;
private long _ntripErrors;
public GpsTransportMetrics GetTransportMetrics()
{
int queueDepth;
double oldestQueueAgeMs = 0;
lock (_rtcmQueueLock)
{
queueDepth = _rtcmLatestByType.Count;
if (queueDepth > 0)
{
double now = MonotonicNow();
oldestQueueAgeMs = _rtcmLatestByType.Values
.Max(x => Math.Max(0, (now - x.ReceivedMono) * 1000.0));
}
}
double nowMono = MonotonicNow();
return new GpsTransportMetrics
{
SerialConnected = IsConnected,
PortName = PortName,
SerialLastRxAgeMs = AgeMs(nowMono, _lastSerialRxMono),
LastValidNmeaAgeMs = AgeMs(nowMono, _lastValidNmeaMono),
LastValidGgaAgeMs = AgeMs(nowMono, _lastValidGgaMono),
LastValidRtcmAgeMs = AgeMs(nowMono, _lastValidRtcmMono),
LastForwardedRtcmAgeMs = AgeMs(nowMono, _lastForwardedRtcmMono),
SerialBytesReceived = Interlocked.Read(ref _serialBytesReceived),
SerialReadErrors = Interlocked.Read(ref _serialReadErrors),
NmeaValid = Interlocked.Read(ref _nmeaValid),
NmeaInvalid = Interlocked.Read(ref _nmeaInvalid),
NmeaChecksumErrors = Interlocked.Read(ref _nmeaChecksumErrors),
RtcmValid = Interlocked.Read(ref _rtcmValid),
RtcmCrcErrors = Interlocked.Read(ref _rtcmCrcErrors),
RtcmInvalidLength = Interlocked.Read(ref _rtcmInvalidLength),
RtcmResyncs = Interlocked.Read(ref _rtcmResyncs),
RtcmQueued = Interlocked.Read(ref _rtcmQueued),
RtcmReplaced = Interlocked.Read(ref _rtcmReplaced),
RtcmDroppedStale = Interlocked.Read(ref _rtcmDroppedStale),
RtcmForwarded = Interlocked.Read(ref _rtcmForwarded),
RtcmPublishErrors = Interlocked.Read(ref _rtcmPublishErrors),
RtcmQueueDepth = queueDepth,
RtcmOldestQueueAgeMs = oldestQueueAgeMs,
SerialReconnects = Interlocked.Read(ref _serialReconnects),
NtripConnected = Ntrip_Conectado,
NtripBytesReceived = Interlocked.Read(ref _ntripBytesReceived),
NtripReconnects = Interlocked.Read(ref _ntripReconnects),
NtripErrors = Interlocked.Read(ref _ntripErrors),
ExpectedRole = _expectedRole.ToString(),
};
}
// =========================================================
// DESCOBERTA / RECONEXÃO SERIAL
// =========================================================
private async Task CheckConnectionAsync()
{
if (Interlocked.CompareExchange(ref _timerRunning, 1, 0) != 0)
return;
try
{
if (Volatile.Read(ref _disposed) != 0)
return;
bool connected = IsConnected;
double now = MonotonicNow();
if (connected)
{
bool graceEnded =
_serialConnectedMono > 0 &&
(now - _serialConnectedMono) >= SerialReconnectGrace.TotalSeconds;
double lastActivity = _lastSerialRxMono > 0
? _lastSerialRxMono
: _serialConnectedMono;
bool silentTooLong =
lastActivity > 0 &&
(now - lastActivity) >= SerialSilenceReconnectTimeout.TotalSeconds;
if (!graceEnded || !silentTooLong)
return;
Models.Variaveis.MostrarLog(
$"GNSS sem dados há {(now - _lastSerialRxMono):F1}s. Reiniciando conexão serial.");
DisconnectInternal();
}
bool found = await ScanAndConnectAsync(
timeoutPorPortaMs: 1500,
ct: _lifetimeCts.Token).ConfigureAwait(false);
if (found)
{
Models.Variaveis.MostrarLog($"GPS conectado na porta {PortName}");
await RestoreExpectedRoleAfterReconnectAsync(_lifetimeCts.Token).ConfigureAwait(false);
}
else
{
Models.Variaveis.MostrarLog("Nenhum GPS UM982 encontrado.");
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
Models.Variaveis.MostrarLog($"Erro ao verificar conexão do GNSS: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _timerRunning, 0);
}
}
public async Task<bool> ScanAndConnectAsync(
int timeoutPorPortaMs = 1500,
CancellationToken ct = default)
{
if (IsConnected)
return true;
await _scanGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (IsConnected)
return true;
string[] portas = SerialPort.GetPortNames()
.OrderBy(p => p)
.ToArray();
foreach (string portName in portas)
{
ct.ThrowIfCancellationRequested();
if (await TryConnectOnPortAsync(portName, timeoutPorPortaMs, ct)
.ConfigureAwait(false))
{
Interlocked.Increment(ref _serialReconnects);
return true;
}
}
return false;
}
finally
{
_scanGate.Release();
}
}
private async Task<bool> TryConnectOnPortAsync(
string portName,
int timeoutMs,
CancellationToken ct)
{
SerialPort porta = null;
try
{
porta = new SerialPort(portName, DefaultBaudRate)
{
ReadTimeout = timeoutMs,
WriteTimeout = timeoutMs,
NewLine = "\n",
DtrEnable = false,
RtsEnable = false,
ReadBufferSize = 64 * 1024,
WriteBufferSize = 16 * 1024,
};
porta.Open();
if (!await EhUm982PorVersionAsync(porta, timeoutMs, ct).ConfigureAwait(false))
{
// Fallback não persistente: pede apenas GGA para confirmar que é GNSS.
byte[] nmea = Encoding.ASCII.GetBytes("gngga com2 1\r\n");
porta.Write(nmea, 0, nmea.Length);
await Task.Delay(200, ct).ConfigureAwait(false);
string sample = await LerAmostraAsync(porta, timeoutMs, ct).ConfigureAwait(false);
if (!EhGpsUm982OuNmea(sample))
{
porta.Close();
porta.Dispose();
return false;
}
}
RegisterConnectedPort(porta);
return true;
}
catch (OperationCanceledException)
{
try { porta?.Close(); } catch { }
try { porta?.Dispose(); } catch { }
throw;
}
catch
{
try { porta?.Close(); } catch { }
try { porta?.Dispose(); } catch { }
return false;
}
}
private void RegisterConnectedPort(SerialPort porta)
{
DisconnectInternal();
lock (_portLock)
{
PortaGps = porta;
PortaGps.DataReceived -= PortaGPS_DataReceived;
PortaGps.DataReceived += PortaGPS_DataReceived;
}
ResetParsers();
_serialConnectedMono = MonotonicNow();
_lastSerialRxMono = 0;
}
private async Task<string> LerAmostraAsync(
SerialPort porta,
int timeoutMs,
CancellationToken ct)
{
double start = MonotonicNow();
StringBuilder buffer = new();
while ((MonotonicNow() - start) * 1000.0 < timeoutMs)
{
ct.ThrowIfCancellationRequested();
try
{
int available = porta.BytesToRead;
if (available > 0)
{
byte[] temp = new byte[available];
int read = porta.Read(temp, 0, temp.Length);
if (read > 0)
{
buffer.Append(Encoding.ASCII.GetString(temp, 0, read));
string text = buffer.ToString();
if (text.Contains("UM982", StringComparison.OrdinalIgnoreCase) ||
text.Contains("$GP", StringComparison.OrdinalIgnoreCase) ||
text.Contains("$GN", StringComparison.OrdinalIgnoreCase))
{
break;
}
}
}
}
catch
{
break;
}
await Task.Delay(50, ct).ConfigureAwait(false);
}
return buffer.ToString();
}
private static bool EhGpsUm982OuNmea(string recebido)
{
if (string.IsNullOrEmpty(recebido))
return false;
return recebido.Contains("UM982", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPTXT", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPRMC", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPGGA", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPGLL", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GNGGA", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPVTG", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GPGSV", StringComparison.OrdinalIgnoreCase) ||
recebido.Contains("$GNTHS", StringComparison.OrdinalIgnoreCase);
}
private async Task<bool> EhUm982PorVersionAsync(
SerialPort porta,
int timeoutMs,
CancellationToken ct)
{
try
{
porta.DiscardInBuffer();
byte[] cmd = Encoding.ASCII.GetBytes("version\r\n");
porta.Write(cmd, 0, cmd.Length);
await Task.Delay(100, ct).ConfigureAwait(false);
string response = await LerAmostraAsync(porta, timeoutMs, ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(response) &&
response.Contains("UM982", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
public void Disconnect()
{
DisconnectInternal();
}
private void DisconnectInternal()
{
SerialPort port;
lock (_portLock)
{
port = PortaGps;
PortaGps = null;
}
if (port == null)
return;
try { port.DataReceived -= PortaGPS_DataReceived; } catch { }
try { if (port.IsOpen) port.Close(); } catch { }
try { port.Dispose(); } catch { }
}
private async Task RestoreExpectedRoleAfterReconnectAsync(CancellationToken ct)
{
// A regra mais importante é: uma reconexão nunca muda o papel do módulo
// para rover por conta própria.
if (_expectedRole == GnssExpectedRole.BaseFixed && _lastBaseConfiguration != null)
{
Models.Variaveis.MostrarLog("Restaurando configuração conhecida da base fixa após reconexão.");
await BaseFix.AplicarBaseFixAsync(
_lastBaseConfiguration.PortaUsb,
_lastBaseConfiguration.PortaSaida,
_lastBaseConfiguration.BaseId,
_lastBaseConfiguration.Latitude,
_lastBaseConfiguration.Longitude,
_lastBaseConfiguration.AltitudeElipsoidal,
ct).ConfigureAwait(false);
}
else if (_expectedRole == GnssExpectedRole.RoverTemporary)
{
Models.Variaveis.MostrarLog("Restaurando modo rover temporário após reconexão.");
await BaseFix.ConfigurarComoRoverParadoAsync("com2", "com2", ct)
.ConfigureAwait(false);
}
// PreserveCurrentConfiguration: não envia unlog, mode rover ou saveconfig.
}
// =========================================================
// ESCRITA SERIAL SERIALIZADA
// =========================================================
internal async Task WriteSerialAsync(
byte[] data,
CancellationToken ct = default)
{
if (data == null || data.Length == 0)
return;
await _serialWriteGate.WaitAsync(ct).ConfigureAwait(false);
try
{
SerialPort port;
lock (_portLock)
port = PortaGps;
if (port == null || !port.IsOpen)
throw new IOException("Porta do GNSS não está conectada.");
port.Write(data, 0, data.Length);
}
finally
{
_serialWriteGate.Release();
}
}
internal Task WriteSerialCommandAsync(
string command,
CancellationToken ct = default)
{
return WriteSerialAsync(Encoding.ASCII.GetBytes(command), ct);
}
internal async Task SendCommandsAsync(
IEnumerable<string> commands,
int delayMs,
CancellationToken ct = default)
{
foreach (string command in commands)
{
ct.ThrowIfCancellationRequested();
await WriteSerialCommandAsync(command, ct).ConfigureAwait(false);
if (delayMs > 0)
await Task.Delay(delayMs, ct).ConfigureAwait(false);
}
}
// =========================================================
// CONFIGURAÇÃO DO MÓDULO
// =========================================================
public async Task ConfigurarModulo(
bool fixar,
MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip,
double? lat = null,
double? lon = null,
double? alt = null,
bool? offset = false)
{
Models.Variaveis.MostrarLog("Iniciando configuração do módulo GPS...");
BaseFix ??= new BaseFixService(PortaGps, this);
BaseFix.FixLiberado = fixar;
const string portaUsb = "com2";
const string portaSaida = "com2";
const string portaEntrada = "com2";
const string baseId = "957";
if (!fixar)
{
Models.Variaveis.MostrarLog("Configurando explicitamente o módulo como rover parado.");
_expectedRole = GnssExpectedRole.RoverTemporary;
await BaseFix.ConfigurarComoRoverParadoAsync(
portaUsb,
portaEntrada,
_lifetimeCts.Token).ConfigureAwait(false);
return;
}
bool sucesso = false;
string mensagem;
try
{
BaseFix.fimProcesso = null;
BaseFix.inicioProcesso = DateTime.UtcNow;
BaseFix.inicioFix = DateTime.UtcNow;
BaseFix.CorrecaoEmAndamento = true;
Models.Variaveis.MostrarLog($"Aplicando posição fixa da base com o método {metodo}...");
switch (metodo)
{
case MetodoFixacaoBase.Ntrip:
BaseFix.DefinirTempos(
segsFixEstavel: TempoSurveryIn,
segsJanelaSegs: 600,
metodo: metodo);
sucesso = await BaseFix.FixarBaseViaNtripAsync(
portaUsb: portaUsb,
portaSaida: portaSaida,
portaEntrada: portaEntrada,
baseId: baseId,
startNtrip: StartNtripAsync,
stopNtrip: StopNtripAsync,
ct: _lifetimeCts.Token).ConfigureAwait(false);
break;
case MetodoFixacaoBase.SurveyIn:
BaseFix.DefinirTempos(
segsFixEstavel: TempoSurveryIn,
segsJanelaSegs: TempoSurveryIn + 30,
metodo: metodo);
await ConfigurarModuloBase(
tempo_fixacao: TempoSurveryIn,
porta_usb: portaUsb,
porta_saida: portaSaida,
ct: _lifetimeCts.Token).ConfigureAwait(false);
DateTime surveyDeadline = DateTime.UtcNow.AddSeconds(TempoSurveryIn + 30);
while (DateTime.UtcNow < surveyDeadline &&
GetFixQualitySnapshot() != TiposCorrecaoGPS.BaseFix)
{
await Task.Delay(500, _lifetimeCts.Token).ConfigureAwait(false);
}
sucesso = GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix;
break;
case MetodoFixacaoBase.Manual:
if (lat.HasValue && lon.HasValue && alt.HasValue)
{
BaseFix.DefinirTempos(10, 10, metodo);
await BaseFix.AplicarBaseFixAsync(
portaUsb,
portaSaida,
baseId,
lat.Value,
lon.Value,
alt.Value,
_lifetimeCts.Token).ConfigureAwait(false);
sucesso = true;
}
break;
}
if (sucesso)
{
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline &&
GetFixQualitySnapshot() != TiposCorrecaoGPS.BaseFix)
{
await Task.Delay(500, _lifetimeCts.Token).ConfigureAwait(false);
}
sucesso = GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix;
}
}
catch (OperationCanceledException)
{
sucesso = false;
}
catch (Exception ex)
{
sucesso = false;
Models.Variaveis.MostrarLog($"Erro ao definir posição da base: {ex.Message}");
}
finally
{
BaseFix.fimProcesso = DateTime.UtcNow;
BaseFix.FixLiberado = false;
BaseFix.CorrecaoEmAndamento = false;
if (sucesso)
{
var snapshot = GetPositionSnapshot();
_expectedRole = GnssExpectedRole.BaseFixed;
_lastBaseConfiguration = new BaseFixedConfiguration
{
PortaUsb = portaUsb,
PortaSaida = portaSaida,
BaseId = baseId,
Latitude = snapshot.Latitude,
Longitude = snapshot.Longitude,
AltitudeElipsoidal = snapshot.AltitudeElipsoidal,
};
if (!(offset ?? false))
{
BaseFix.LatitudeFix = snapshot.Latitude;
BaseFix.LongitudeFix = snapshot.Longitude;
BaseFix.AltitudeElpsoidalFix = snapshot.AltitudeElipsoidal;
BaseFix.OrientacaoFix = snapshot.Orientacao;
}
}
if (!(offset ?? false) &&
AppShell.Mock &&
lat.HasValue && lon.HasValue && alt.HasValue)
{
lock (_modelLock)
{
UltimaLeitura.Latitude = lat.Value;
UltimaLeitura.Longitude = lon.Value;
UltimaLeitura.AltitudeElipsoidal = alt.Value;
}
AtualizarCoordenadasGPS();
sucesso = true;
}
mensagem = sucesso
? $"Posição da base aplicada com sucesso com o método {metodo}."
: $"Falha ao aplicar posição da base com o método {metodo}.";
Models.Variaveis.MostrarLog(mensagem);
Models.Variaveis.Dock?._vm?.FinalizarFixacaoBase(sucesso, mensagem);
}
}
private async Task ConfigurarModuloBase(
string porta_usb = "com3",
string porta_saida = "com2",
int tempo_fixacao = 60,
CancellationToken ct = default)
{
const string baseId = "957";
const string distanciaMin = "0";
string[] commands =
{
"config com1 115200\r\n",
"config com2 115200\r\n",
"config com3 115200\r\n",
"unlog com1\r\n",
"unlog com2\r\n",
"unlog com3\r\n",
$"mode base {baseId} time {tempo_fixacao} {distanciaMin}\r\n",
$"RTCM1006 {porta_saida} 10\r\n",
$"RTCM1033 {porta_saida} 30\r\n",
$"RTCM1074 {porta_saida} 1\r\n",
$"RTCM1084 {porta_saida} 1\r\n",
$"RTCM1094 {porta_saida} 1\r\n",
$"RTCM1124 {porta_saida} 1\r\n",
$"RTCM1230 {porta_saida} 10\r\n",
$"gngga {porta_usb} 1\r\n",
"saveconfig\r\n",
};
await Task.Delay(1000, ct).ConfigureAwait(false);
await SendCommandsAsync(commands, 300, ct).ConfigureAwait(false);
_expectedRole = GnssExpectedRole.BaseFixed;
}
// =========================================================
// LEITURA SERIAL E DEMULTIPLEXAÇÃO
// =========================================================
private ParserMode _parserMode = ParserMode.Idle;
private readonly List<byte> _rtcmBuffer = new(1100);
private readonly List<byte> _nmeaBuffer = new(256);
private int _rtcmExpectedBytes = -1;
private double _frameStartedMono;
private void PortaGPS_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
SerialPort port;
lock (_portLock)
port = PortaGps;
if (port == null || !port.IsOpen)
return;
int available = port.BytesToRead;
if (available <= 0)
return;
byte[] buffer = new byte[available];
int bytesRead = port.Read(buffer, 0, buffer.Length);
if (bytesRead <= 0)
return;
double now = MonotonicNow();
_lastSerialRxMono = now;
Interlocked.Add(ref _serialBytesReceived, bytesRead);
List<string> nmeaLines = new();
List<byte[]> rtcmMessages = new();
lock (_parserLock)
{
for (int i = 0; i < bytesRead; i++)
{
ProcessIncomingByte(buffer[i], nmeaLines, rtcmMessages);
}
}
foreach (string line in nmeaLines)
ProcessNmeaSafely(line);
foreach (byte[] message in rtcmMessages)
EnqueueRtcm(message);
lock (_modelLock)
{
UltimaLeitura.Momento = DateTime.Now;
UltimaLeitura.Inicializado = true;
}
}
catch (Exception ex)
{
Interlocked.Increment(ref _serialReadErrors);
Models.Variaveis.MostrarLog($"Erro ao processar dados da porta serial: {ex.Message}");
}
}
private void ProcessIncomingByte(
byte b,
List<string> nmeaLines,
List<byte[]> rtcmMessages)
{
double now = MonotonicNow();
if (_parserMode == ParserMode.Rtcm &&
(now - _frameStartedMono) > RtcmAssemblyTimeout.TotalSeconds)
{
ResetParserState();
Interlocked.Increment(ref _rtcmResyncs);
}
else if (_parserMode == ParserMode.Nmea &&
(now - _frameStartedMono) > NmeaAssemblyTimeout.TotalSeconds)
{
ResetParserState();
Interlocked.Increment(ref _nmeaInvalid);
}
switch (_parserMode)
{
case ParserMode.Idle:
StartFrameIfApplicable(b, now);
break;
case ParserMode.Nmea:
// Uma sentença quebrada não pode esconder um RTCM novo.
if (b == 0xD3)
{
Interlocked.Increment(ref _nmeaInvalid);
StartRtcm(now);
return;
}
if (b == (byte)'$')
{
// Reinicia na sentença NMEA mais recente.
StartNmea(now);
return;
}
if (b == (byte)'\n')
{
_nmeaBuffer.Add(b);
string line = Encoding.ASCII.GetString(_nmeaBuffer.ToArray()).Trim();
ResetParserState();
if (!string.IsNullOrWhiteSpace(line))
nmeaLines.Add(line);
return;
}
if (b == (byte)'\r' || (b >= 0x20 && b <= 0x7E))
{
_nmeaBuffer.Add(b);
}
else
{
ResetParserState();
Interlocked.Increment(ref _nmeaInvalid);
StartFrameIfApplicable(b, now);
return;
}
if (_nmeaBuffer.Count > 512)
{
ResetParserState();
Interlocked.Increment(ref _nmeaInvalid);
}
break;
case ParserMode.Rtcm:
_rtcmBuffer.Add(b);
if (_rtcmBuffer.Count == 3)
{
// Os seis bits superiores do byte 1 são reservados e devem ser zero.
if ((_rtcmBuffer[1] & 0xFC) != 0)
{
Interlocked.Increment(ref _rtcmInvalidLength);
ResetAndResyncFromCurrentBuffer(now);
return;
}
int payloadLength = ((_rtcmBuffer[1] & 0x03) << 8) | _rtcmBuffer[2];
_rtcmExpectedBytes = 3 + payloadLength + 3;
if (payloadLength <= 0 || _rtcmExpectedBytes > 1029)
{
Interlocked.Increment(ref _rtcmInvalidLength);
ResetAndResyncFromCurrentBuffer(now);
return;
}
}
if (_rtcmExpectedBytes > 0 && _rtcmBuffer.Count == _rtcmExpectedBytes)
{
byte[] message = _rtcmBuffer.ToArray();
ResetParserState();
if (ValidateRtcmCrc24Q(message))
{
Interlocked.Increment(ref _rtcmValid);
_lastValidRtcmMono = now;
rtcmMessages.Add(message);
}
else
{
Interlocked.Increment(ref _rtcmCrcErrors);
Interlocked.Increment(ref _rtcmResyncs);
}
}
else if (_rtcmExpectedBytes > 0 && _rtcmBuffer.Count > _rtcmExpectedBytes)
{
Interlocked.Increment(ref _rtcmResyncs);
ResetAndResyncFromCurrentBuffer(now);
}
break;
}
}
private void StartFrameIfApplicable(byte b, double now)
{
if (b == 0xD3)
StartRtcm(now);
else if (b == (byte)'$')
StartNmea(now);
}
private void StartNmea(double now)
{
ResetParserState();
_parserMode = ParserMode.Nmea;
_frameStartedMono = now;
_nmeaBuffer.Add((byte)'$');
}
private void StartRtcm(double now)
{
ResetParserState();
_parserMode = ParserMode.Rtcm;
_frameStartedMono = now;
_rtcmBuffer.Add(0xD3);
}
private void ResetAndResyncFromCurrentBuffer(double now)
{
byte[] candidate = _rtcmBuffer.Skip(1).ToArray();
ResetParserState();
Interlocked.Increment(ref _rtcmResyncs);
foreach (byte b in candidate)
{
if (_parserMode == ParserMode.Idle)
{
if (b == 0xD3)
{
StartRtcm(now);
}
else if (b == (byte)'$')
{
StartNmea(now);
}
}
else if (_parserMode == ParserMode.Rtcm)
{
_rtcmBuffer.Add(b);
if (_rtcmBuffer.Count == 3)
{
int len = ((_rtcmBuffer[1] & 0x03) << 8) | _rtcmBuffer[2];
_rtcmExpectedBytes = 3 + len + 3;
}
}
else if (_parserMode == ParserMode.Nmea)
{
if (b == (byte)'\n')
{
ResetParserState();
}
else if (b == (byte)'\r' || (b >= 0x20 && b <= 0x7E))
{
_nmeaBuffer.Add(b);
}
}
}
}
private void ResetParsers()
{
lock (_parserLock)
ResetParserState();
}
private void ResetParserState()
{
_parserMode = ParserMode.Idle;
_rtcmBuffer.Clear();
_nmeaBuffer.Clear();
_rtcmExpectedBytes = -1;
_frameStartedMono = 0;
}
// =========================================================
// RTCM: VALIDAÇÃO, FILA E PUBLICAÇÃO
// =========================================================
private void EnqueueRtcm(byte[] message)
{
int type = GetRtcmMessageType(message);
if (type < 0)
{
Interlocked.Increment(ref _rtcmInvalidLength);
return;
}
RtcmEnvelope envelope = new()
{
Type = type,
Data = message,
ReceivedMono = MonotonicNow(),
Sequence = Interlocked.Increment(ref _rtcmSequence),
};
bool shouldSignal;
lock (_rtcmQueueLock)
{
shouldSignal = _rtcmLatestByType.Count == 0;
if (_rtcmLatestByType.ContainsKey(type))
Interlocked.Increment(ref _rtcmReplaced);
_rtcmLatestByType[type] = envelope;
Interlocked.Increment(ref _rtcmQueued);
}
if (shouldSignal)
{
try { _rtcmSignal.Release(); } catch (SemaphoreFullException) { }
}
}
private async Task RtcmPublisherLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await _rtcmSignal.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
while (!ct.IsCancellationRequested)
{
List<RtcmEnvelope> batch;
lock (_rtcmQueueLock)
{
if (_rtcmLatestByType.Count == 0)
break;
batch = _rtcmLatestByType.Values
.OrderBy(x => x.Sequence)
.ToList();
_rtcmLatestByType.Clear();
}
foreach (RtcmEnvelope item in batch)
{
if (ct.IsCancellationRequested)
break;
double age = MonotonicNow() - item.ReceivedMono;
if (age > RtcmMaxQueueAge.TotalSeconds)
{
Interlocked.Increment(ref _rtcmDroppedStale);
continue;
}
try
{
// A chamada externa fica isolada neste worker. Se MQTT bloquear,
// a serial continua lendo e a fila mantém somente o RTCM mais novo por tipo.
VariaveisControleOperacao.EnviarDadosCorrecaoRTCM(item.Data);
_lastForwardedRtcmMono = MonotonicNow();
Interlocked.Increment(ref _rtcmForwarded);
}
catch (Exception ex)
{
Interlocked.Increment(ref _rtcmPublishErrors);
Models.Variaveis.MostrarLog(
$"Erro ao encaminhar RTCM tipo {item.Type}: {ex.Message}");
}
}
}
}
}
private static int GetRtcmMessageType(byte[] message)
{
if (message == null || message.Length < 8 || message[0] != 0xD3)
return -1;
return (message[3] << 4) | (message[4] >> 4);
}
private static bool ValidateRtcmCrc24Q(byte[] message)
{
if (message == null || message.Length < 6)
return false;
uint calculated = ComputeCrc24Q(message, 0, message.Length - 3);
uint received =
((uint)message[^3] << 16) |
((uint)message[^2] << 8) |
message[^1];
return calculated == received;
}
private static uint ComputeCrc24Q(byte[] data, int offset, int count)
{
uint crc = 0;
for (int i = offset; i < offset + count; i++)
{
crc ^= (uint)data[i] << 16;
for (int bit = 0; bit < 8; bit++)
{
crc <<= 1;
if ((crc & 0x1000000) != 0)
crc ^= 0x1864CFB;
}
}
return crc & 0xFFFFFF;
}
// =========================================================
// NMEA
// =========================================================
private void ProcessNmeaSafely(string sentence)
{
if (string.IsNullOrWhiteSpace(sentence))
return;
try
{
if (!TryValidateNmeaChecksum(sentence, out bool checksumPresent))
{
Interlocked.Increment(ref _nmeaChecksumErrors);
Interlocked.Increment(ref _nmeaInvalid);
return;
}
ProcessarDadosNMEA(sentence);
_lastValidNmeaMono = MonotonicNow();
Interlocked.Increment(ref _nmeaValid);
if (sentence.StartsWith("$GNGGA", StringComparison.Ordinal) ||
sentence.StartsWith("$GPGGA", StringComparison.Ordinal) ||
sentence.StartsWith("$GLGGA", StringComparison.Ordinal))
{
_lastValidGgaMono = MonotonicNow();
Interlocked.Increment(ref _ggaSequence);
}
}
catch (Exception ex)
{
Interlocked.Increment(ref _nmeaInvalid);
Models.Variaveis.MostrarLog($"Sentença NMEA inválida descartada: {ex.Message}");
}
}
private static bool TryValidateNmeaChecksum(string sentence, out bool checksumPresent)
{
checksumPresent = false;
if (string.IsNullOrWhiteSpace(sentence) || sentence[0] != '$')
return false;
int star = sentence.IndexOf('*');
if (star < 0)
{
// Alguns comandos/respostas do fabricante podem não trazer checksum.
return true;
}
checksumPresent = true;
if (star + 2 >= sentence.Length)
return false;
byte checksum = 0;
for (int i = 1; i < star; i++)
checksum ^= (byte)sentence[i];
string expectedText = sentence.Substring(star + 1, 2);
return byte.TryParse(
expectedText,
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out byte expected) &&
checksum == expected;
}
private void ProcessarDadosNMEA(string sentenca)
{
DateTime agora = DateTime.Now;
if (sentenca.StartsWith("$GPGGA", StringComparison.Ordinal))
{
ProcessarGPGGA(sentenca);
AtualizarCoordenadasGPS();
}
else if (sentenca.StartsWith("$GNGGA", StringComparison.Ordinal) ||
sentenca.StartsWith("$GLGGA", StringComparison.Ordinal))
{
ProcessarGNGGA(sentenca);
AtualizarCoordenadasGPS();
}
else if (sentenca.StartsWith("$GNRMC", StringComparison.Ordinal))
{
ProcessarGxRMC(sentenca);
}
else if (sentenca.StartsWith("$GNVTG", StringComparison.Ordinal) ||
sentenca.StartsWith("$GPVTG", StringComparison.Ordinal))
{
ProcessarGNVTG(sentenca);
}
else if (sentenca.StartsWith("$GPGSV", StringComparison.Ordinal) ||
sentenca.StartsWith("$GLGSV", StringComparison.Ordinal) ||
sentenca.StartsWith("$GBGSV", StringComparison.Ordinal) ||
sentenca.StartsWith("$GAGSV", StringComparison.Ordinal))
{
ProcessarGSV(sentenca);
}
else if (sentenca.StartsWith("$GNTHS", StringComparison.Ordinal) ||
sentenca.StartsWith("$GPTHS", StringComparison.Ordinal) ||
sentenca.StartsWith("$GATHS", StringComparison.Ordinal))
{
ProcessarGNTHS(sentenca);
}
else if (sentenca.Length > 6 &&
sentenca[3] == 'G' && sentenca[4] == 'L' && sentenca[5] == 'L')
{
ProcessarGNGLL(sentenca);
}
else if (sentenca.Length > 6 &&
sentenca[3] == 'G' && sentenca[4] == 'S' && sentenca[5] == 'A')
{
ProcessarGxGSA(sentenca);
}
else if (sentenca.Length > 6 &&
sentenca[3] == 'R' && sentenca[4] == 'M' && sentenca[5] == 'C')
{
ProcessarGxRMC(sentenca);
}
lock (_modelLock)
UltimaLeitura.UltimoComandoRespondido = agora;
}
private void ProcessarGPGGA(string sentenca)
{
string[] parts = sentenca.Split(',');
if (parts.Length < 10)
throw new FormatException("GPGGA incompleta.");
lock (_modelLock)
{
UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
UltimaLeitura.Momento = DateTime.Now;
if (!string.IsNullOrWhiteSpace(parts[2]) && !string.IsNullOrWhiteSpace(parts[3]))
UltimaLeitura.Latitude = GPSUtils.ConvertToDecimalDegrees(parts[2], parts[3], 2);
if (!string.IsNullOrWhiteSpace(parts[4]) && !string.IsNullOrWhiteSpace(parts[5]))
UltimaLeitura.Longitude = GPSUtils.ConvertToDecimalDegrees(parts[4], parts[5], 3);
UltimaLeitura.NumeroSatelites = ParseInt(parts[7], 0);
UltimaLeitura.PrecisaoHorizontal = ParseDouble(parts[8], 0);
UltimaLeitura.Altitude = ParseDouble(parts[9], 0);
}
}
private void ProcessarGNGGA(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 15)
throw new FormatException("GNGGA incompleta.");
string horaUtc = campos[1];
string latitudeRaw = campos[2];
string hemisferioLat = campos[3];
string longitudeRaw = campos[4];
string hemisferioLon = campos[5];
int fixCode = ParseInt(campos[6], 0);
int satelites = ParseInt(campos[7], 0);
double hdop = ParseDouble(campos[8], 99.9);
double altMsl = ParseDouble(campos[9], 0);
double geoidSep = ParseDouble(campos[11], 0);
string idadeRaw = campos[13];
string baseId = campos[14].Split('*')[0];
double latitude = ParseDmm(latitudeRaw, hemisferioLat, 2);
double longitude = ParseDmm(longitudeRaw, hemisferioLon, 3);
double idadeCorrecao = -1;
if (!string.IsNullOrWhiteSpace(idadeRaw) &&
double.TryParse(
idadeRaw,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double parsedAge))
{
idadeCorrecao = parsedAge;
}
double altElipsoidal = altMsl + geoidSep;
lock (_modelLock)
{
UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
UltimaLeitura.Momento = DateTime.Now;
if (!double.IsNaN(latitude))
{
UltimaLeitura.LatitudeAnt = UltimaLeitura.Latitude;
UltimaLeitura.Latitude = latitude;
}
if (!double.IsNaN(longitude))
{
UltimaLeitura.LongitudeAnt = UltimaLeitura.Longitude;
UltimaLeitura.Longitude = longitude;
}
UltimaLeitura.Altitude = altMsl;
UltimaLeitura.AltitudeElipsoidal = altElipsoidal;
UltimaLeitura.PrecisaoHorizontal = hdop;
UltimaLeitura.NumeroSatelites = satelites;
UltimaLeitura.QualidadeFix = (TiposCorrecaoGPS)fixCode;
UltimaLeitura.IdadeCorrecao = idadeCorrecao;
UltimaLeitura.BaseID = baseId;
if (TryParseNmeaTime(horaUtc, out TimeSpan tod))
UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime();
if (!UltimaLeitura.EnuOriginSet &&
UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.RTKFixo &&
!double.IsNaN(latitude) &&
!double.IsNaN(longitude))
{
UltimaLeitura.Lat0 = latitude;
UltimaLeitura.Lon0 = longitude;
UltimaLeitura.EnuOriginSet = true;
}
if (UltimaLeitura.EnuOriginSet &&
!double.IsNaN(latitude) &&
!double.IsNaN(longitude))
{
(double latCor, double lonCor) = LeverArm.FixLeverArmLatLon_Fast(
latitude,
longitude,
UltimaLeitura.OrientacaoReal,
UltimaLeitura.TimestampOri.frequencia);
UltimaLeitura.Latitude = latCor;
UltimaLeitura.Longitude = lonCor;
}
}
}
private void ProcessarGNVTG(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 8)
throw new FormatException("VTG incompleta.");
double curso = ParseDouble(campos[1], 0);
double velocidadeKmh = ParseDouble(campos[7].Split('*')[0], 0);
lock (_modelLock)
{
UltimaLeitura.Momento = DateTime.Now;
UltimaLeitura.CursoVerdadeiro = curso;
UltimaLeitura.Velocidade = velocidadeKmh;
}
}
private void ProcessarGSV(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 4 || sentenca.Length < 3)
throw new FormatException("GSV incompleta.");
string sistema = sentenca.Substring(1, 2);
int total = ParseInt(campos[1], 0);
int atual = ParseInt(campos[2], 0);
int visiveis = ParseInt(campos[3], 0);
lock (_modelLock)
{
UltimaLeitura.Momento = DateTime.Now;
var leitura = UltimaLeitura.SatelitesEmVista
.FirstOrDefault(x => x.TipoSistema == sistema);
if (leitura == null)
{
leitura = new GPSSatelitesEmVistaModel
{
TipoSistema = sistema,
Sentencas = new List<GPSSatelitesEmVistaSentencaModel>(),
};
UltimaLeitura.SatelitesEmVista.Add(leitura);
}
var item = leitura.Sentencas.FirstOrDefault(x => x.SentencaAtual == atual);
if (item == null)
{
item = new GPSSatelitesEmVistaSentencaModel
{
SentencaAtual = atual,
SentencasTotal = total,
QuantidadeSatelites = visiveis,
Dados = new List<GPSSatelitesEmVistaDadosModel>(),
};
leitura.Sentencas.Add(item);
}
item.SentencasTotal = total;
item.QuantidadeSatelites = visiveis;
item.Dados = new List<GPSSatelitesEmVistaDadosModel>();
for (int i = 4; i + 3 < campos.Length; i += 4)
{
item.Dados.Add(new GPSSatelitesEmVistaDadosModel
{
PRN = campos[i],
Elevacao = ParseDouble(campos[i + 1], 0),
Azimute = ParseDouble(campos[i + 2], 0),
QualidadeSinal = ParseDouble(campos[i + 3].Split('*')[0], 0),
});
}
}
}
private void ProcessarGNTHS(string sentenca)
{
string[] campos = sentenca.TrimStart('$').Split(',');
if (campos.Length < 3)
throw new FormatException("THS incompleta.");
string status = campos[2].Split('*')[0];
bool headingValido = double.TryParse(
campos[1],
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double heading);
lock (_modelLock)
{
UltimaLeitura.TimestampOri.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
UltimaLeitura.Momento = DateTime.Now;
UltimaLeitura.TipoOrientacao = status;
if (headingValido)
{
UltimaLeitura.OrientacaoReal = InverterHeading
? GPSUtils.NormalizarAngulo(heading - 180.0)
: heading;
}
else
{
// Mantém o último heading numérico, mas atualiza o status V/A.
// Assim a saúde consegue distinguir dado inválido de ausência de sentença.
}
}
}
private void ProcessarGNGLL(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 7)
throw new FormatException("GLL incompleta.");
string mode = campos.Length > 7 ? campos[7].Split('*')[0] : string.Empty;
bool valido = campos[6] == "A" && mode != "N";
lock (_modelLock)
{
if (valido)
{
double lat = ParseDmm(campos[1], campos[2], 2);
double lon = ParseDmm(campos[3], campos[4], 3);
if (!double.IsNaN(lat)) UltimaLeitura.Latitude = lat;
if (!double.IsNaN(lon)) UltimaLeitura.Longitude = lon;
}
if (TryParseNmeaTime(campos[5], out TimeSpan tod))
UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime();
ApplyModeToFixQuality(mode);
}
AtualizarCoordenadasGPS();
}
private void ProcessarGxGSA(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 17)
throw new FormatException("GSA incompleta.");
int satsUsados = 0;
for (int i = 3; i <= 14 && i < campos.Length; i++)
if (!string.IsNullOrWhiteSpace(campos[i])) satsUsados++;
double pdop = ParseDouble(campos[15], double.NaN);
double hdop = ParseDouble(campos[16], double.NaN);
double vdop = campos.Length > 17
? ParseDouble(campos[17].Split('*')[0], double.NaN)
: double.NaN;
var fixDim = (TiposDimensaoCorrecaoGPS)ParseInt(campos[2], 1);
lock (_modelLock)
{
if (!double.IsNaN(hdop)) UltimaLeitura.PrecisaoHorizontal = hdop;
if (satsUsados > 0) UltimaLeitura.NumeroSatelites = Math.Max(UltimaLeitura.NumeroSatelites, satsUsados);
if (!double.IsNaN(pdop)) UltimaLeitura.PDOP = pdop;
if (!double.IsNaN(vdop)) UltimaLeitura.VDOP = vdop;
UltimaLeitura.FixDimensao = fixDim;
}
}
private void ProcessarGxRMC(string sentenca)
{
string[] campos = sentenca.Split(',');
if (campos.Length < 12)
throw new FormatException("RMC incompleta.");
string mode = string.Empty;
if (campos.Length > 12)
mode = campos[12].Split('*')[0].Trim();
bool valido = campos[2] == "A";
lock (_modelLock)
{
if (valido)
{
double lat = ParseDmm(campos[3], campos[4], 2);
double lon = ParseDmm(campos[5], campos[6], 3);
if (!double.IsNaN(lat)) UltimaLeitura.Latitude = lat;
if (!double.IsNaN(lon)) UltimaLeitura.Longitude = lon;
}
if (double.TryParse(campos[7], NumberStyles.Float, CultureInfo.InvariantCulture, out double speedKnots))
UltimaLeitura.Velocidade = speedKnots * 1.852; // km/h
if (double.TryParse(campos[8], NumberStyles.Float, CultureInfo.InvariantCulture, out double course))
UltimaLeitura.CursoVerdadeiro = course;
if (TryParseRmcDateTime(campos[1], campos[9], out DateTime dt))
UltimaLeitura.DataHora = dt.ToLocalTime();
if (double.TryParse(campos[10], NumberStyles.Float, CultureInfo.InvariantCulture, out double magVar))
{
if (campos[11].StartsWith("W", StringComparison.OrdinalIgnoreCase))
magVar = -magVar;
UltimaLeitura.VariacaoMagnetica = magVar;
}
ApplyModeToFixQuality(mode);
}
AtualizarCoordenadasGPS();
}
private void ApplyModeToFixQuality(string mode)
{
if (string.IsNullOrWhiteSpace(mode))
return;
UltimaLeitura.QualidadeFix = mode switch
{
"R" => TiposCorrecaoGPS.RTKFixo,
"F" => TiposCorrecaoGPS.RTKFlutuante,
"D" => TiposCorrecaoGPS.DGPS,
"E" => TiposCorrecaoGPS.DeadReckoing,
"A" => TiposCorrecaoGPS.Autonomo,
"N" => TiposCorrecaoGPS.SemCorrecao,
_ => UltimaLeitura.QualidadeFix,
};
}
// =========================================================
// NTRIP COM CANCELAMENTO REAL
// =========================================================
private CancellationTokenSource _ntripCts;
private Task _ntripTask;
private TcpClient _ntripClient;
private Task StartNtripAsync()
{
lock (_ntripLock)
{
if (_ntripTask != null && !_ntripTask.IsCompleted)
return Task.CompletedTask;
CorrecaoRTK_Ntrip = true;
_ntripCts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token);
_ntripTask = Task.Run(
() => AplicarCorrecaoRTK_NtripAsync(_ntripCts.Token),
_ntripCts.Token);
}
return Task.CompletedTask;
}
private async Task StopNtripAsync()
{
Task task;
TcpClient client;
lock (_ntripLock)
{
CorrecaoRTK_Ntrip = false;
_ntripCts?.Cancel();
client = _ntripClient;
task = _ntripTask;
}
try { client?.Close(); } catch { }
if (task != null)
{
try { await task.ConfigureAwait(false); }
catch (OperationCanceledException) { }
catch { }
}
lock (_ntripLock)
{
_ntripClient = null;
_ntripTask = null;
_ntripCts?.Dispose();
_ntripCts = null;
Ntrip_Conectado = false;
}
}
private async Task AplicarCorrecaoRTK_NtripAsync(CancellationToken ct)
{
if (!IsConnected || !APIService.HasInternet)
return;
double backoffSeconds = 1.0;
while (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip)
{
if (!APIService.HasInternet)
{
Ntrip_Conectado = false;
await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
continue;
}
TcpClient client = null;
try
{
client = new TcpClient();
lock (_ntripLock)
_ntripClient = client;
await ConnectTcpWithTimeoutAsync(
client,
NtripHost,
NtripPort,
TimeSpan.FromSeconds(8),
ct).ConfigureAwait(false);
using NetworkStream stream = client.GetStream();
string credentials = string.IsNullOrWhiteSpace(NtripUsername)
? string.Empty
: Convert.ToBase64String(
Encoding.ASCII.GetBytes($"{NtripUsername}:{NtripPassword}"));
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";
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" }, 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)
{
await WriteSerialAsync(firstBodyBytes, ct).ConfigureAwait(false);
Interlocked.Add(ref _ntripBytesReceived, firstBodyBytes.Length);
}
byte[] buffer = new byte[4096];
while (!ct.IsCancellationRequested && CorrecaoRTK_Ntrip)
{
int read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
if (read <= 0)
throw new IOException("Caster NTRIP 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);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
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;
}
}
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);
}
}
Ntrip_Conectado = false;
}
private static async Task ConnectTcpWithTimeoutAsync(
TcpClient client,
string host,
int port,
TimeSpan timeout,
CancellationToken ct)
{
Task connectTask = client.ConnectAsync(host, port);
Task timeoutTask = Task.Delay(timeout, ct);
Task completed = await Task.WhenAny(connectTask, timeoutTask).ConfigureAwait(false);
if (completed != connectTask)
{
ct.ThrowIfCancellationRequested();
throw new TimeoutException($"Timeout conectando em {host}:{port}.");
}
await connectTask.ConfigureAwait(false);
}
private static async Task<(string Header, byte[] FirstBodyBytes)> ReadHttpHeaderAsync(
NetworkStream stream,
int maxHeaderBytes,
CancellationToken ct)
{
List<byte> data = new(1024);
byte[] temp = new byte[512];
int headerEnd = -1;
int delimiterLength = 0;
while (headerEnd < 0)
{
int read = await stream.ReadAsync(temp, 0, temp.Length, ct).ConfigureAwait(false);
if (read <= 0)
throw new IOException("Conexão encerrada antes do cabeçalho NTRIP.");
for (int i = 0; i < read; i++)
data.Add(temp[i]);
if (data.Count > maxHeaderBytes)
throw new InvalidDataException("Cabeçalho NTRIP excedeu o limite.");
(headerEnd, delimiterLength) = FindNtripHeaderEnd(data);
}
byte[] all = data.ToArray();
string header = Encoding.ASCII.GetString(all, 0, headerEnd);
int bodyStart = headerEnd + delimiterLength;
byte[] body = bodyStart < all.Length
? all.Skip(bodyStart).ToArray()
: Array.Empty<byte>();
return (header, body);
}
private static (int HeaderEnd, int DelimiterLength) FindNtripHeaderEnd(List<byte> 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')
{
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')
{
for (int i = 0; i <= data.Count - 2; i++)
{
if (data[i] == '\r' && data[i + 1] == '\n')
return (i, 2);
}
}
return (-1, 0);
}
// =========================================================
// SNAPSHOTS E UI
// =========================================================
internal long GetGgaSequence() => Interlocked.Read(ref _ggaSequence);
internal bool TryGetGgaSnapshot(long afterSequence, out long sequence, out GgaFix snapshot)
{
sequence = Interlocked.Read(ref _ggaSequence);
snapshot = default;
if (sequence <= afterSequence)
return false;
lock (_modelLock)
{
snapshot = new GgaFix(
tsUtc: UltimaLeitura.DataHora.ToUniversalTime(),
latDeg: UltimaLeitura.Latitude,
lonDeg: UltimaLeitura.Longitude,
altElipsoidalM: UltimaLeitura.AltitudeElipsoidal,
headingDeg: UltimaLeitura.OrientacaoReal,
fixQuality: UltimaLeitura.QualidadeFix);
}
return true;
}
private TiposCorrecaoGPS GetFixQualitySnapshot()
{
lock (_modelLock)
return UltimaLeitura.QualidadeFix;
}
private PositionSnapshot GetPositionSnapshot()
{
lock (_modelLock)
{
return new PositionSnapshot
{
Latitude = UltimaLeitura.Latitude,
Longitude = UltimaLeitura.Longitude,
AltitudeElipsoidal = UltimaLeitura.AltitudeElipsoidal,
Orientacao = UltimaLeitura.OrientacaoReal,
};
}
}
private void AtualizarCoordenadasGPS()
{
lock (_modelLock)
{
UltimaLeitura.AnguloCarroDefinido = UltimaLeitura.OrientacaoReal;
UltimaLeitura.Ntrip_ativado = CorrecaoRTK_Ntrip;
UltimaLeitura.Heartbeat = (UltimaLeitura.Heartbeat + 1) % 10;
}
if (Interlocked.CompareExchange(ref _uiUpdatePending, 1, 0) != 0)
return;
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
try
{
Models.Variaveis.Dock?._vm?.AtualizarDadosGnss(UltimaLeitura);
}
catch (Exception ex)
{
Models.Variaveis.MostrarLog($"Erro ao atualizar UI GNSS: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _uiUpdatePending, 0);
}
}));
}
// =========================================================
// HELPERS
// =========================================================
private static double MonotonicNow() =>
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
private static double AgeMs(double now, double timestamp)
{
if (timestamp <= 0)
return -1;
return Math.Max(0, (now - timestamp) * 1000.0);
}
private static int ParseInt(string text, int fallback)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
? value
: fallback;
}
private static double ParseDouble(string text, double fallback)
{
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
? value
: fallback;
}
private static double ParseDmm(string raw, string hemisphere, int degreeDigits)
{
if (string.IsNullOrWhiteSpace(raw) || raw.Length <= degreeDigits)
return double.NaN;
if (!double.TryParse(
raw.Substring(0, degreeDigits),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double degrees) ||
!double.TryParse(
raw.Substring(degreeDigits),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double minutes))
{
return double.NaN;
}
double value = degrees + minutes / 60.0;
if (hemisphere.Equals("S", StringComparison.OrdinalIgnoreCase) ||
hemisphere.Equals("W", StringComparison.OrdinalIgnoreCase))
{
value = -value;
}
return value;
}
private static bool TryParseNmeaTime(string text, out TimeSpan value)
{
value = default;
if (string.IsNullOrWhiteSpace(text) || text.Length < 6)
return false;
if (!int.TryParse(text.Substring(0, 2), out int hh) ||
!int.TryParse(text.Substring(2, 2), out int mm) ||
!double.TryParse(
text.Substring(4),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double seconds))
{
return false;
}
int ss = (int)Math.Floor(seconds);
int ms = (int)Math.Round((seconds - ss) * 1000.0);
if (ms >= 1000)
{
ss++;
ms = 0;
}
try
{
value = new TimeSpan(0, hh, mm, ss, ms);
return true;
}
catch
{
return false;
}
}
private static bool TryParseRmcDateTime(
string timeText,
string dateText,
out DateTime utc)
{
utc = default;
if (!TryParseNmeaTime(timeText, out TimeSpan tod) ||
string.IsNullOrWhiteSpace(dateText) ||
dateText.Length != 6)
{
return false;
}
if (!int.TryParse(dateText.Substring(0, 2), out int day) ||
!int.TryParse(dateText.Substring(2, 2), out int month) ||
!int.TryParse(dateText.Substring(4, 2), out int yy))
{
return false;
}
try
{
utc = new DateTime(2000 + yy, month, day, 0, 0, 0, DateTimeKind.Utc).Add(tod);
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_tmrCheck.Stop();
_tmrCheck.Dispose();
try { StopNtripAsync().GetAwaiter().GetResult(); } catch { }
_lifetimeCts.Cancel();
try { _rtcmSignal.Release(); } catch { }
try { _rtcmPublisherTask.Wait(TimeSpan.FromSeconds(2)); } catch { }
DisconnectInternal();
_scanGate.Dispose();
_serialWriteGate.Dispose();
_rtcmSignal.Dispose();
_lifetimeCts.Dispose();
}
private enum ParserMode
{
Idle,
Nmea,
Rtcm,
}
private enum GnssExpectedRole
{
PreserveCurrentConfiguration,
RoverTemporary,
BaseFixed,
}
private sealed class RtcmEnvelope
{
public int Type { get; init; }
public byte[] Data { get; init; }
public double ReceivedMono { get; init; }
public long Sequence { get; init; }
}
private sealed class BaseFixedConfiguration
{
public string PortaUsb { get; init; }
public string PortaSaida { get; init; }
public string BaseId { get; init; }
public double Latitude { get; init; }
public double Longitude { get; init; }
public double AltitudeElipsoidal { get; init; }
}
private sealed class PositionSnapshot
{
public double Latitude { get; init; }
public double Longitude { get; init; }
public double AltitudeElipsoidal { get; init; }
public double Orientacao { get; init; }
}
}
// =============================================================
// BASE FIX SERVICE
// =============================================================
public class BaseFixService
{
public BaseFixService(SerialPort porta, GpsService service)
{
// O SerialPort recebido é mantido apenas por compatibilidade de assinatura.
// As escritas usam sempre o GpsService para não reter uma porta antiga após reconexão.
gpsService = service ?? throw new ArgumentNullException(nameof(service));
}
private readonly GpsService gpsService;
private long _lastGgaSequence;
public List<GgaFix> amostras_pos = new(1000);
public DateTime? inicioProcesso = null;
public DateTime? inicioFix = null;
public DateTime? fimProcesso = null;
private int segundosFixEstavel = 120;
private int maxJanelaSegundos = 120;
private MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip;
public double Progresso
{
get
{
if (inicioFix is null)
return 0;
if (fimProcesso != null)
return 100;
return Math.Clamp(
(DateTime.UtcNow - inicioFix.Value).TotalSeconds /
Math.Max(1, segundosFixEstavel) * 100.0,
0,
100);
}
}
public double ProgressoGeral
{
get
{
if (inicioProcesso is null)
return 0;
return Math.Clamp(
(DateTime.UtcNow - inicioProcesso.Value).TotalSeconds /
Math.Max(1, maxJanelaSegundos) * 100.0,
0,
100);
}
}
public string ProgressoStr
{
get
{
if (CorrecaoEmAndamento)
return $"Recebendo correção RTK via {metodo}. Progresso geral: {ProgressoGeral:F2}%, progresso da correção: {Progresso:F2}%";
if (PosicaoBaseFixada && CorrecaoAbsoluta && inicioProcesso.HasValue && fimProcesso.HasValue)
return $"Correção absoluta concluída com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos";
if (PosicaoBaseFixada && !CorrecaoAbsoluta)
return $"Correção relativa concluída em {segundosFixEstavel} segundos";
if (inicioProcesso.HasValue && fimProcesso.HasValue)
return $"Correção absoluta falhou com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos";
return "Correção absoluta não realizada";
}
}
public bool PosicaoBaseFixada =>
gpsService.UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.BaseFix;
public bool CorrecaoAbsoluta = false;
public bool CorrecaoEmAndamento = false;
public bool FixLiberado = false;
public double LatitudeFix { get; set; }
public double LongitudeFix { get; set; }
public double AltitudeElpsoidalFix { get; set; }
public double OrientacaoFix { get; set; }
public void DefinirTempos(
int segsFixEstavel,
int segsJanelaSegs,
MetodoFixacaoBase metodo)
{
segundosFixEstavel = Math.Max(1, segsFixEstavel);
maxJanelaSegundos = Math.Max(segundosFixEstavel, segsJanelaSegs);
this.metodo = metodo;
}
public void ReiniciarFix()
{
CorrecaoEmAndamento = false;
CorrecaoAbsoluta = false;
inicioFix = null;
inicioProcesso = null;
fimProcesso = null;
FixLiberado = false;
amostras_pos.Clear();
}
public async Task<bool> FixarBaseViaNtripAsync(
string portaUsb = "com3",
string portaEntrada = "com2",
string portaSaida = "com2",
string baseId = "957",
double madK = 3.5,
Func<Task> startNtrip = null,
Func<Task> stopNtrip = null,
CancellationToken ct = default)
{
if (!APIService.HasInternet)
return false;
await ConfigurarComoRoverParadoAsync(portaUsb, portaEntrada, ct)
.ConfigureAwait(false);
if (startNtrip != null)
await startNtrip().ConfigureAwait(false);
try
{
List<GgaFix> samples = await EsperarFixEAmostrarAsync(ct).ConfigureAwait(false);
if (samples.Count < 10)
{
Models.Variaveis.MostrarLog(
"Poucas amostras de RTK FIX coletadas. Verifique NTRIP e sinais GNSS.");
CorrecaoAbsoluta = false;
return false;
}
var (lat, lon, h, heading, count) = FiltrarEAgrupar(samples, madK);
if (count < 5 ||
double.IsNaN(lat) ||
double.IsNaN(lon) ||
double.IsNaN(h))
{
Models.Variaveis.MostrarLog("Filtro da posição da base não produziu amostras suficientes.");
CorrecaoAbsoluta = false;
return false;
}
// Para de injetar RTCM NTRIP e aguarda o socket realmente fechar
// antes de enviar comandos ASCII de configuração ao UM982.
if (stopNtrip != null)
await stopNtrip().ConfigureAwait(false);
await AplicarBaseFixAsync(
portaUsb,
portaSaida,
baseId,
lat,
lon,
h,
ct).ConfigureAwait(false);
Models.Variaveis.MostrarLog(
$"[BASE/FIX] Coordenadas aplicadas (n={count}): lat={lat:0.000000000}, lon={lon:0.000000000}, h={h:0.000}, heading={heading:0.00}");
CorrecaoAbsoluta = true;
return true;
}
finally
{
if (stopNtrip != null)
{
try { await stopNtrip().ConfigureAwait(false); }
catch { }
}
}
}
public async Task ConfigurarComoRoverParadoAsync(
string portaUsb,
string portaEntrada,
CancellationToken ct = default)
{
Models.Variaveis.MostrarLog("Configurando GNSS temporariamente como rover parado...");
string[] commands =
{
$"config {portaUsb} 115200\r\n",
$"config {portaEntrada} 115200\r\n",
"unlog com1\r\n",
"unlog com2\r\n",
"unlog com3\r\n",
"mode rover uav\r\n",
$"gngga {portaUsb} 1\r\n",
$"gpths {portaUsb} 1\r\n",
// Não salva a configuração rover temporária.
};
await gpsService.SendCommandsAsync(commands, 250, ct).ConfigureAwait(false);
}
private async Task<List<GgaFix>> EsperarFixEAmostrarAsync(CancellationToken ct)
{
Models.Variaveis.MostrarLog("Iniciando coleta de dados para fixação da base...");
amostras_pos = new List<GgaFix>(1000);
inicioProcesso = DateTime.UtcNow;
inicioFix = null;
_lastGgaSequence = gpsService.GetGgaSequence();
DateTime noFixDeadline = DateTime.UtcNow.AddMinutes(2);
while (ProgressoGeral < 100 && FixLiberado)
{
ct.ThrowIfCancellationRequested();
if (inicioFix is null && DateTime.UtcNow >= noFixDeadline)
{
Models.Variaveis.MostrarLog("Timeout aguardando RTK FIX via NTRIP.");
break;
}
GgaFix gga = await LerProximoGgaAsync(5000, ct).ConfigureAwait(false);
if (gga is null)
continue;
bool valid = gga.FixQuality == TiposCorrecaoGPS.RTKFixo;
if (!valid &&
inicioProcesso.HasValue &&
(DateTime.UtcNow - inicioProcesso.Value).TotalSeconds > 120)
{
valid = gga.FixQuality == TiposCorrecaoGPS.RTKFlutuante;
}
if (!FixLiberado || !valid)
{
inicioFix = null;
amostras_pos.Clear();
continue;
}
if (inicioFix is null)
{
Models.Variaveis.MostrarLog("RTK válido definido. Iniciando janela de coleta estável...");
inicioFix = DateTime.UtcNow;
amostras_pos.Clear();
}
amostras_pos.Add(gga);
if (Progresso >= 100)
break;
}
return amostras_pos;
}
private async Task<GgaFix> LerProximoGgaAsync(
int timeoutMs = 5000,
CancellationToken ct = default)
{
Stopwatch sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested && sw.ElapsedMilliseconds < timeoutMs)
{
if (gpsService.TryGetGgaSnapshot(
_lastGgaSequence,
out long sequence,
out GgaFix snapshot))
{
_lastGgaSequence = sequence;
return snapshot;
}
await Task.Delay(50, ct).ConfigureAwait(false);
}
return null;
}
private (double lat, double lon, double h, double hdg, int n) FiltrarEAgrupar(
List<GgaFix> samples,
double madK = 3.5)
{
Models.Variaveis.MostrarLog("Filtrando dados aferidos...");
if (samples == null || samples.Count == 0)
return (double.NaN, double.NaN, double.NaN, double.NaN, 0);
double medLat = Median(samples.Select(x => x.LatDeg));
double medLon = Median(samples.Select(x => x.LonDeg));
double medH = Median(samples.Select(x => x.AltElipsoidalM));
double[] validHeadings = samples
.Select(x => x.HeadingDeg)
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x))
.ToArray();
bool useHeadingFilter = validHeadings.Length >= Math.Max(5, samples.Count / 2);
double medHeading = useHeadingFilter
? CircularMedian(validHeadings)
: double.NaN;
double madLat = Math.Max(1e-12, Median(samples.Select(x => Math.Abs(x.LatDeg - medLat))));
double madLon = Math.Max(1e-12, Median(samples.Select(x => Math.Abs(x.LonDeg - medLon))));
double madH = Math.Max(1e-9, Median(samples.Select(x => Math.Abs(x.AltElipsoidalM - medH))));
double madHeading = useHeadingFilter
? Math.Max(0.01, Median(validHeadings.Select(x => AngularDistanceDegrees(x, medHeading))))
: 1.0;
List<GgaFix> filtered = samples.Where(x =>
Math.Abs(x.LatDeg - medLat) / madLat <= madK &&
Math.Abs(x.LonDeg - medLon) / madLon <= madK &&
Math.Abs(x.AltElipsoidalM - medH) / madH <= madK &&
(!useHeadingFilter ||
AngularDistanceDegrees(x.HeadingDeg, medHeading) / madHeading <= madK))
.ToList();
if (filtered.Count == 0)
return (double.NaN, double.NaN, double.NaN, double.NaN, 0);
double heading = CircularMean(
filtered
.Select(x => x.HeadingDeg)
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x)));
return (
filtered.Average(x => x.LatDeg),
filtered.Average(x => x.LonDeg),
filtered.Average(x => x.AltElipsoidalM),
heading,
filtered.Count);
}
public async Task AplicarBaseFixAsync(
string portaUsb,
string portaSaida,
string baseId,
double latDeg,
double lonDeg,
double hEllipsM,
CancellationToken ct = default)
{
Models.Variaveis.MostrarLog("Aplicando dados de correção da base...");
string lat = latDeg.ToString("0.000000000", CultureInfo.InvariantCulture);
string lon = lonDeg.ToString("0.000000000", CultureInfo.InvariantCulture);
string h = hEllipsM.ToString("0.000", CultureInfo.InvariantCulture);
string[] commands =
{
"unlog com1\r\n",
"unlog com2\r\n",
"unlog com3\r\n",
$"mode base {baseId} {lat} {lon} {h}\r\n",
$"RTCM1006 {portaSaida} 10\r\n",
$"RTCM1033 {portaSaida} 30\r\n",
$"RTCM1074 {portaSaida} 1\r\n",
$"RTCM1084 {portaSaida} 1\r\n",
$"RTCM1094 {portaSaida} 1\r\n",
$"RTCM1124 {portaSaida} 1\r\n",
$"RTCM1230 {portaSaida} 10\r\n",
$"gngga {portaUsb} 1\r\n",
$"gpths {portaUsb} 1\r\n",
"saveconfig\r\n",
};
await gpsService.SendCommandsAsync(commands, 200, ct).ConfigureAwait(false);
}
private static double Median(IEnumerable<double> values)
{
double[] ordered = values
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x))
.OrderBy(x => x)
.ToArray();
if (ordered.Length == 0)
return double.NaN;
int middle = ordered.Length / 2;
return ordered.Length % 2 == 1
? ordered[middle]
: (ordered[middle - 1] + ordered[middle]) / 2.0;
}
private static double CircularMean(IEnumerable<double> headings)
{
double[] valid = headings
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x))
.ToArray();
if (valid.Length == 0)
return double.NaN;
double sin = valid.Sum(x => Math.Sin(x * Math.PI / 180.0));
double cos = valid.Sum(x => Math.Cos(x * Math.PI / 180.0));
double angle = Math.Atan2(sin, cos) * 180.0 / Math.PI;
return (angle + 360.0) % 360.0;
}
private static double CircularMedian(IEnumerable<double> headings)
{
double[] valid = headings
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x))
.Select(x => (x % 360.0 + 360.0) % 360.0)
.ToArray();
if (valid.Length == 0)
return double.NaN;
return valid
.OrderBy(candidate => valid.Sum(x => AngularDistanceDegrees(x, candidate)))
.First();
}
private static double AngularDistanceDegrees(double a, double b)
{
double diff = Math.Abs(((a - b + 540.0) % 360.0) - 180.0);
return diff;
}
public enum MetodoFixacaoBase
{
Ntrip = 0,
SurveyIn = 1,
Manual = 2,
}
}
public sealed class GpsTransportMetrics
{
public bool SerialConnected { get; init; }
public string PortName { get; init; }
public double SerialLastRxAgeMs { get; init; }
public double LastValidNmeaAgeMs { get; init; }
public double LastValidGgaAgeMs { get; init; }
public double LastValidRtcmAgeMs { get; init; }
public double LastForwardedRtcmAgeMs { get; init; }
public long SerialBytesReceived { get; init; }
public long SerialReadErrors { get; init; }
public long NmeaValid { get; init; }
public long NmeaInvalid { get; init; }
public long NmeaChecksumErrors { get; init; }
public long RtcmValid { get; init; }
public long RtcmCrcErrors { get; init; }
public long RtcmInvalidLength { get; init; }
public long RtcmResyncs { get; init; }
public long RtcmQueued { get; init; }
public long RtcmReplaced { get; init; }
public long RtcmDroppedStale { get; init; }
public long RtcmForwarded { get; init; }
public long RtcmPublishErrors { get; init; }
public int RtcmQueueDepth { get; init; }
public double RtcmOldestQueueAgeMs { get; init; }
public long SerialReconnects { get; init; }
public bool NtripConnected { get; init; }
public long NtripBytesReceived { get; init; }
public long NtripReconnects { get; init; }
public long NtripErrors { get; init; }
public string ExpectedRole { get; init; }
}
}