diff --git a/AgroBase/AgroBase/AgroBase.csproj b/AgroBase/AgroBase/AgroBase.csproj index 94e27671c..fab4b68ed 100644 --- a/AgroBase/AgroBase/AgroBase.csproj +++ b/AgroBase/AgroBase/AgroBase.csproj @@ -517,6 +517,7 @@ frmSimulacaoMapeamentoVisual.cs + diff --git a/AgroBase/AgroBase/Forms/frmInstancial.cs b/AgroBase/AgroBase/Forms/frmInstancial.cs index 80fc81833..d1c968f5e 100644 --- a/AgroBase/AgroBase/Forms/frmInstancial.cs +++ b/AgroBase/AgroBase/Forms/frmInstancial.cs @@ -1,4 +1,4 @@ -using AgroBase.Forms.IHM; +using AgroBase.Forms.IHM; using AgroBase.Models; using AgroBase.Services; using System; @@ -14,134 +14,484 @@ namespace AgroBase.Forms public static frmPrincipal frmPrincipal = new frmPrincipal(); public static frmJoystick frmJoystick = new frmJoystick(); - public static frmIHM frmIHM = new frmIHM() - { - FormBorderStyle = FormBorderStyle.None, - //WindowState = FormWindowState.Maximized, - //TopMost = true + public static frmIHM frmIHM = new frmIHM() + { + FormBorderStyle = FormBorderStyle.None, + //WindowState = FormWindowState.Maximized, + //TopMost = true }; + private static readonly SemaphoreSlim _startupGate = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _shutdownGate = + new SemaphoreSlim(1, 1); + + private static CancellationTokenSource _appCts; + + private static bool _startupConcluido; + private static bool _shutdownIniciado; + + private bool _allowClose; + public frmInstancial() { InitializeComponent(); - ThreadPool.SetMinThreads(workerThreads: 50, completionPortThreads: 50); + /* + * O construtor precisa ser leve. + * Nada de abrir MQTT, Redis, Python, CAN, Livox ou operadores aqui. + * O WinForms ainda está criando handles e a aplicação ainda não + * possui lifecycle seguro para aguardar falhas. + */ + ThreadPool.SetMinThreads( + workerThreads: 50, + completionPortThreads: 50 + ); + } - Variaveis.IniciarMQTT(); - - //Variaveis.IniciarUDP(); - - APIService.IniciarRotinas(); - - GeneralJoystick.IniciarRotinas(); - - VersionamentoService.IniciarRotinas(); - - PythonService.EncerrarProcessos(); - - RedisService.Iniciar(); - RedisService.LimparDadosIniciais(); - - LivoxManagerProcess.Start(); - - Task.Run(async () => + private async void frmInstancial_Load(object sender, EventArgs e) + { + try { - await VariaveisOperacao.Operadores.IniciarProcessamento(false); - }); + await InicializarServicosAsync(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Load] Falha crítica na inicialização: " + + ex + ); - AudioAlertaService.SelecionarVoz(VariaveisEquipamento.VozAlerta); + try + { + MessageBox.Show( + "Falha ao inicializar o sistema:\n\n" + + ex.Message, + "AgroBase", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + } + catch { } + } } private void frmInstancial_Shown(object sender, EventArgs e) { - if (!Variaveis.Producao) + try { - frmIHM.FormClosing += FrmIHM_FormClosing; - frmPrincipal.FormClosing += frmPrincipal_FormClosing; - frmPrincipal.Show(); - } - else - { - frmIHM.FormClosing += frmPrincipal_FormClosing; - frmIHM.Show(); - } + if (!Variaveis.Producao) + { + frmIHM.FormClosing -= FrmIHM_FormClosing; + frmPrincipal.FormClosing -= frmPrincipal_FormClosing; - this.Hide(); + frmIHM.FormClosing += FrmIHM_FormClosing; + frmPrincipal.FormClosing += frmPrincipal_FormClosing; + + frmPrincipal.Show(); + } + else + { + frmIHM.FormClosing -= frmPrincipal_FormClosing; + frmIHM.FormClosing += frmPrincipal_FormClosing; + + frmIHM.Show(); + } + + Hide(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Shown] " + ex + ); + } } - - - private void frmInstancial_Load(object sender, EventArgs e) + private static async Task InicializarServicosAsync() { - FuncoesGlobais.AddApplicationToStartup(); - IniciarConexaoMqtt(); + await _startupGate.WaitAsync() + .ConfigureAwait(false); - Control control = new Control(); - if (!Variaveis.Producao) + try { - control = frmPrincipal; - } - else - { - control = frmIHM; - } + if (_startupConcluido) + return; + + _shutdownIniciado = false; + Variaveis.Fechando = false; + + _appCts?.Dispose(); + _appCts = new CancellationTokenSource(); + + FuncoesGlobais.AddApplicationToStartup(); + + /* + * Ordem pensada para campo: + * 1. limpar processos externos velhos; + * 2. subir infraestrutura local; + * 3. subir comunicação MQTT/UDP; + * 4. iniciar rotinas e operadores; + * 5. iniciar varredura física. + */ + + PythonService.EncerrarProcessos(); + + RedisService.Iniciar(); + RedisService.LimparDadosIniciais(); + + await Variaveis.IniciarMqttAsync(_appCts.Token) + .ConfigureAwait(false); + + /* + * Hoje pode ficar comentado se UDP não for usado no rover, + * mas o método fica aqui no lugar certo do lifecycle. + */ + //Variaveis.IniciarUDP(); + + APIService.IniciarRotinas(); + GeneralJoystick.IniciarRotinas(); + VersionamentoService.IniciarRotinas(); + + LivoxManagerProcess.Start(); + + await VariaveisOperacao + .Operadores + .IniciarProcessamento(false) + .ConfigureAwait(false); + + AudioAlertaService.SelecionarVoz( + VariaveisEquipamento.VozAlerta + ); + + IniciarTimerVarreduraDispositivos(); + + _startupConcluido = true; + + Variaveis.MostrarLog( + "[frmInstancial] Inicialização concluída." + ); + } + catch + { + await EncerrarProcessosInternoAsync( + sairProcesso: false + ).ConfigureAwait(false); + + throw; + } + finally + { + _startupGate.Release(); + } + } + + private static void IniciarTimerVarreduraDispositivos() + { + if (tmrVarreduraDispositivos != null) + return; + + tmrVarreduraDispositivos = + new AsyncTaskTimerModel( + "tmrVarreduraDispositivos", + tmrVarreduraDispositivos_Tick, + SerialService.InteraloVerificacao, + null, + 15000 + ); - tmrVarreduraDispositivos = new AsyncTaskTimerModel("tmrVarreduraDispositivos", tmrVarreduraDispositivos_Tick, SerialService.InteraloVerificacao, null, 15000); tmrVarreduraDispositivos.Start(); } private static async Task tmrVarreduraDispositivos_Tick() { - await SerialService.RealizarVarreduraPortasUSB(); + if (Variaveis.Fechando) + return; + + await SerialService + .RealizarVarreduraPortasUSB() + .ConfigureAwait(false); } + /* + * Método mantido para compatibilidade com chamadas antigas. + * Não é mais chamado no Load, porque o startup correto é + * Variaveis.IniciarMqttAsync(). + */ private async void IniciarConexaoMqtt() { - await Variaveis.MqttServiceLocal.ConnectAsync(); + try + { + await Variaveis.IniciarMqttAsync(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.IniciarConexaoMqtt] " + + ex.Message + ); + } } - public static async Task EncerrarProcessos() + public static Task EncerrarProcessos() + { + return EncerrarProcessos(sairProcesso: false); + } + + public static async Task EncerrarProcessos( + bool sairProcesso) + { + await _shutdownGate.WaitAsync() + .ConfigureAwait(false); + + try + { + if (_shutdownIniciado) + return; + + _shutdownIniciado = true; + + await EncerrarProcessosInternoAsync( + sairProcesso + ).ConfigureAwait(false); + } + finally + { + _shutdownGate.Release(); + } + } + + private static async Task EncerrarProcessosInternoAsync( + bool sairProcesso) { Variaveis.Fechando = true; - LivoxManagerProcess.Dispose(); + try { _appCts?.Cancel(); } catch { } - PythonService.EncerrarProcessos(); - - Variaveis.StopUdpChannel(); - - foreach (var Disp in Variaveis.DispositivosConectados) + /* + * Primeiro paramos timers para nenhuma rotina reentrar + * enquanto desmontamos serial, CAN, MQTT e processos. + */ + try { - Disp.EnviarProtocolosConfiguracao(false); - Disp.SalvarParametros(Disp.Dados); + await AsyncTaskTimerModel + .PararTimersAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao parar timers: " + + ex.Message + ); } - var timeout = DateTime.Now.AddSeconds(10); - while (!CanManager.CanService.FilaLiberada && DateTime.Now < timeout) + tmrVarreduraDispositivos = null; + + try { - await Task.Delay(200); + await SerialService + .EncerrarAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar SerialService: " + + ex.Message + ); } - AsyncTaskTimerModel.PararTimers(); + /* + * Tenta avisar dispositivos antes de fechar CAN/MQTT/processos. + */ + try + { + foreach (var disp in Variaveis.DispositivosConectados) + { + try + { + disp.EnviarProtocolosConfiguracao(false); + disp.SalvarParametros(disp.Dados); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao salvar " + + disp.Dispositivo + ": " + ex.Message + ); + } + } + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao percorrer dispositivos: " + + ex.Message + ); + } - try { Environment.Exit(0); } catch { } + /* + * Dá uma janela curta para a fila CAN drenar. + * Usa DateTime aqui só por compatibilidade com o padrão atual, + * mas sem travar indefinidamente. + */ + try + { + var timeout = DateTime.Now.AddSeconds(10); + + while (!CanManager.CanService.FilaLiberada && + DateTime.Now < timeout) + { + await Task.Delay(200) + .ConfigureAwait(false); + } + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro aguardando fila CAN: " + + ex.Message + ); + } + + try + { + LivoxManagerProcess.Dispose(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar Livox: " + + ex.Message + ); + } + + try + { + PythonService.EncerrarProcessos(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar Python: " + + ex.Message + ); + } + + try + { + Variaveis.StopUdpChannel(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar UDP: " + + ex.Message + ); + } + + try + { + await GPSService + .EncerrarAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar GPS: " + + ex.Message + ); + } + + try + { + await Variaveis + .EncerrarMqttAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.Encerrar] Erro ao encerrar MQTT: " + + ex.Message + ); + } + + try + { + _appCts?.Dispose(); + _appCts = null; + } + catch { } + + _startupConcluido = false; + + if (sairProcesso) + { + try + { + Environment.Exit(0); + } + catch { } + } } - - - - - private async void frmPrincipal_FormClosing(object sender, FormClosingEventArgs e) + private async void frmPrincipal_FormClosing( + object sender, + FormClosingEventArgs e) { - await EncerrarProcessos(); + if (_allowClose) + return; + e.Cancel = true; + + try + { + await EncerrarProcessos( + sairProcesso: false + ); + + _allowClose = true; + + BeginInvoke( + new Action(() => + { + try + { + Close(); + } + catch { } + }) + ); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[frmInstancial.FormClosing] " + ex + ); + + try + { + Environment.Exit(1); + } + catch { } + } } - private void FrmIHM_FormClosing(object sender, FormClosingEventArgs e) + private void FrmIHM_FormClosing( + object sender, + FormClosingEventArgs e) { + /* + * Em modo desenvolvimento, fechar a IHM apenas oculta a janela. + * Em produção, o evento de fechamento da IHM é associado ao + * frmPrincipal_FormClosing e faz shutdown real. + */ e.Cancel = true; frmIHM.Hide(); } - } } diff --git a/AgroBase/AgroBase/Models/BaseLinkState.cs b/AgroBase/AgroBase/Models/BaseLinkState.cs new file mode 100644 index 000000000..b920f4d21 --- /dev/null +++ b/AgroBase/AgroBase/Models/BaseLinkState.cs @@ -0,0 +1,636 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace AgroBase.Models +{ + /// + /// Mantém o estado da comunicação entre o rover e a base. + /// + /// Regras importantes: + /// - Não armazena estado de enlace dentro de GPSModel. + /// - Mede idades com Stopwatch, portanto não sofre com ajuste do relógio do Windows. + /// - É thread-safe para callbacks MQTT, timers e UI. + /// - Uma nova sessão invalida confirmações antigas de descoberta. + /// + public sealed class BaseLinkState + { + private readonly object _sync = new object(); + + private int _brokerConnected; + private int _discoveryAcknowledged; + + private long _generation; + + private long _lastBrokerConnectedMono; + private long _lastBrokerDisconnectedMono; + private long _lastDiscoverySentMono; + private long _lastDiscoveryAckMono; + private long _lastHeartbeatMono; + private long _lastRtcmMono; + private long _lastPositionMono; + private long _lastCommandMono; + private long _lastAnyMessageMono; + + private long _lastBrokerConnectedUtcTicks; + private long _lastBrokerDisconnectedUtcTicks; + private long _lastDiscoverySentUtcTicks; + private long _lastDiscoveryAckUtcTicks; + private long _lastHeartbeatUtcTicks; + private long _lastRtcmUtcTicks; + private long _lastPositionUtcTicks; + private long _lastCommandUtcTicks; + private long _lastAnyMessageUtcTicks; + + private long _brokerConnectCount; + private long _brokerDisconnectCount; + private long _discoverySentCount; + private long _discoveryAckCount; + private long _heartbeatCount; + private long _rtcmCount; + private long _positionCount; + private long _commandCount; + private long _anyMessageCount; + private long _invalidDiscoveryAckCount; + + private long _discoverySequence; + + private string _sessionId; + private string _baseId; + private string _roverId; + private string _lastDisconnectReason; + private string _lastError; + + private int _heartbeatTimeoutMs; + private int _criticalChannelTimeoutMs; + private int _rtcmTimeoutMs; + private int _discoveryRetryMs; + + public BaseLinkState( + int heartbeatTimeoutMs = 10000, + int criticalChannelTimeoutMs = 12000, + int rtcmTimeoutMs = 5000, + int discoveryRetryMs = 3000) + { + SetTimeouts( + heartbeatTimeoutMs, + criticalChannelTimeoutMs, + rtcmTimeoutMs, + discoveryRetryMs + ); + + BeginNewSession(); + } + + public bool BrokerConnected + { + get { return Volatile.Read(ref _brokerConnected) == 1; } + } + + public bool DiscoveryAcknowledged + { + get { return Volatile.Read(ref _discoveryAcknowledged) == 1; } + } + + /// + /// A base é considerada conhecida quando o broker está conectado e: + /// - a descoberta da sessão atual foi confirmada; ou + /// - um heartbeat recente comprova comunicação direta com a base. + /// + /// O fallback por heartbeat mantém compatibilidade até o protocolo + /// explícito de discovery/ack ser implantado. + /// + public bool BaseKnown + { + get + { + return BrokerConnected && + (DiscoveryAcknowledged || HeartbeatHealthy); + } + } + + public bool HeartbeatHealthy + { + get + { + return GetAgeMs(Interlocked.Read(ref _lastHeartbeatMono)) + <= Volatile.Read(ref _heartbeatTimeoutMs); + } + } + + public bool RtcmRecent + { + get + { + return GetAgeMs(Interlocked.Read(ref _lastRtcmMono)) + <= Volatile.Read(ref _rtcmTimeoutMs); + } + } + + /// + /// Indica que o canal crítico está comprovadamente vivo. + /// RTCM possui uma propriedade separada porque pode não existir + /// durante configuração, simulação ou ausência temporária de correção. + /// + public bool CriticalChannelHealthy + { + get + { + if (!BrokerConnected) + return false; + + double lastCriticalAge = Math.Min( + LastHeartbeatAgeMs, + Math.Min(LastRtcmAgeMs, LastCommandAgeMs) + ); + + return lastCriticalAge <= Volatile.Read(ref _criticalChannelTimeoutMs); + } + } + + /// + /// A descoberta só deve ser publicada quando o broker está conectado, + /// a base ainda não foi reconhecida e o intervalo de retry venceu. + /// + public bool DiscoveryNeeded + { + get + { + if (!BrokerConnected || BaseKnown) + return false; + + return LastDiscoverySentAgeMs >= Volatile.Read(ref _discoveryRetryMs); + } + } + + public long Generation + { + get { return Interlocked.Read(ref _generation); } + } + + public string SessionId + { + get + { + lock (_sync) + return _sessionId; + } + } + + public string BaseId + { + get + { + lock (_sync) + return _baseId; + } + } + + public string RoverId + { + get + { + lock (_sync) + return _roverId; + } + } + + public double LastHeartbeatAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastHeartbeatMono)); } + } + + public double LastRtcmAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastRtcmMono)); } + } + + public double LastPositionAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastPositionMono)); } + } + + public double LastCommandAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastCommandMono)); } + } + + public double LastAnyMessageAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastAnyMessageMono)); } + } + + public double LastDiscoverySentAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastDiscoverySentMono)); } + } + + public double LastDiscoveryAckAgeMs + { + get { return GetAgeMs(Interlocked.Read(ref _lastDiscoveryAckMono)); } + } + + public void SetTimeouts( + int heartbeatTimeoutMs, + int criticalChannelTimeoutMs, + int rtcmTimeoutMs, + int discoveryRetryMs) + { + if (heartbeatTimeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(heartbeatTimeoutMs)); + + if (criticalChannelTimeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(criticalChannelTimeoutMs)); + + if (rtcmTimeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(rtcmTimeoutMs)); + + if (discoveryRetryMs <= 0) + throw new ArgumentOutOfRangeException(nameof(discoveryRetryMs)); + + Volatile.Write(ref _heartbeatTimeoutMs, heartbeatTimeoutMs); + Volatile.Write(ref _criticalChannelTimeoutMs, criticalChannelTimeoutMs); + Volatile.Write(ref _rtcmTimeoutMs, rtcmTimeoutMs); + Volatile.Write(ref _discoveryRetryMs, discoveryRetryMs); + } + + /// + /// Inicia uma nova sessão lógica do rover. + /// Toda confirmação de descoberta anterior é invalidada. + /// + public string BeginNewSession(string roverId = null, string sessionId = null) + { + string newSessionId = string.IsNullOrWhiteSpace(sessionId) + ? Guid.NewGuid().ToString("N") + : sessionId.Trim(); + + lock (_sync) + { + _sessionId = newSessionId; + _roverId = roverId; + _baseId = null; + _lastDisconnectReason = null; + _lastError = null; + } + + Interlocked.Increment(ref _generation); + Interlocked.Exchange(ref _discoveryAcknowledged, 0); + Interlocked.Exchange(ref _discoverySequence, 0); + + ResetEventTimestamps(); + + return newSessionId; + } + + public void MarkBrokerConnected() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _brokerConnected, 1); + Interlocked.Exchange(ref _lastBrokerConnectedMono, nowMono); + Interlocked.Exchange(ref _lastBrokerConnectedUtcTicks, nowUtc); + Interlocked.Increment(ref _brokerConnectCount); + + lock (_sync) + { + _lastDisconnectReason = null; + _lastError = null; + } + } + + public void MarkBrokerDisconnected(string reason = null) + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _brokerConnected, 0); + Interlocked.Exchange(ref _discoveryAcknowledged, 0); + Interlocked.Exchange(ref _lastBrokerDisconnectedMono, nowMono); + Interlocked.Exchange(ref _lastBrokerDisconnectedUtcTicks, nowUtc); + Interlocked.Increment(ref _brokerDisconnectCount); + + lock (_sync) + { + _baseId = null; + _lastDisconnectReason = reason; + } + } + + /// + /// Registra envio de discovery e retorna a sequência utilizada. + /// + public long MarkDiscoverySent() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + long sequence = Interlocked.Increment(ref _discoverySequence); + + Interlocked.Exchange(ref _lastDiscoverySentMono, nowMono); + Interlocked.Exchange(ref _lastDiscoverySentUtcTicks, nowUtc); + Interlocked.Increment(ref _discoverySentCount); + + return sequence; + } + + /// + /// Confirma a descoberta somente se o sessionId recebido pertencer + /// à sessão atual. Retorna false para ACK antigo ou inválido. + /// + public bool TryMarkDiscoveryAcknowledged( + string sessionId, + string baseId = null) + { + string currentSession; + + lock (_sync) + currentSession = _sessionId; + + if (string.IsNullOrWhiteSpace(sessionId) || + !string.Equals( + currentSession, + sessionId.Trim(), + StringComparison.Ordinal)) + { + Interlocked.Increment(ref _invalidDiscoveryAckCount); + return false; + } + + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + lock (_sync) + { + _baseId = baseId; + _lastError = null; + } + + Interlocked.Exchange(ref _discoveryAcknowledged, 1); + Interlocked.Exchange(ref _lastDiscoveryAckMono, nowMono); + Interlocked.Exchange(ref _lastDiscoveryAckUtcTicks, nowUtc); + Interlocked.Increment(ref _discoveryAckCount); + + MarkAnyMessageInternal(nowMono, nowUtc); + return true; + } + + /// + /// Compatibilidade temporária enquanto a base ainda não publica + /// discovery_ack com sessionId. Um heartbeat recente já comprova + /// que existe comunicação direta com a base. + /// + public void MarkLegacyBaseDetected(string baseId = null) + { + lock (_sync) + { + if (!string.IsNullOrWhiteSpace(baseId)) + _baseId = baseId; + } + + MarkHeartbeat(); + } + + public void MarkHeartbeat() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _lastHeartbeatMono, nowMono); + Interlocked.Exchange(ref _lastHeartbeatUtcTicks, nowUtc); + Interlocked.Increment(ref _heartbeatCount); + + MarkAnyMessageInternal(nowMono, nowUtc); + } + + public void MarkRtcm() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _lastRtcmMono, nowMono); + Interlocked.Exchange(ref _lastRtcmUtcTicks, nowUtc); + Interlocked.Increment(ref _rtcmCount); + + MarkAnyMessageInternal(nowMono, nowUtc); + } + + public void MarkPosition() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _lastPositionMono, nowMono); + Interlocked.Exchange(ref _lastPositionUtcTicks, nowUtc); + Interlocked.Increment(ref _positionCount); + + MarkAnyMessageInternal(nowMono, nowUtc); + } + + public void MarkCommand() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + + Interlocked.Exchange(ref _lastCommandMono, nowMono); + Interlocked.Exchange(ref _lastCommandUtcTicks, nowUtc); + Interlocked.Increment(ref _commandCount); + + MarkAnyMessageInternal(nowMono, nowUtc); + } + + public void MarkAnyMessage() + { + long nowMono = Stopwatch.GetTimestamp(); + long nowUtc = DateTime.UtcNow.Ticks; + MarkAnyMessageInternal(nowMono, nowUtc); + } + + public void RecordError(string error) + { + lock (_sync) + _lastError = error; + } + + public void ClearError() + { + lock (_sync) + _lastError = null; + } + + public BaseLinkSnapshot GetSnapshot() + { + string sessionId; + string baseId; + string roverId; + string disconnectReason; + string lastError; + + lock (_sync) + { + sessionId = _sessionId; + baseId = _baseId; + roverId = _roverId; + disconnectReason = _lastDisconnectReason; + lastError = _lastError; + } + + return new BaseLinkSnapshot + { + BrokerConnected = BrokerConnected, + DiscoveryAcknowledged = DiscoveryAcknowledged, + BaseKnown = BaseKnown, + HeartbeatHealthy = HeartbeatHealthy, + RtcmRecent = RtcmRecent, + CriticalChannelHealthy = CriticalChannelHealthy, + DiscoveryNeeded = DiscoveryNeeded, + + Generation = Generation, + SessionId = sessionId, + BaseId = baseId, + RoverId = roverId, + + HeartbeatTimeoutMs = Volatile.Read(ref _heartbeatTimeoutMs), + CriticalChannelTimeoutMs = Volatile.Read(ref _criticalChannelTimeoutMs), + RtcmTimeoutMs = Volatile.Read(ref _rtcmTimeoutMs), + DiscoveryRetryMs = Volatile.Read(ref _discoveryRetryMs), + + LastBrokerConnectedUtc = ReadUtcDateTime(ref _lastBrokerConnectedUtcTicks), + LastBrokerDisconnectedUtc = ReadUtcDateTime(ref _lastBrokerDisconnectedUtcTicks), + LastDiscoverySentUtc = ReadUtcDateTime(ref _lastDiscoverySentUtcTicks), + LastDiscoveryAckUtc = ReadUtcDateTime(ref _lastDiscoveryAckUtcTicks), + LastHeartbeatUtc = ReadUtcDateTime(ref _lastHeartbeatUtcTicks), + LastRtcmUtc = ReadUtcDateTime(ref _lastRtcmUtcTicks), + LastPositionUtc = ReadUtcDateTime(ref _lastPositionUtcTicks), + LastCommandUtc = ReadUtcDateTime(ref _lastCommandUtcTicks), + LastAnyMessageUtc = ReadUtcDateTime(ref _lastAnyMessageUtcTicks), + + LastDiscoverySentAgeMs = LastDiscoverySentAgeMs, + LastDiscoveryAckAgeMs = LastDiscoveryAckAgeMs, + LastHeartbeatAgeMs = LastHeartbeatAgeMs, + LastRtcmAgeMs = LastRtcmAgeMs, + LastPositionAgeMs = LastPositionAgeMs, + LastCommandAgeMs = LastCommandAgeMs, + LastAnyMessageAgeMs = LastAnyMessageAgeMs, + + BrokerConnectCount = Interlocked.Read(ref _brokerConnectCount), + BrokerDisconnectCount = Interlocked.Read(ref _brokerDisconnectCount), + DiscoverySentCount = Interlocked.Read(ref _discoverySentCount), + DiscoveryAckCount = Interlocked.Read(ref _discoveryAckCount), + InvalidDiscoveryAckCount = Interlocked.Read(ref _invalidDiscoveryAckCount), + HeartbeatCount = Interlocked.Read(ref _heartbeatCount), + RtcmCount = Interlocked.Read(ref _rtcmCount), + PositionCount = Interlocked.Read(ref _positionCount), + CommandCount = Interlocked.Read(ref _commandCount), + AnyMessageCount = Interlocked.Read(ref _anyMessageCount), + DiscoverySequence = Interlocked.Read(ref _discoverySequence), + + LastDisconnectReason = disconnectReason, + LastError = lastError + }; + } + + private void ResetEventTimestamps() + { + Interlocked.Exchange(ref _lastDiscoverySentMono, 0); + Interlocked.Exchange(ref _lastDiscoveryAckMono, 0); + Interlocked.Exchange(ref _lastHeartbeatMono, 0); + Interlocked.Exchange(ref _lastRtcmMono, 0); + Interlocked.Exchange(ref _lastPositionMono, 0); + Interlocked.Exchange(ref _lastCommandMono, 0); + Interlocked.Exchange(ref _lastAnyMessageMono, 0); + + Interlocked.Exchange(ref _lastDiscoverySentUtcTicks, 0); + Interlocked.Exchange(ref _lastDiscoveryAckUtcTicks, 0); + Interlocked.Exchange(ref _lastHeartbeatUtcTicks, 0); + Interlocked.Exchange(ref _lastRtcmUtcTicks, 0); + Interlocked.Exchange(ref _lastPositionUtcTicks, 0); + Interlocked.Exchange(ref _lastCommandUtcTicks, 0); + Interlocked.Exchange(ref _lastAnyMessageUtcTicks, 0); + } + + private void MarkAnyMessageInternal(long nowMono, long nowUtc) + { + Interlocked.Exchange(ref _lastAnyMessageMono, nowMono); + Interlocked.Exchange(ref _lastAnyMessageUtcTicks, nowUtc); + Interlocked.Increment(ref _anyMessageCount); + } + + private static double GetAgeMs(long timestamp) + { + if (timestamp <= 0) + return double.PositiveInfinity; + + long now = Stopwatch.GetTimestamp(); + long delta = now - timestamp; + + if (delta <= 0) + return 0.0; + + return delta * 1000.0 / Stopwatch.Frequency; + } + + private static DateTime? ReadUtcDateTime(ref long ticksField) + { + long ticks = Interlocked.Read(ref ticksField); + + if (ticks <= 0) + return null; + + return new DateTime(ticks, DateTimeKind.Utc); + } + } + + public sealed class BaseLinkSnapshot + { + public bool BrokerConnected { get; set; } + public bool DiscoveryAcknowledged { get; set; } + public bool BaseKnown { get; set; } + public bool HeartbeatHealthy { get; set; } + public bool RtcmRecent { get; set; } + public bool CriticalChannelHealthy { get; set; } + public bool DiscoveryNeeded { get; set; } + + public long Generation { get; set; } + public string SessionId { get; set; } + public string BaseId { get; set; } + public string RoverId { get; set; } + + public int HeartbeatTimeoutMs { get; set; } + public int CriticalChannelTimeoutMs { get; set; } + public int RtcmTimeoutMs { get; set; } + public int DiscoveryRetryMs { get; set; } + + public DateTime? LastBrokerConnectedUtc { get; set; } + public DateTime? LastBrokerDisconnectedUtc { get; set; } + public DateTime? LastDiscoverySentUtc { get; set; } + public DateTime? LastDiscoveryAckUtc { get; set; } + public DateTime? LastHeartbeatUtc { get; set; } + public DateTime? LastRtcmUtc { get; set; } + public DateTime? LastPositionUtc { get; set; } + public DateTime? LastCommandUtc { get; set; } + public DateTime? LastAnyMessageUtc { get; set; } + + public double LastDiscoverySentAgeMs { get; set; } + public double LastDiscoveryAckAgeMs { get; set; } + public double LastHeartbeatAgeMs { get; set; } + public double LastRtcmAgeMs { get; set; } + public double LastPositionAgeMs { get; set; } + public double LastCommandAgeMs { get; set; } + public double LastAnyMessageAgeMs { get; set; } + + public long BrokerConnectCount { get; set; } + public long BrokerDisconnectCount { get; set; } + public long DiscoverySentCount { get; set; } + public long DiscoveryAckCount { get; set; } + public long InvalidDiscoveryAckCount { get; set; } + public long HeartbeatCount { get; set; } + public long RtcmCount { get; set; } + public long PositionCount { get; set; } + public long CommandCount { get; set; } + public long AnyMessageCount { get; set; } + public long DiscoverySequence { get; set; } + + public string LastDisconnectReason { get; set; } + public string LastError { get; set; } + } +} diff --git a/AgroBase/AgroBase/Models/Components/AsyncTaskTimerModel.cs b/AgroBase/AgroBase/Models/Components/AsyncTaskTimerModel.cs index a9bca38af..1eddf8f5a 100644 --- a/AgroBase/AgroBase/Models/Components/AsyncTaskTimerModel.cs +++ b/AgroBase/AgroBase/Models/Components/AsyncTaskTimerModel.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -7,80 +8,200 @@ using System.Windows.Forms; namespace AgroBase.Models { - public class AsyncTaskTimerModel : IDisposable + /// + /// Timer assíncrono de uso geral para os loops da aplicação. + /// + /// Garantias principais: + /// - nunca executa dois ticks simultaneamente; + /// - timeout não libera um novo tick enquanto o anterior ainda estiver vivo; + /// - Start/Stop/Restart são protegidos contra corridas; + /// - agendamento usa relógio monotônico; + /// - exceções do callback não encerram silenciosamente o loop; + /// - callbacks antigos Func<Task> continuam compatíveis; + /// - callbacks novos podem receber CancellationToken real. + /// + public sealed class AsyncTaskTimerModel : IDisposable { + // Mantidos públicos por compatibilidade com o projeto atual. + // O código interno nunca itera essa lista sem antes criar um snapshot. public static readonly object _timersLock = new object(); public static List Timers { get; set; } = new List(); - public static void PararTimers() + private static readonly AsyncLocal _timerEmExecucao = + new AsyncLocal(); + + private readonly object _lifecycleLock = new object(); + private readonly object _metricsLock = new object(); + + private readonly Func _taskToExecute; + private readonly bool _callbackSupportsCancellation; + private readonly AsyncTaskTimerScheduleMode _scheduleMode; + private readonly bool _runImmediately; + + private CancellationTokenSource _generationCts; + private Task _loopTask = Task.CompletedTask; + + private int _stateValue = (int)TimerState.Created; + private long _generation; + private int _interval; + private int _timeout; + private int _isTicking; + private int _cleanupCompleted; + + private readonly string _stackTrace; + private readonly string _id; + + public Control _uiControl; + + private DateTime _lastStartUtc = DateTime.MinValue; + private DateTime _lastCompletionUtc = DateTime.MinValue; + private DateTime _lastSuccessUtc = DateTime.MinValue; + private DateTime _lastFailureUtc = DateTime.MinValue; + private DateTime _lastTimeoutUtc = DateTime.MinValue; + + private long _currentTickStartTimestamp; + private double _lastExecutionDurationMs; + private double _lastSuccessfulDurationMs; + private double _maxExecutionDurationMs; + private double _lastScheduleLatenessMs; + private double _maxScheduleLatenessMs; + private double _totalExecutionDurationMs; + + private string _lastError; + private AsyncTaskTimerTickResult _lastResult = AsyncTaskTimerTickResult.None; + + private long _ticksStarted; + private long _ticksFinished; + private long _ticksSucceeded; + private long _ticksFailed; + private long _ticksTimedOut; + private long _ticksCanceled; + private long _schedulesSkipped; + private long _overruns; + + private int _consecutiveFailures; + private int _consecutiveTimeouts; + private int _consecutiveSuccesses; + + /// + /// Mantido como propriedade configurável para compatibilidade e diagnóstico. + /// Em produção pode ser desativado individualmente por timer. + /// + public bool DebugMessages { get; set; } = false; + + public TimerState State { - lock (_timersLock) + get { return (TimerState)Volatile.Read(ref _stateValue); } + } + + public bool IsRunning { get { return State == TimerState.Running; } } + public bool IsStopped { get { return State == TimerState.Stopped; } } + public bool IsCreated { get { return State == TimerState.Created; } } + public bool IsDisposed { get { return State == TimerState.Disposed; } } + public bool IsTicking { get { return Volatile.Read(ref _isTicking) == 1; } } + + public int Interval { get { return Volatile.Read(ref _interval); } } + + public double IntervalHz + { + get { - foreach (var timer in Timers) + int interval = Interval; + return interval > 0 ? 1000.0 / interval : 0.0; + } + } + + public int Timeout { get { return Volatile.Read(ref _timeout); } } + public string StackTrace { get { return _stackTrace; } } + public string Id { get { return _id; } } + public long Generation { get { return Interlocked.Read(ref _generation); } } + public AsyncTaskTimerScheduleMode ScheduleMode { get { return _scheduleMode; } } + public bool CallbackSupportsCancellation { get { return _callbackSupportsCancellation; } } + + /// + /// Duração real do último callback, incluindo o tempo posterior ao timeout + /// caso o callback não tenha respeitado o cancelamento. + /// + public TimeSpan TimeTick + { + get + { + lock (_metricsLock) { - timer.Stop(); + return TimeSpan.FromMilliseconds(_lastExecutionDurationMs); } } } - - private TimerState _state = TimerState.Created; - public TimerState State { get { return _state; } } - /// - /// Verifica se o timer foi iniciado e está executando. + /// Construtor compatível com todos os usos antigos Func<Task>. + /// O callback legado não recebe o token, portanto deve terminar por conta própria. + /// Mesmo após timeout, outro tick não será iniciado até ele realmente encerrar. /// - public bool IsRunning => _state == TimerState.Running; - - /// - /// Verifica se o timer está parado. - /// - public bool IsStopped => _state == TimerState.Stopped; - - /// - /// Verifica se o timer foi criado, mas ainda não foi iniciado. - /// - public bool IsCreated => _state == TimerState.Created; - public bool IsDisposed => _state == TimerState.Disposed; - - private CancellationTokenSource _cancellationTokenSource; - private Func _taskToExecute; - private int _interval; // Intervalo em milissegundos - public int Interval { get { return _interval; } } - public double IntervalHz { get { return (1.0 / ((double)_interval) * 1000.0); } } - private string _stackTrace; - public string StackTrace { get { return _stackTrace; } } - private string _id; - public string Id { get { return _id; } } - public Control _uiControl; - private int _timeout = 60000; - private System.Timers.Timer _timer; - - private DateTime beginTask = DateTime.MinValue; - private DateTime endTask = DateTime.MinValue; - /// - /// Obtém o tempo decorrido entre o início e o fim da tarefa. - /// - public TimeSpan TimeTick => endTask - beginTask; - - private bool _Ticking = false; - - private bool DebugMessages = true; - - /// - /// Inicializa uma nova instância do AsyncTaskTimerService. - /// - /// A tarefa assíncrona que será executada periodicamente. - /// Intervalo em milissegundos entre as execuções. - /// Controle para executar tarefas relacionadas à UI. Opcional. - public AsyncTaskTimerModel(string id, Func taskToExecute, int interval, Control uiControl = null, int timeout = 60000) + public AsyncTaskTimerModel( + string id, + Func taskToExecute, + int interval, + Control uiControl = null, + int timeout = 60000) + : this( + id, + taskToExecute == null + ? (Func)null + : (_ => taskToExecute()), + interval, + uiControl, + timeout, + AsyncTaskTimerScheduleMode.FixedDelay, + false, + false) { - //if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - if (string.IsNullOrEmpty(id)) return; - //if (taskToExecute == null) throw new ArgumentNullException(nameof(taskToExecute)); - if (taskToExecute == null) return; - if (interval <= 0) throw new ArgumentException("Interval must be greater than zero.", nameof(interval)); - if (timeout <= 0) throw new ArgumentException("Timeout must be greater than zero.", nameof(timeout)); + } + + /// + /// Construtor recomendado para novos loops. O callback recebe cancelamento real. + /// + public AsyncTaskTimerModel( + string id, + Func taskToExecute, + int interval, + Control uiControl = null, + int timeout = 60000, + AsyncTaskTimerScheduleMode scheduleMode = AsyncTaskTimerScheduleMode.FixedDelay, + bool runImmediately = false) + : this( + id, + taskToExecute, + interval, + uiControl, + timeout, + scheduleMode, + runImmediately, + true) + { + } + + private AsyncTaskTimerModel( + string id, + Func taskToExecute, + int interval, + Control uiControl, + int timeout, + AsyncTaskTimerScheduleMode scheduleMode, + bool runImmediately, + bool callbackSupportsCancellation) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("O id do timer é obrigatório.", nameof(id)); + + if (taskToExecute == null) + throw new ArgumentNullException(nameof(taskToExecute)); + + if (interval <= 0) + throw new ArgumentOutOfRangeException(nameof(interval), "O intervalo deve ser maior que zero."); + + if (timeout <= 0) + throw new ArgumentOutOfRangeException(nameof(timeout), "O timeout deve ser maior que zero."); _id = id; _stackTrace = ObterIdDoChamador(); @@ -88,299 +209,1028 @@ namespace AgroBase.Models _interval = interval; _timeout = timeout; _uiControl = uiControl; - - _timer = new System.Timers.Timer(); - _timer.Interval = interval; - _timer.AutoReset = false; - _timer.Elapsed += _timer_Elapsed; - _timer.Disposed += _timer_Disposed; + _scheduleMode = scheduleMode; + _runImmediately = runImmediately; + _callbackSupportsCancellation = callbackSupportsCancellation; lock (_timersLock) { + if (Timers == null) + Timers = new List(); + Timers.Add(this); } } - private void _timer_Disposed(object sender, EventArgs e) - { - _state = TimerState.Disposed; - } - - private async void _timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) - { - System.Timers.Timer tmr = sender as System.Timers.Timer; - if (IsDisposed) return; - tmr.Stop(); - - if (_Ticking) - { - if (_state == TimerState.Running) - { - tmr.Start(); - } - return; - } - - _Ticking = true; - - beginTask = DateTime.Now; - - try - { - // Define a tarefa a ser executada - var taskToRun = _uiControl != null - ? FuncoesGlobais.ExecutarMetodoComVerificacaoCrossThreadAsync(_uiControl, _taskToExecute) - : _taskToExecute(); - - // Aguarda a conclusão da tarefa ou o timeout - var completedTask = await Task.WhenAny(taskToRun, Task.Delay(_timeout)); - - if (completedTask == taskToRun) - { - // A tarefa foi concluída dentro do limite - await taskToRun; - } - else - { - // Timeout atingido - ExibirConsole($"TIMEOUT - tarefa excedeu {_timeout}ms"); - //Restart(); - } - - endTask = DateTime.Now; - } - catch (OperationCanceledException) - { - // Cancelamento solicitado - ExibirConsole($"CANCEL"); - return; - } - catch (Exception ex) - { - // Log de erros inesperados - ExibirConsole($"ERROR"); - Console.WriteLine($"Erro no timer {_id}: {ex.Message}"); - } - finally - { - _Ticking = false; - if (_state == TimerState.Running) - { - try { if (!IsDisposed) tmr.Start(); } catch { } - } - } - } - /// - /// Inicia o timer assíncrono. + /// Solicita a parada de todos os timers registrados. + /// Não bloqueia a thread chamadora. Use PararTimersAsync no shutdown + /// quando for necessário aguardar o encerramento real dos callbacks. /// - public void Start() + public static void PararTimers() { - if (VerificaDisposed()) return; - if (_state == TimerState.Running) return; + AsyncTaskTimerModel[] snapshot = GetTimersSnapshot(); - ExibirConsole($"START"); - - _state = TimerState.Running; - - _cancellationTokenSource = new CancellationTokenSource(); - //Task.Run(() => TimerLoopAsync(_cancellationTokenSource.Token)); - _timer.Start(); - } - - /// - /// Para o timer assíncrono. - /// - public void Stop() - { - if (VerificaDisposed()) return; - if (_state != TimerState.Running) return; - - _cancellationTokenSource.Cancel(); - _cancellationTokenSource = null; - - _state = TimerState.Stopped; - - ExibirConsole($"STOP"); - - _timer.Stop(); - } - - /// - /// Reinicia o timer assíncrono. - /// - public void Restart() - { - if (VerificaDisposed()) return; - - Stop(); - Start(); - - ExibirConsole($"RESTART"); - } - - /// - /// Alterna o status do timer. - /// - public void Toogle() - { - if (VerificaDisposed()) return; - - if (IsRunning) + foreach (AsyncTaskTimerModel timer in snapshot) { - Stop(); - } - else - { - Start(); - } - - ExibirConsole($"TOOGLE"); - } - - /// - /// Define o intervalo entre as execuções. - /// - /// Novo intervalo em milissegundos. - public void SetInterval(int interval) - { - if (VerificaDisposed()) return; - - if (interval <= 0) throw new ArgumentException("Interval must be greater than zero.", nameof(interval)); - _interval = interval; - - _timer.Interval = _interval; - } - - /// - /// Loop interno do timer com timeout na execução da tarefa. - /// - private async Task TimerLoopAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested && Id == "tmrMonitoramentoRecursos") - { - if (_Ticking) - { - return; - } - - _Ticking = true; - - beginTask = DateTime.Now; - try { - // Cria um token de cancelamento específico para o timeout - var timeoutCts = new CancellationTokenSource(_timeout); - var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - // Define a tarefa a ser executada - var taskToRun = _uiControl != null - ? FuncoesGlobais.ExecutarMetodoComVerificacaoCrossThreadAsync(_uiControl, _taskToExecute) - : _taskToExecute(); - - // Aguarda a conclusão da tarefa ou o timeout - var completedTask = await Task.WhenAny(taskToRun, Task.Delay(_timeout, linkedTokenSource.Token)); - - if (completedTask == taskToRun) - { - // A tarefa foi concluída dentro do limite - await taskToRun; - } - else - { - // Timeout atingido - ExibirConsole($"TIMEOUT - tarefa excedeu {_timeout}ms"); - //Restart(); - } - - endTask = DateTime.Now; - } - catch (OperationCanceledException) - { - // Cancelamento solicitado - ExibirConsole($"CANCEL"); - break; + timer.Stop(); } catch (Exception ex) { - // Log de erros inesperados - ExibirConsole($"ERROR"); - Console.WriteLine($"Erro no timer {_id}: {ex.Message}"); - } - finally - { - // Garante que o intervalo é respeitado entre execuções - await Task.Delay(_interval, cancellationToken); - _Ticking = false; - //_ticks++; + Variaveis.MostrarLog($"[AynscTaskTimer.PararTimers] Erro ao solicitar parada do timer '{timer?.Id}': {ex}"); } } - - ExibirConsole($"ENCERRADO"); } /// - /// Descarta os recursos após finalizar o uso para liberar a memória. + /// Para todos os timers e aguarda cada loop realmente terminar. + /// Este é o método recomendado para o encerramento da aplicação. + /// + public static async Task PararTimersAsync() + { + AsyncTaskTimerModel[] snapshot = GetTimersSnapshot(); + + Task[] tasks = snapshot + .Where(x => x != null) + .Select(x => x.StopAsync()) + .ToArray(); + + if (tasks.Length > 0) + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + public static AsyncTaskTimerModel[] GetTimersSnapshot() + { + lock (_timersLock) + { + if (Timers == null) + return Array.Empty(); + + return Timers + .Where(x => x != null) + .ToArray(); + } + } + + /// + /// Inicia o timer. O primeiro tick respeita o intervalo, como na classe antiga, + /// exceto quando runImmediately=true no construtor com CancellationToken. + /// + public void Start() + { + Task previousLoop; + CancellationTokenSource cts; + long generation; + + lock (_lifecycleLock) + { + ThrowIfDisposed(); + + if (State == TimerState.Running) + return; + + previousLoop = _loopTask ?? Task.CompletedTask; + cts = new CancellationTokenSource(); + generation = Interlocked.Increment(ref _generation); + + _generationCts = cts; + Volatile.Write(ref _stateValue, (int)TimerState.Running); + + _loopTask = Task.Run(async () => + { + // Uma nova geração sempre espera a geração anterior encerrar. + // Isso impede overlap até quando Stop/Start são chamados em sequência. + try + { + await previousLoop.ConfigureAwait(false); + } + catch (Exception ex) + { + ExibirConsole($"LOOP ANTERIOR FINALIZOU COM ERRO: {ex}"); + } + + if (!IsGenerationActive(generation, cts.Token)) + return; + + await RunGenerationAsync(generation, cts).ConfigureAwait(false); + }); + } + + ExibirConsole($"START geração={generation} modo={_scheduleMode} intervalo={Interval}ms"); + } + + /// + /// Solicita parada sem bloquear a thread chamadora. + /// O callback atual recebe cancelamento, quando suportado, e nenhum novo tick começa. + /// + public void Stop() + { + RequestStop(); + } + + /// + /// Solicita parada e aguarda o callback/loop atual finalizar de verdade. + /// Se um callback legado ignorar timeout e nunca retornar, este método também aguardará, + /// preservando a garantia de que não haverá uma segunda execução simultânea. + /// + public async Task StopAsync() + { + Task loop = RequestStop(); + + // Evita auto-deadlock caso um callback tente parar e aguardar o próprio timer. + if (ReferenceEquals(_timerEmExecucao.Value, this)) + return; + + if (loop != null) + { + try + { + await loop.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Encerramento normal. + } + catch (Exception ex) + { + ExibirConsole($"ERRO AO AGUARDAR STOP: {ex}"); + } + } + } + + public void Restart() + { + ThrowIfDisposed(); + Stop(); + Start(); + ExibirConsole("RESTART solicitado"); + } + + public async Task RestartAsync() + { + ThrowIfDisposed(); + await StopAsync().ConfigureAwait(false); + Start(); + ExibirConsole("RESTART concluído"); + } + + /// + /// Mantido com a grafia antiga para compatibilidade. + /// + public void Toogle() + { + Toggle(); + } + + public void Toggle() + { + ThrowIfDisposed(); + + if (IsRunning) + Stop(); + else + Start(); + } + + public void SetInterval(int interval) + { + ThrowIfDisposed(); + + if (interval <= 0) + throw new ArgumentOutOfRangeException(nameof(interval), "O intervalo deve ser maior que zero."); + + Volatile.Write(ref _interval, interval); + ExibirConsole($"INTERVAL alterado para {interval}ms"); + } + + public void SetTimeout(int timeout) + { + ThrowIfDisposed(); + + if (timeout <= 0) + throw new ArgumentOutOfRangeException(nameof(timeout), "O timeout deve ser maior que zero."); + + Volatile.Write(ref _timeout, timeout); + ExibirConsole($"TIMEOUT alterado para {timeout}ms"); + } + + private Task RequestStop() + { + CancellationTokenSource cts = null; + Task loop; + bool changed = false; + + lock (_lifecycleLock) + { + if (IsDisposed) + return _loopTask ?? Task.CompletedTask; + + loop = _loopTask ?? Task.CompletedTask; + + if (State == TimerState.Running) + { + Volatile.Write(ref _stateValue, (int)TimerState.Stopped); + Interlocked.Increment(ref _generation); + cts = _generationCts; + changed = true; + } + } + + if (cts != null) + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // A geração já terminou entre o snapshot e o cancelamento. + } + } + + if (changed) + ExibirConsole("STOP solicitado"); + + return loop; + } + + private async Task RunGenerationAsync(long generation, CancellationTokenSource generationCts) + { + CancellationToken token = generationCts.Token; + + try + { + if (_scheduleMode == AsyncTaskTimerScheduleMode.FixedRateSkipMissed) + await RunFixedRateAsync(generation, token).ConfigureAwait(false); + else + await RunFixedDelayAsync(generation, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Encerramento esperado. + } + catch (Exception ex) + { + // Um erro do motor interno nunca deve desaparecer silenciosamente. + RegisterInfrastructureFailure(ex); + ExibirConsole($"ERRO FATAL NO LOOP: {ex}"); + } + finally + { + try + { + generationCts.Dispose(); + } + catch + { + } + + lock (_lifecycleLock) + { + if (ReferenceEquals(_generationCts, generationCts)) + _generationCts = null; + + // Só a geração ainda vigente pode alterar o estado. + if (Generation == generation && State == TimerState.Running) + Volatile.Write(ref _stateValue, (int)TimerState.Stopped); + } + + ExibirConsole($"ENCERRADO geração={generation}"); + } + } + + private async Task RunFixedDelayAsync(long generation, CancellationToken token) + { + bool firstTick = true; + + while (IsGenerationActive(generation, token)) + { + if (!(firstTick && _runImmediately)) + { + int delayMs = Interval; + await Task.Delay(delayMs, token).ConfigureAwait(false); + } + + firstTick = false; + + if (!IsGenerationActive(generation, token)) + break; + + RegisterScheduleLateness(0.0); + await ExecuteOneTickAsync(generation, token).ConfigureAwait(false); + } + } + + private async Task RunFixedRateAsync(long generation, CancellationToken token) + { + long nextDue = Stopwatch.GetTimestamp(); + int intervalAtSchedule = Interval; + + if (!_runImmediately) + nextDue += MillisecondsToStopwatchTicks(intervalAtSchedule); + + while (IsGenerationActive(generation, token)) + { + await DelayUntilAsync(nextDue, token).ConfigureAwait(false); + + if (!IsGenerationActive(generation, token)) + break; + + long actualStart = Stopwatch.GetTimestamp(); + double latenessMs = StopwatchTicksToMilliseconds(Math.Max(0L, actualStart - nextDue)); + RegisterScheduleLateness(latenessMs); + + await ExecuteOneTickAsync(generation, token).ConfigureAwait(false); + + if (!IsGenerationActive(generation, token)) + break; + + int currentInterval = Interval; + long now = Stopwatch.GetTimestamp(); + + // Se o intervalo mudou, inicia uma nova cadência a partir de agora. + if (currentInterval != intervalAtSchedule) + { + intervalAtSchedule = currentInterval; + nextDue = now + MillisecondsToStopwatchTicks(currentInterval); + continue; + } + + long intervalTicks = MillisecondsToStopwatchTicks(currentInterval); + nextDue += intervalTicks; + + if (nextDue <= now) + { + long skipped = ((now - nextDue) / intervalTicks) + 1L; + nextDue += skipped * intervalTicks; + Interlocked.Add(ref _schedulesSkipped, skipped); + Interlocked.Increment(ref _overruns); + } + } + } + + private async Task ExecuteOneTickAsync(long generation, CancellationToken runToken) + { + if (Interlocked.CompareExchange(ref _isTicking, 1, 0) != 0) + { + // Esta condição não deveria ocorrer. Se ocorrer, é registrada e o tick é descartado. + Interlocked.Increment(ref _schedulesSkipped); + ExibirConsole("TICK DESCARTADO: execução anterior ainda ativa"); + return; + } + + long startedTimestamp = Stopwatch.GetTimestamp(); + Interlocked.Exchange(ref _currentTickStartTimestamp, startedTimestamp); + Interlocked.Increment(ref _ticksStarted); + + lock (_metricsLock) + { + _lastStartUtc = DateTime.UtcNow; + _lastResult = AsyncTaskTimerTickResult.Running; + } + + bool timedOut = false; + bool canceledByStop = false; + Exception callbackException = null; + + CancellationTokenSource tickCts = null; + CancellationTokenSource timeoutDelayCts = null; + + try + { + if (!IsGenerationActive(generation, runToken)) + { + canceledByStop = true; + return; + } + + tickCts = CancellationTokenSource.CreateLinkedTokenSource(runToken); + timeoutDelayCts = new CancellationTokenSource(); + + Task callbackTask; + + try + { + _timerEmExecucao.Value = this; + callbackTask = ExecuteCallbackAsync(tickCts.Token) ?? Task.CompletedTask; + } + catch (Exception ex) + { + callbackTask = Task.FromException(ex); + } + + Task timeoutTask = Task.Delay(Timeout, timeoutDelayCts.Token); + Task stopTask = Task.Delay(System.Threading.Timeout.Infinite, runToken); + + Task first = await Task.WhenAny(callbackTask, timeoutTask, stopTask).ConfigureAwait(false); + + if (first == callbackTask) + { + try + { + timeoutDelayCts.Cancel(); + } + catch + { + } + } + else if (first == stopTask || runToken.IsCancellationRequested) + { + canceledByStop = true; + SafeCancel(tickCts); + } + else + { + timedOut = true; + SafeCancel(tickCts); + RegisterTimeout(); + ExibirConsole( + $"TIMEOUT - tarefa excedeu {Timeout}ms. " + + "Nenhum novo tick será iniciado até o callback realmente terminar."); + } + + // Esta espera é deliberada. Mesmo após timeout/cancelamento, não liberamos + // outro tick enquanto o callback anterior ainda existir. + try + { + await callbackTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (tickCts.IsCancellationRequested) + { + // Cancelamento cooperativo esperado. + } + catch (Exception ex) + { + callbackException = ex; + } + } + finally + { + _timerEmExecucao.Value = null; + + if (timeoutDelayCts != null) + { + try { timeoutDelayCts.Cancel(); } catch { } + timeoutDelayCts.Dispose(); + } + + if (tickCts != null) + tickCts.Dispose(); + + long finishedTimestamp = Stopwatch.GetTimestamp(); + double durationMs = StopwatchTicksToMilliseconds(finishedTimestamp - startedTimestamp); + + RegisterTickCompletion( + durationMs, + timedOut, + canceledByStop, + callbackException); + + Interlocked.Exchange(ref _currentTickStartTimestamp, 0L); + Interlocked.Exchange(ref _isTicking, 0); + } + } + + private Task ExecuteCallbackAsync(CancellationToken token) + { + if (_uiControl == null) + return _taskToExecute(token); + + if (_uiControl.IsDisposed || _uiControl.Disposing) + throw new ObjectDisposedException( + _uiControl.Name, + $"O controle de UI associado ao timer '{Id}' foi descartado."); + + // Mantém compatibilidade com o helper já usado pelo projeto. + return FuncoesGlobais.ExecutarMetodoComVerificacaoCrossThreadAsync( + _uiControl, + () => _taskToExecute(token)); + } + + private static async Task DelayUntilAsync(long dueTimestamp, CancellationToken token) + { + while (true) + { + token.ThrowIfCancellationRequested(); + + long remainingTicks = dueTimestamp - Stopwatch.GetTimestamp(); + if (remainingTicks <= 0) + return; + + double remainingMs = StopwatchTicksToMilliseconds(remainingTicks); + + // Task.Delay possui granularidade limitada no Windows. Um pequeno laço final + // reduz deriva sem transformar o timer em busy-wait permanente. + if (remainingMs > 3.0) + { + int delay = Math.Max(1, (int)Math.Floor(remainingMs - 1.0)); + await Task.Delay(delay, token).ConfigureAwait(false); + } + else + { + await Task.Yield(); + } + } + } + + private bool IsGenerationActive(long generation, CancellationToken token) + { + return + !token.IsCancellationRequested && + !IsDisposed && + IsRunning && + Generation == generation; + } + + private void RegisterTimeout() + { + Interlocked.Increment(ref _ticksTimedOut); + Interlocked.Increment(ref _consecutiveTimeouts); + Interlocked.Exchange(ref _consecutiveSuccesses, 0); + + lock (_metricsLock) + { + _lastTimeoutUtc = DateTime.UtcNow; + } + } + + private void RegisterTickCompletion( + double durationMs, + bool timedOut, + bool canceledByStop, + Exception callbackException) + { + Interlocked.Increment(ref _ticksFinished); + + lock (_metricsLock) + { + _lastCompletionUtc = DateTime.UtcNow; + _lastExecutionDurationMs = durationMs; + _totalExecutionDurationMs += durationMs; + + if (durationMs > _maxExecutionDurationMs) + _maxExecutionDurationMs = durationMs; + } + + if (callbackException != null) + { + Interlocked.Increment(ref _ticksFailed); + Interlocked.Increment(ref _consecutiveFailures); + Interlocked.Exchange(ref _consecutiveSuccesses, 0); + + lock (_metricsLock) + { + _lastFailureUtc = DateTime.UtcNow; + _lastError = callbackException.ToString(); + _lastResult = AsyncTaskTimerTickResult.Failed; + } + + ExibirConsole($"ERROR: {callbackException}"); + return; + } + + if (timedOut) + { + // O timeout já foi contabilizado no instante em que ocorreu. + Interlocked.Increment(ref _consecutiveFailures); + + lock (_metricsLock) + { + _lastResult = AsyncTaskTimerTickResult.TimedOut; + } + + return; + } + + if (canceledByStop) + { + Interlocked.Increment(ref _ticksCanceled); + + lock (_metricsLock) + { + _lastResult = AsyncTaskTimerTickResult.Canceled; + } + + return; + } + + Interlocked.Increment(ref _ticksSucceeded); + Interlocked.Increment(ref _consecutiveSuccesses); + Interlocked.Exchange(ref _consecutiveFailures, 0); + Interlocked.Exchange(ref _consecutiveTimeouts, 0); + + lock (_metricsLock) + { + _lastSuccessUtc = DateTime.UtcNow; + _lastSuccessfulDurationMs = durationMs; + _lastError = null; + _lastResult = AsyncTaskTimerTickResult.Succeeded; + } + } + + private void RegisterScheduleLateness(double latenessMs) + { + lock (_metricsLock) + { + _lastScheduleLatenessMs = latenessMs; + + if (latenessMs > _maxScheduleLatenessMs) + _maxScheduleLatenessMs = latenessMs; + } + } + + private void RegisterInfrastructureFailure(Exception ex) + { + Interlocked.Increment(ref _ticksFailed); + Interlocked.Increment(ref _consecutiveFailures); + Interlocked.Exchange(ref _consecutiveSuccesses, 0); + + lock (_metricsLock) + { + _lastFailureUtc = DateTime.UtcNow; + _lastError = "Falha interna do timer: " + ex; + _lastResult = AsyncTaskTimerTickResult.InfrastructureFailure; + } + } + + public AsyncTaskTimerMetrics GetMetrics() + { + double lastDuration; + double lastSuccessfulDuration; + double maxDuration; + double lastLateness; + double maxLateness; + double totalDuration; + DateTime lastStart; + DateTime lastCompletion; + DateTime lastSuccess; + DateTime lastFailure; + DateTime lastTimeout; + string lastError; + AsyncTaskTimerTickResult lastResult; + + lock (_metricsLock) + { + lastDuration = _lastExecutionDurationMs; + lastSuccessfulDuration = _lastSuccessfulDurationMs; + maxDuration = _maxExecutionDurationMs; + lastLateness = _lastScheduleLatenessMs; + maxLateness = _maxScheduleLatenessMs; + totalDuration = _totalExecutionDurationMs; + lastStart = _lastStartUtc; + lastCompletion = _lastCompletionUtc; + lastSuccess = _lastSuccessUtc; + lastFailure = _lastFailureUtc; + lastTimeout = _lastTimeoutUtc; + lastError = _lastError; + lastResult = _lastResult; + } + + long finished = Interlocked.Read(ref _ticksFinished); + double averageDuration = finished > 0 ? totalDuration / finished : 0.0; + + return new AsyncTaskTimerMetrics + { + Id = Id, + Origin = StackTrace, + State = State, + ScheduleMode = ScheduleMode, + IntervalMs = Interval, + TimeoutMs = Timeout, + CallbackSupportsCancellation = CallbackSupportsCancellation, + Generation = Generation, + IsTicking = IsTicking, + + TicksStarted = Interlocked.Read(ref _ticksStarted), + TicksFinished = finished, + TicksSucceeded = Interlocked.Read(ref _ticksSucceeded), + TicksFailed = Interlocked.Read(ref _ticksFailed), + TicksTimedOut = Interlocked.Read(ref _ticksTimedOut), + TicksCanceled = Interlocked.Read(ref _ticksCanceled), + SchedulesSkipped = Interlocked.Read(ref _schedulesSkipped), + Overruns = Interlocked.Read(ref _overruns), + + ConsecutiveSuccesses = Volatile.Read(ref _consecutiveSuccesses), + ConsecutiveFailures = Volatile.Read(ref _consecutiveFailures), + ConsecutiveTimeouts = Volatile.Read(ref _consecutiveTimeouts), + + LastExecutionDurationMs = lastDuration, + LastSuccessfulDurationMs = lastSuccessfulDuration, + AverageExecutionDurationMs = averageDuration, + MaxExecutionDurationMs = maxDuration, + CurrentExecutionDurationMs = GetCurrentExecutionDurationMs(), + + LastScheduleLatenessMs = lastLateness, + MaxScheduleLatenessMs = maxLateness, + + LastStartUtc = lastStart, + LastCompletionUtc = lastCompletion, + LastSuccessUtc = lastSuccess, + LastFailureUtc = lastFailure, + LastTimeoutUtc = lastTimeout, + LastCompletionAgeMs = AgeMilliseconds(lastCompletion), + + LastResult = lastResult, + LastError = lastError, + Healthy = CalculateHealthy(lastCompletion, lastResult) + }; + } + + private bool CalculateHealthy(DateTime lastCompletionUtc, AsyncTaskTimerTickResult lastResult) + { + if (IsDisposed) + return false; + + if (!IsRunning) + return State == TimerState.Created || State == TimerState.Stopped; + + if (lastResult == AsyncTaskTimerTickResult.Failed || + lastResult == AsyncTaskTimerTickResult.InfrastructureFailure || + Volatile.Read(ref _consecutiveTimeouts) > 0) + { + return false; + } + + if (IsTicking) + { + double currentDuration = GetCurrentExecutionDurationMs(); + return currentDuration <= Math.Max(Timeout * 1.25, Interval * 3.0); + } + + if (lastCompletionUtc == DateTime.MinValue) + return true; + + double allowedAgeMs = Math.Max(Interval * 4.0, Timeout + Interval * 2.0); + return AgeMilliseconds(lastCompletionUtc) <= allowedAgeMs; + } + + private double GetCurrentExecutionDurationMs() + { + long start = Interlocked.Read(ref _currentTickStartTimestamp); + if (start <= 0 || !IsTicking) + return 0.0; + + return StopwatchTicksToMilliseconds(Stopwatch.GetTimestamp() - start); + } + + private static double AgeMilliseconds(DateTime utc) + { + if (utc == DateTime.MinValue) + return -1.0; + + return Math.Max(0.0, (DateTime.UtcNow - utc).TotalMilliseconds); + } + + /// + /// Solicita descarte sem bloquear a thread chamadora. + /// Para shutdown determinístico prefira DisposeAsync(). /// public void Dispose() { - if (VerificaDisposed()) return; + Task loopToObserve; + CancellationTokenSource cts; - // Cancela o token de cancelamento - Stop(); - - // Remove o timer da lista estática - lock (_timersLock) + lock (_lifecycleLock) { - Timers.Remove(this); + if (IsDisposed) + return; + + Volatile.Write(ref _stateValue, (int)TimerState.Disposed); + Interlocked.Increment(ref _generation); + + cts = _generationCts; + loopToObserve = _loopTask ?? Task.CompletedTask; } - // Libera o CancellationTokenSource - _cancellationTokenSource?.Dispose(); + SafeCancel(cts); + RemoveFromRegistry(); - _state = TimerState.Disposed; + // Não bloqueia a UI nem cria deadlock. Os recursos restantes são limpos + // quando o loop realmente encerrar. + _ = loopToObserve.ContinueWith( + _ => CompleteCleanup(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); - ExibirConsole($"DISPOSED"); - - _timer?.Dispose(); + ExibirConsole("DISPOSE solicitado"); } + public async Task DisposeAsync() + { + Task loopToAwait; + CancellationTokenSource cts; + + lock (_lifecycleLock) + { + if (!IsDisposed) + { + Volatile.Write(ref _stateValue, (int)TimerState.Disposed); + Interlocked.Increment(ref _generation); + } + + cts = _generationCts; + loopToAwait = _loopTask ?? Task.CompletedTask; + } + + SafeCancel(cts); + RemoveFromRegistry(); + + if (!ReferenceEquals(_timerEmExecucao.Value, this)) + { + try + { + await loopToAwait.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + ExibirConsole($"ERRO DURANTE DISPOSE ASYNC: {ex}"); + } + } + + CompleteCleanup(); + ExibirConsole("DISPOSED"); + } + + private void CompleteCleanup() + { + if (Interlocked.Exchange(ref _cleanupCompleted, 1) == 1) + return; + + CancellationTokenSource cts; + + lock (_lifecycleLock) + { + cts = _generationCts; + _generationCts = null; + } + + if (cts != null) + { + try { cts.Dispose(); } catch { } + } + } + + private void RemoveFromRegistry() + { + lock (_timersLock) + { + Timers?.Remove(this); + } + } + + private static void SafeCancel(CancellationTokenSource cts) + { + if (cts == null) + return; + + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + + private void ThrowIfDisposed() + { + if (IsDisposed) + throw new ObjectDisposedException( + nameof(AsyncTaskTimerModel), + $"O timer '{Id}' já foi descartado."); + } private string ObterIdDoChamador() { - var stackTrace = new System.Diagnostics.StackTrace(); - var frame = stackTrace.GetFrame(2); // Obter o chamador que instanciou este objeto - var method = frame.GetMethod(); - return $"{method.DeclaringType?.Name}.{method.Name}"; + try + { + StackTrace stackTrace = new StackTrace(); + + // Ignora frames pertencentes à própria classe para encontrar + // o ponto real que instanciou o timer. + foreach (StackFrame frame in stackTrace.GetFrames() ?? Array.Empty()) + { + var method = frame.GetMethod(); + Type declaringType = method?.DeclaringType; + + if (declaringType == null || declaringType == typeof(AsyncTaskTimerModel)) + continue; + + return $"{declaringType.FullName}.{method.Name}"; + } + } + catch + { + } + + return "origem_desconhecida"; } private void ExibirConsole(string msg) { - if (DebugMessages) - { - Console.WriteLine($"AsyncTaskTimer id = {_stackTrace} -> {Id} - {msg}"); - } + if (!DebugMessages) + return; + + Variaveis.MostrarLog( + $"[AsyncTaskTimer.ExibirConsole] origem={_stackTrace} id={Id} estado={State} -> {msg}"); } - private bool VerificaDisposed() + private static long MillisecondsToStopwatchTicks(int milliseconds) { - bool _disposed = _state == TimerState.Disposed; - if (_disposed) - ExibirConsole($"O timer {Id} já foi descartado!"); - return _disposed; + return Math.Max( + 1L, + (long)Math.Round(milliseconds * (double)Stopwatch.Frequency / 1000.0)); + } + + private static double StopwatchTicksToMilliseconds(long ticks) + { + return ticks * 1000.0 / Stopwatch.Frequency; } - } public enum TimerState { - Created, // Apenas criado, mas não iniciado - Running, // Executando ativamente - Stopped, // Parado após ser iniciado - Disposed, // Descartado + Created, + Running, + Stopped, + Disposed, } + public enum AsyncTaskTimerScheduleMode + { + /// + /// Comportamento compatível com a classe antiga: + /// callback termina, depois aguarda o intervalo completo. + /// + FixedDelay = 0, + /// + /// Mantém cadência monotônica. Se houver atraso, pula prazos perdidos + /// e nunca tenta compensar executando em rajada. + /// + FixedRateSkipMissed = 1, + } + public enum AsyncTaskTimerTickResult + { + None = 0, + Running = 1, + Succeeded = 2, + Failed = 3, + TimedOut = 4, + Canceled = 5, + InfrastructureFailure = 6, + } + + public sealed class AsyncTaskTimerMetrics + { + public string Id { get; set; } + public string Origin { get; set; } + public TimerState State { get; set; } + public AsyncTaskTimerScheduleMode ScheduleMode { get; set; } + public int IntervalMs { get; set; } + public int TimeoutMs { get; set; } + public bool CallbackSupportsCancellation { get; set; } + public long Generation { get; set; } + public bool IsTicking { get; set; } + + public long TicksStarted { get; set; } + public long TicksFinished { get; set; } + public long TicksSucceeded { get; set; } + public long TicksFailed { get; set; } + public long TicksTimedOut { get; set; } + public long TicksCanceled { get; set; } + public long SchedulesSkipped { get; set; } + public long Overruns { get; set; } + + public int ConsecutiveSuccesses { get; set; } + public int ConsecutiveFailures { get; set; } + public int ConsecutiveTimeouts { get; set; } + + public double LastExecutionDurationMs { get; set; } + public double LastSuccessfulDurationMs { get; set; } + public double AverageExecutionDurationMs { get; set; } + public double MaxExecutionDurationMs { get; set; } + public double CurrentExecutionDurationMs { get; set; } + + public double LastScheduleLatenessMs { get; set; } + public double MaxScheduleLatenessMs { get; set; } + + public DateTime LastStartUtc { get; set; } + public DateTime LastCompletionUtc { get; set; } + public DateTime LastSuccessUtc { get; set; } + public DateTime LastFailureUtc { get; set; } + public DateTime LastTimeoutUtc { get; set; } + public double LastCompletionAgeMs { get; set; } + + public AsyncTaskTimerTickResult LastResult { get; set; } + public string LastError { get; set; } + public bool Healthy { get; set; } + } } diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index 7da2f37c7..665a6b611 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -2,23 +2,24 @@ using AgroBase.Forms.Operacoes; using AgroBase.Models.Modules; using AgroBase.Models.Operacoes; +using AgroBase.Models.Operadores; using AgroBase.Services; +using AgroBase.Services.Operadores; +using AgroMonitor; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; +using System.Drawing; +using System.Globalization; using System.IO; using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using static AgroBase.Models.Enums; -using System.Drawing; -using System.Text; using static AgroBase.Services.OIDCanService.OidHandler; -using AgroBase.Models.Operadores; -using AgroBase.Services.Operadores; -using AgroMonitor; -using Newtonsoft.Json.Linq; -using System.Globalization; namespace AgroBase.Models { @@ -183,6 +184,9 @@ namespace AgroBase.Models private static AsyncTaskTimerModel tmrLeituras; private static AsyncTaskTimerModel tmrComunicacao; + private static string _discoverySessionId = Guid.NewGuid().ToString("N"); + private static long _telemetrySequence = 0; + #region PARAMETRIZACAO @@ -1694,269 +1698,1192 @@ namespace AgroBase.Models } } - private void EnviarParametorsOperacao() - { - var topico = Variaveis.MqttServiceBase.Topicos.FirstOrDefault(x => x.Topico == VariaveisEquipamento.TopicoMqttParametros.Replace("", VariaveisEquipamento.Parametros.serial_number)); - var p = Variaveis.OperacaoEmAndamento.Parametros; - string msg = JsonConvert.SerializeObject(p); - Task.Run(async () => await Variaveis.MqttServiceBase.PublishAsync(topico, msg)); - } - - public void ExecutaComandoDaBase(OperacaoComandoBaseModel controleBase) - { - if (controleBase.Dispositivo == T_Code.Mod) - { - var _Parametros = controleBase.Parametros; - - - switch (controleBase.Tecla) - { - case BotoesJoystick.Share: // Requisitar parametros da operacao - EnviarParametorsOperacao(); - break; - case BotoesJoystick.Options: // Operacao nao iniciada, carrega todos os parametros compeltos - Variaveis.OperacaoEmAndamento.CarregarParamerosOperacaoBase(_Parametros); - EnviarParametorsOperacao(); - break; - case BotoesJoystick.Touchpad: // Operacao em andamento, atualiza apenas os dados de controle - if (_Parametros.Modo != null) Variaveis.OperacaoEmAndamento.Parametros.Modo = _Parametros.Modo; - if (_Parametros.QtdBicos != null) Variaveis.OperacaoEmAndamento.Parametros.QtdBicos = _Parametros.QtdBicos; - if (_Parametros.Descricao != null) Variaveis.OperacaoEmAndamento.Parametros.Descricao = _Parametros.Descricao; - if (_Parametros.QtdCamerasSolo != null) Variaveis.OperacaoEmAndamento.Parametros.QtdCamerasSolo = _Parametros.QtdCamerasSolo; - if (_Parametros.CapacidadeReservatorio != null) Variaveis.OperacaoEmAndamento.Parametros.CapacidadeReservatorio = _Parametros.CapacidadeReservatorio; - - if (_Parametros.Controle != null) Variaveis.OperacaoEmAndamento.Parametros.Controle = _Parametros.Controle; - if (_Parametros.ParametrosMandatorios != null) Variaveis.OperacaoEmAndamento.Parametros.ParametrosMandatorios = _Parametros.ParametrosMandatorios; - if (_Parametros.ModulosMandatorios != null) - { - Variaveis.OperacaoEmAndamento.Parametros.ModulosMandatorios = _Parametros.ModulosMandatorios; - Variaveis.OperacaoEmAndamento.DefinirComponentesEmUso(); - } - - HealthWorkerService.AtualizarDadosOperacao(true); - EnviarParametorsOperacao(); - break; - case BotoesJoystick.L1: // Inicia o modo simulador - if (!Variaveis.OperacaoEmAndamento.Iniciado) - Task.Run(async () => await Variaveis.OperacaoEmAndamento.IniciarSimulacao()); - break; - case BotoesJoystick.L2: // Inicia a operacao - if (!Variaveis.OperacaoEmAndamento.Iniciado) - Task.Run(async () => await Variaveis.OperacaoEmAndamento.IniciarOperacao()); - break; - case BotoesJoystick.L3: // Finaliza a operacao - if (Variaveis.OperacaoEmAndamento.Iniciado) - Task.Run(async () => await Variaveis.OperacaoEmAndamento.FinalizarOperacao()); - else - ReiniciarOperacao(true); - break; - case BotoesJoystick.R1: // Libera a operacao interrompida mediante supervisao humana - if (!(Variaveis.OperacaoEmAndamento.Trajetoria?.AutonomiaCorredor?.Liberado ?? false)) - { - var (idx_corredor, b_liberada, h_liberado) = ((JObject)controleBase._comp_value).ToObject<(int, bool, bool)>(); - Variaveis.OperacaoEmAndamento.Trajetoria?.AtualizarDadosAutonomiaCorredor(idxCorredor: idx_corredor, bat_liberada: b_liberada, herb_liberado: h_liberado); - } - break; - } - return; - } - - if (controleBase.Dispositivo == T_Code.Ipb) - { - if (controleBase._comp_value == true) - { - Variaveis.IniciarUDP(); - } - else - { - Variaveis.StopUdpChannel(); - } - return; - } - - if (controleBase.Dispositivo == T_Code.Gps) - { - var (la_lateral, la_frontal) = ((JObject)controleBase._comp_value).ToObject<(double?, double?)>(); - if (la_lateral != null || la_frontal != null) - GPSService.LeverArm = new GeoLeverArm(offsetFisicoFrontalCm: VariaveisEquipamento.LeverArmFrontalCm, offsetFisicoLateralCm: VariaveisEquipamento.LeverArmLateralCm, offsetCampoFrontalCm: la_frontal ?? 0, offsetCampoLateralCm: la_lateral ?? 0); - return; - } - - if (controleBase.Dispositivo == T_Code.Snr && Variaveis.OperacaoEmAndamento.DispSen?.Dados?.CameraCaminho?.Id != null) - { - RedisService.AtualizarCampos( - RedisService.CamKey(Variaveis.OperacaoEmAndamento.DispSen.Dados.CameraCaminho.Id), - ("streaming", controleBase.Tecla == BotoesJoystick.L1), - ("frame_type", (TipoFrameCamera)controleBase._comp_value) - ); - return; - } - - if (controleBase.Dispositivo == T_Code.Cam && (Variaveis.OperacaoEmAndamento.DispSen?.Dados?.CamerasSolo?.Any(x => !string.IsNullOrEmpty(x.Id)) ?? false)) - { - RedisService.AtualizarCampos( - RedisService.CamKey(Variaveis.OperacaoEmAndamento.DispSen.Dados.CamerasSolo.FirstOrDefault(x => !string.IsNullOrEmpty(x.Id)).Id), - ("streaming", controleBase.Tecla == BotoesJoystick.L1), - ("frame_type", (TipoFrameCamera)controleBase._comp_value) - ); - return; - } - - if (controleBase.Dispositivo == T_Code.Atu && Variaveis.OperacaoEmAndamento.DispAtu != null) - { - if (controleBase.Controle == null) return; - - S_Code get_comp(string id) - { - if (string.IsNullOrEmpty(id)) return S_Code.sMOD; - var d_atu = Variaveis.OperacaoEmAndamento.DispAtu.Dados; - if (d_atu.BicosPulverizadores.Any(x => x.ID == id)) return S_Code.sBIC; - if (d_atu.BombasPressurizadoras.Any(x => x.ID == id)) return S_Code.sBMB; - if (d_atu.Sensores.Any(x => x.ID == id)) return d_atu.Sensores.First(x => x.ID == id).Componente; - return S_Code.sVZO; - } - - S_Code comp = get_comp(controleBase._comp_id); - List valores = null; - - switch (comp) - { - case S_Code.sMOD: - if (controleBase.Controle.Altura != null) Variaveis.OperacaoEmAndamento.Controle.AlturaBarra = (double)controleBase.Controle.Altura; - break; - case S_Code.sBIC: - if (controleBase._comp_value != null) - { - var bico = Variaveis.OperacaoEmAndamento.Controle.Bicos.FirstOrDefault(x => x.ID == controleBase._comp_id); - if (bico != null) - { - bico.AnguloAbertura = (int)controleBase._comp_value; - valores = new List() { controleBase.Controle.AnguloSP }; - } - } - break; - case S_Code.sBMB: - switch (controleBase.Controle.Comando) - { - case CanMessagePosicaoDados.Command1: - valores = new List() { controleBase.Controle.Potencia, controleBase.Controle.Pressao }; - break; - case CanMessagePosicaoDados.Command2: - Variaveis.OperacaoEmAndamento.Parametros.Controle.AtuModoControle = (ModoControleBomba)controleBase._comp_value; - valores = new List() { (int)controleBase._comp_value, controleBase.Controle.Potencia }; - break; - } - break; - } - - GeneralJoystick.EnviarComandoAtuador(comp, controleBase._comp_id, Status: controleBase.Controle.Estado, Valores: valores, Comando: controleBase.Controle.Comando, Forcar: true); - return; - } - - if (controleBase.Dispositivo == T_Code.Sen && Variaveis.OperacaoEmAndamento.DispSen != null) - { - S_Code get_comp(string id) - { - if (string.IsNullOrEmpty(id)) return S_Code.sMOD; - var d_sen = Variaveis.OperacaoEmAndamento.DispSen.Dados; - if (d_sen.Reles.Any(x => x.ID == id)) return S_Code.sRLE; - if (d_sen.Servos.Any(x => x.ID == id)) return S_Code.sSRV; - if (d_sen.Sinaleiros.Any(x => x.ID == id)) return S_Code.sLED; - if (d_sen.Sensores.Any(x => x.ID == id)) return d_sen.Sensores.First(x => x.ID == id).Componente; - return S_Code.sVZO; - } - - S_Code comp = get_comp(controleBase._comp_id); - GeneralJoystick.EnviarComandoSensoriamento(comp, controleBase._comp_id, controleBase._comp_value); - return; - } - - if (controleBase.Dispositivo == T_Code.Dir && controleBase.Tecla == BotoesJoystick.R3) - { - //GeneralJoystick.EnviaComandoMotor(Keys.R, ForcarComando: true, ID: controleBase._comp_id); - GeneralJoystick.ProcessarDadosControle(BotoesJoystick.R3, false, ID: controleBase._comp_id, ForcarComando: true); - return; - } - - if (controleBase.Dispositivo == T_Code.Npc) - { - var (modo, e_entrada, e_saida, t_ligar, t_turbo) = ((JObject)controleBase._comp_value).ToObject<(CoolerControlService.CoolerMode, Estado?, Estado?, double?, double?)>(); - Variaveis.OperacaoEmAndamento.DispSen?.Dados?.CoolerControl?.AlterarControle(modo, entrada: e_entrada, saida: e_saida, tempOn: t_ligar, tempTurbo: t_turbo); - return; - } - - if (controleBase.Dispositivo == T_Code.Trj) - { - var (lat, lon, pontosRetorno) = ((JObject)controleBase._comp_value).ToObject<(double?, double?, List)>(); - GPSModel posicaoBase = null; - if (lat != null && lon != null) - posicaoBase = new GPSModel() { Latitude = lat.Value, Longitude = lon.Value }; - List pontosRetornoPos = null; - if (pontosRetorno?.Any() ?? false) - pontosRetornoPos = pontosRetorno.Select(x => new GPSModel() { Latitude = x[0], Longitude = x[1] }).ToList(); - Variaveis.OperacaoEmAndamento.Trajetoria?.IniciarRetornoBase(posicaoBase, pontosRetornoPos); - return; - } - - Variaveis.OperacaoEmAndamento.Emergencia = controleBase.Emergencia; - if (Variaveis.OperacaoEmAndamento.Emergencia) return; - - Variaveis.OperacaoEmAndamento.Pausa = controleBase.Pausa; - if (Variaveis.OperacaoEmAndamento.Pausa) return; - - if (controleBase.Controle == null) return; - - var cmdRover = GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.botaoJoy == controleBase.Tecla); - - if (cmdRover?.Dispositivo == T_Code.Mov && (Variaveis.OperacaoEmAndamento.Parametros?.Controle?.MovimentoAutomatico ?? false) && Variaveis.OperacaoEmAndamento.Iniciado) return; - if (cmdRover?.Dispositivo == T_Code.Dir && (Variaveis.OperacaoEmAndamento.Parametros?.Controle?.DirecionalAutomatico ?? false) && Variaveis.OperacaoEmAndamento.Iniciado) return; - - var _Controle = Variaveis.OperacaoEmAndamento.Controle; - if (controleBase.Controle.PercentualVelocidadeSP != null) _Controle.PercentualVelocidadeSP = (double)controleBase.Controle.PercentualVelocidadeSP; - if (controleBase.Controle.AnguloSP != null) _Controle.Angulo = (double)controleBase.Controle.AnguloSP; - if (controleBase.Controle.TipoMovimentoDirecional != null) _Controle.TipoMovimento = (TipoMovimentoDirecional)controleBase.Controle.TipoMovimentoDirecional; - if (controleBase.Controle.EmFreio != null) _Controle.EmFreio = (bool)controleBase.Controle.EmFreio; - - //GeneralJoystick.EnviaComandoMotor(tecla, controleBase.Dispositivo, ForcarComando: true, ID: controleBase._comp_id); - - switch (cmdRover?.Dispositivo) - { - case T_Code.Mov: - GeneralJoystick.ProcessarDadosControle(cmdRover.botaoJoy, controleBase.Solto, valorDesejado: _Controle.PercentualVelocidadeSP, ForcarComando: true); - break; - case T_Code.Dir: - GeneralJoystick.ProcessarDadosControle(cmdRover.botaoJoy, controleBase.Solto, valorDesejado: _Controle.Angulo, ForcarComando: true); - break; - } - } + private async Task tmrComunicacao_Tick() { - if (Variaveis.MqttServiceBase == null) return; + var service = Variaveis.MqttServiceBase; + + if (service == null) + return; + + bool baseEncontrada = + (DateTime.Now - VariaveisOperacao + .PosicaoBase + .UltimoComandoRespondido) + .TotalSeconds < 10; - bool base_encontrada = (DateTime.Now - VariaveisOperacao.PosicaoBase.UltimoComandoRespondido).TotalSeconds < 10; MqttService.MqttTopicosModel topico = null; - string msg = string.Empty; - if (!base_encontrada) + + if (!baseEncontrada) { - topico = Variaveis.MqttServiceBase.Topicos.FirstOrDefault(x => x.Topico == VariaveisMonitoramento.TopicoMqttDispositivos); - msg = $"{VariaveisEquipamento.Parametros.serial_number},{VariaveisEquipamento.Parametros.rover_ip}"; + topico = service.Topicos.FirstOrDefault( + x => x.Topico == + VariaveisMonitoramento.TopicoMqttDispositivos + ); + + if (topico == null) + return; + + var discovery = new + { + rover_id = + VariaveisEquipamento.Parametros.serial_number, + rover_ip = + VariaveisEquipamento.Parametros.rover_ip, + session_id = + _discoverySessionId, + sent_at_utc = + DateTime.UtcNow + }; + + string msg = JsonConvert.SerializeObject(discovery); + + await service.PublishWithResultAsync( + topico, + msg + ); + + return; } - else + + topico = service.Topicos.FirstOrDefault( + x => x.Topico == + VariaveisEquipamento + .TopicoMqttTelemetria + .Replace( + "", + VariaveisEquipamento + .Parametros + .serial_number + ) + ); + + if (topico == null) + return; + + var dados = + Variaveis + .OperacaoEmAndamento + .Parametros + .DadosLeitura; + + if (dados == null) + return; + + dados.Atualizar(); + + OperacaoParametrosDadosModel snapshot = + dados.Clone(); + + long sequence = Interlocked.Increment( + ref _telemetrySequence + ); + + /* + * Por enquanto mantemos o payload como OperacaoParametrosDadosModel + * puro para compatibilidade com a base. O sequence já fica calculado + * aqui para a próxima etapa, quando entrarmos no TelemetryEnvelope. + */ + + string payload = + JsonConvert.SerializeObject(snapshot); + + MqttPublishResult result = + await service.PublishWithResultAsync( + topico, + payload + ); + + if (result.Succeeded) { - topico = Variaveis.MqttServiceBase.Topicos.FirstOrDefault(x => x.Topico == VariaveisEquipamento.TopicoMqttTelemetria.Replace("", VariaveisEquipamento.Parametros.serial_number)); - Variaveis.OperacaoEmAndamento.Parametros.DadosLeitura?.Atualizar(); - var data = Variaveis.OperacaoEmAndamento.Parametros.DadosLeitura?.Clone(); - msg = JsonConvert.SerializeObject(data); - } - if (topico != null && !string.IsNullOrEmpty(msg)) - { - await Variaveis.MqttServiceBase.PublishAsync(topico, msg); - if (Variaveis.OperacaoEmAndamento.Parametros.DadosLeitura != null) - Variaveis.OperacaoEmAndamento.Parametros.DadosLeitura.UltimoEnvioLog = DateTime.Now; + /* + * O cursor dos logs só avança se a telemetria foi aceita + * pelo broker. Assim, se o publish falhar, os logs entram + * novamente no próximo snapshot. + */ + dados.UltimoEnvioLog = snapshot.Momento; } } + + + // ============================================================================ + // CAMPOS + // ============================================================================ + + private readonly SemaphoreSlim _baseCommandGate = + new SemaphoreSlim(1, 1); + + private readonly object _baseCommandDedupeLock = + new object(); + + private readonly Dictionary _baseCommandDedupe = + new Dictionary(); + + private static readonly TimeSpan BaseCommandDedupeWindow = + TimeSpan.FromMilliseconds(1500); + + + // ============================================================================ + // PUBLICAÇÃO DE PARÂMETROS + // ============================================================================ + + private void EnviarParametorsOperacao() + { + Forget( + EnviarParametrosOperacaoAsync(), + "Enviar parâmetros da operação" + ); + } + + private async Task EnviarParametrosOperacaoAsync() + { + var service = Variaveis.MqttServiceBase; + + if (service == null) + return; + + string topicName = + VariaveisEquipamento + .TopicoMqttParametros + .Replace( + "", + VariaveisEquipamento + .Parametros + .serial_number + ); + + var topico = service.Topicos + .FirstOrDefault(x => x.Topico == topicName); + + if (topico == null) + return; + + var parametros = + Variaveis + .OperacaoEmAndamento? + .Parametros; + + if (parametros == null) + return; + + string msg = + JsonConvert.SerializeObject(parametros); + + /* + * Preferencial com o MqttService novo. + * Se sua versão ainda não tiver PublishWithResultAsync, + * troque este bloco por: + * + * await service.PublishAsync(topico, msg); + */ + MqttPublishResult result = + await service.PublishWithResultAsync( + topico, + msg + ).ConfigureAwait(false); + + if (!result.Succeeded) + { + Variaveis.MostrarLog( + "[ComandoBase] Falha ao publicar parâmetros: " + + result.Status + " " + result.Error + ); + } + } + + + // ============================================================================ + // ENTRADA COMPATÍVEL + // ============================================================================ + + public void ExecutaComandoDaBase( + OperacaoComandoBaseModel controleBase) + { + Forget( + ExecutaComandoDaBaseAsync(controleBase), + "Executar comando da base" + ); + } + + public async Task ExecutaComandoDaBaseAsync( + OperacaoComandoBaseModel controleBase) + { + if (controleBase == null) + return; + + if (Variaveis.Fechando) + return; + + if (EhComandoDuplicado(controleBase)) + return; + + bool entered = false; + + try + { + /* + * Comando remoto é rota crítica. + * Processar em série evita: + * - iniciar e finalizar operação ao mesmo tempo; + * - atualizar parâmetros enquanto inicia operação; + * - dois comandos de atuador disputando o mesmo estado local. + */ + await _baseCommandGate + .WaitAsync() + .ConfigureAwait(false); + + entered = true; + + await ExecutaComandoDaBaseInternoAsync( + controleBase + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "[ComandoBase] Erro ao executar comando: " + + ex + ); + } + finally + { + if (entered) + _baseCommandGate.Release(); + } + } + + private async Task ExecutaComandoDaBaseInternoAsync( + OperacaoComandoBaseModel controleBase) + { + var op = Variaveis.OperacaoEmAndamento; + + if (op == null) + return; + + switch (controleBase.Dispositivo) + { + case T_Code.Mod: + await ExecutarComandoModuloAsync( + controleBase, + op + ).ConfigureAwait(false); + return; + + case T_Code.Ipb: + ExecutarComandoIpBridge(controleBase); + return; + + case T_Code.Gps: + ExecutarComandoGps(controleBase); + return; + + case T_Code.Snr: + ExecutarComandoCameraCaminho(controleBase, op); + return; + + case T_Code.Cam: + ExecutarComandoCameraSolo(controleBase, op); + return; + + case T_Code.Atu: + ExecutarComandoAtuador(controleBase, op); + return; + + case T_Code.Sen: + ExecutarComandoSensoriamento(controleBase, op); + return; + + case T_Code.Dir: + if (controleBase.Tecla == BotoesJoystick.R3) + { + GeneralJoystick.ProcessarDadosControle( + BotoesJoystick.R3, + false, + ID: controleBase._comp_id, + ForcarComando: true + ); + return; + } + break; + + case T_Code.Npc: + ExecutarComandoNpc(controleBase, op); + return; + + case T_Code.Trj: + ExecutarComandoTrajetoria(controleBase, op); + return; + } + + ExecutarComandoMovDirGenerico(controleBase, op); + } + + + // ============================================================================ + // COMANDOS DE OPERAÇÃO + // ============================================================================ + + private async Task ExecutarComandoModuloAsync( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + var parametros = controleBase.Parametros; + + switch (controleBase.Tecla) + { + case BotoesJoystick.Share: + await EnviarParametrosOperacaoAsync() + .ConfigureAwait(false); + return; + + case BotoesJoystick.Options: + if (parametros == null) + return; + + /* + * Carga completa de parâmetros. Idealmente só antes + * da operação estar iniciada. + */ + if (op.Iniciado) + { + Variaveis.MostrarLog( + "[ComandoBase] Ignorando carga completa de parâmetros com operação iniciada." + ); + + return; + } + + op.CarregarParamerosOperacaoBase(parametros); + + await EnviarParametrosOperacaoAsync() + .ConfigureAwait(false); + return; + + case BotoesJoystick.Touchpad: + if (parametros == null) + return; + + AtualizarParametrosParciais(parametros, op); + + HealthWorkerService.AtualizarDadosOperacao(true); + + await EnviarParametrosOperacaoAsync() + .ConfigureAwait(false); + return; + + case BotoesJoystick.L1: + if (!op.Iniciado) + await op.IniciarSimulacao() + .ConfigureAwait(false); + return; + + case BotoesJoystick.L2: + if (!op.Iniciado) + await op.IniciarOperacao() + .ConfigureAwait(false); + return; + + case BotoesJoystick.L3: + if (op.Iniciado) + await op.FinalizarOperacao() + .ConfigureAwait(false); + else + ReiniciarOperacao(true); + return; + + case BotoesJoystick.R1: + ExecutarLiberacaoHumanaAutonomia( + controleBase, + op + ); + return; + } + } + + private static void AtualizarParametrosParciais( + OperacaoParametrosModel parametros, + dynamic op) + { + if (op?.Parametros == null) + return; + + if (parametros.Modo != null) + op.Parametros.Modo = parametros.Modo; + + if (parametros.QtdBicos != null) + op.Parametros.QtdBicos = parametros.QtdBicos; + + if (parametros.Descricao != null) + op.Parametros.Descricao = parametros.Descricao; + + if (parametros.QtdCamerasSolo != null) + op.Parametros.QtdCamerasSolo = + parametros.QtdCamerasSolo; + + if (parametros.CapacidadeReservatorio != null) + op.Parametros.CapacidadeReservatorio = + parametros.CapacidadeReservatorio; + + if (parametros.Controle != null) + op.Parametros.Controle = parametros.Controle; + + if (parametros.ParametrosMandatorios != null) + op.Parametros.ParametrosMandatorios = + parametros.ParametrosMandatorios; + + if (parametros.ModulosMandatorios != null) + { + op.Parametros.ModulosMandatorios = + parametros.ModulosMandatorios; + + op.DefinirComponentesEmUso(); + } + } + + private void ExecutarLiberacaoHumanaAutonomia( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + if (op?.Trajetoria?.AutonomiaCorredor?.Liberado ?? false) + return; + + if (!TryDeserializeValue( + controleBase._comp_value, + out ValueTuple data)) + { + Variaveis.MostrarLog( + "[ComandoBase] Payload inválido para liberação humana." + ); + return; + } + + op.Trajetoria? + .AtualizarDadosAutonomiaCorredor( + idxCorredor: data.Item1, + bat_liberada: data.Item2, + herb_liberado: data.Item3 + ); + } + + + // ============================================================================ + // COMANDOS INDIVIDUAIS + // ============================================================================ + + private void ExecutarComandoIpBridge( + OperacaoComandoBaseModel controleBase) + { + bool ligar; + + if (!TryDeserializeValue( + controleBase._comp_value, + out ligar)) + { + return; + } + + if (ligar) + Variaveis.IniciarUDP(); + else + Variaveis.StopUdpChannel(); + } + + private void ExecutarComandoGps( + OperacaoComandoBaseModel controleBase) + { + if (!TryDeserializeValue( + controleBase._comp_value, + out ValueTuple lever)) + { + Variaveis.MostrarLog( + "[ComandoBase] Payload inválido para lever arm." + ); + return; + } + + double? laLateral = lever.Item1; + double? laFrontal = lever.Item2; + + if (laLateral == null && laFrontal == null) + return; + + GPSService.LeverArm = new GeoLeverArm( + offsetFisicoFrontalCm: + VariaveisEquipamento.LeverArmFrontalCm, + offsetFisicoLateralCm: + VariaveisEquipamento.LeverArmLateralCm, + offsetCampoFrontalCm: + laFrontal ?? 0, + offsetCampoLateralCm: + laLateral ?? 0 + ); + } + + private void ExecutarComandoCameraCaminho( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + string cameraId = + op?.DispSen?.Dados?.CameraCaminho?.Id; + + if (string.IsNullOrWhiteSpace(cameraId)) + return; + + if (!TryDeserializeValue( + controleBase._comp_value, + out TipoFrameCamera frameType)) + { + return; + } + + RedisService.AtualizarCampos( + RedisService.CamKey(cameraId), + ("streaming", controleBase.Tecla == BotoesJoystick.L1), + ("frame_type", frameType) + ); + } + + private void ExecutarComandoCameraSolo( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + var cameras = + op?.DispSen?.Dados?.CamerasSolo; + + string cameraId = null; + + if (cameras != null) + { + foreach (var camera in cameras) + { + if (!string.IsNullOrWhiteSpace(camera.Id)) + { + cameraId = camera.Id; + break; + } + } + } + + if (string.IsNullOrWhiteSpace(cameraId)) + return; + + if (!TryDeserializeValue( + controleBase._comp_value, + out TipoFrameCamera frameType)) + { + return; + } + + RedisService.AtualizarCampos( + RedisService.CamKey(cameraId), + ("streaming", controleBase.Tecla == BotoesJoystick.L1), + ("frame_type", frameType) + ); + } + + private void ExecutarComandoAtuador( + OperacaoComandoBaseModel controleBase, + OperacaoModel op) + { + if (controleBase.Controle == null) + return; + + var dadosAtu = op?.DispAtu?.Dados; + + if (dadosAtu == null) + return; + + S_Code comp = + ResolverComponenteAtuador( + dadosAtu, + controleBase._comp_id + ); + + if (comp == S_Code.sVZO) + { + Variaveis.MostrarLog( + "[ComandoBase] Componente ATU não encontrado: " + + controleBase._comp_id + ); + return; + } + + List valores = null; + + switch (comp) + { + case S_Code.sMOD: + if (controleBase.Controle.Altura != null) + { + op.Controle.AlturaBarra = + (double)controleBase.Controle.Altura; + } + break; + + case S_Code.sBIC: + if (controleBase._comp_value != null) + { + int angulo; + + if (TryDeserializeValue( + controleBase._comp_value, + out angulo)) + { + var bico = + op.Controle + .Bicos + .FirstOrDefault( + x => x.ID == + controleBase._comp_id + ); + + if (bico != null) + bico.AnguloAbertura = angulo; + } + + valores = new List + { + controleBase.Controle.AnguloSP + }; + } + break; + + case S_Code.sBMB: + switch (controleBase.Controle.Comando) + { + case CanMessagePosicaoDados.Command1: + valores = new List + { + controleBase.Controle.Potencia, + controleBase.Controle.Pressao + }; + break; + + case CanMessagePosicaoDados.Command2: + ModoControleBomba modo; + + if (TryDeserializeValue( + controleBase._comp_value, + out modo)) + { + if (op.Parametros?.Controle != null) + { + op.Parametros + .Controle + .AtuModoControle = modo; + } + + valores = new List + { + (int)modo, + controleBase.Controle.Potencia + }; + } + break; + } + break; + } + + GeneralJoystick.EnviarComandoAtuador( + comp, + controleBase._comp_id, + Status: controleBase.Controle.Estado, + Valores: valores, + Comando: controleBase.Controle.Comando, + Forcar: true + ); + } + + private void ExecutarComandoSensoriamento( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + var dadosSen = op?.DispSen?.Dados; + + if (dadosSen == null) + return; + + S_Code comp = + ResolverComponenteSensoriamento( + dadosSen, + controleBase._comp_id + ); + + if (comp == S_Code.sVZO) + { + Variaveis.MostrarLog( + "[ComandoBase] Componente SEN não encontrado: " + + controleBase._comp_id + ); + return; + } + + GeneralJoystick.EnviarComandoSensoriamento( + comp, + controleBase._comp_id, + controleBase._comp_value + ); + } + + private void ExecutarComandoNpc( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + if (!TryDeserializeValue( + controleBase._comp_value, + out ValueTuple< + CoolerControlService.CoolerMode, + Estado?, + Estado?, + double?, + double?> data)) + { + Variaveis.MostrarLog( + "[ComandoBase] Payload inválido para cooler." + ); + return; + } + + op?.DispSen? + .Dados? + .CoolerControl? + .AlterarControle( + data.Item1, + entrada: data.Item2, + saida: data.Item3, + tempOn: data.Item4, + tempTurbo: data.Item5 + ); + } + + private void ExecutarComandoTrajetoria( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + if (!TryDeserializeValue( + controleBase._comp_value, + out ValueTuple< + double?, + double?, + List> data)) + { + Variaveis.MostrarLog( + "[ComandoBase] Payload inválido para retorno base." + ); + return; + } + + GPSModel posicaoBase = null; + + if (data.Item1 != null && data.Item2 != null) + { + posicaoBase = new GPSModel + { + Latitude = data.Item1.Value, + Longitude = data.Item2.Value + }; + } + + List pontosRetornoPos = null; + + if (data.Item3 != null && data.Item3.Any()) + { + pontosRetornoPos = data.Item3 + .Where(x => x != null && x.Length >= 2) + .Select(x => new GPSModel + { + Latitude = x[0], + Longitude = x[1] + }) + .ToList(); + } + + op?.Trajetoria? + .IniciarRetornoBase( + posicaoBase, + pontosRetornoPos + ); + } + + + // ============================================================================ + // MOV/DIR GENÉRICO + // ============================================================================ + + private void ExecutarComandoMovDirGenerico( + OperacaoComandoBaseModel controleBase, + dynamic op) + { + op.Emergencia = controleBase.Emergencia; + + if (op.Emergencia) + return; + + op.Pausa = controleBase.Pausa; + + if (op.Pausa) + return; + + if (controleBase.Controle == null) + return; + + var cmdRover = GeneralJoystick + .DeParaComandos + .FirstOrDefault( + x => x.botaoJoy == controleBase.Tecla + ); + + if (cmdRover == null) + return; + + if (cmdRover.Dispositivo == T_Code.Mov && + (op.Parametros?.Controle?.MovimentoAutomatico ?? false) && + op.Iniciado) + { + return; + } + + if (cmdRover.Dispositivo == T_Code.Dir && + (op.Parametros?.Controle?.DirecionalAutomatico ?? false) && + op.Iniciado) + { + return; + } + + var controle = op.Controle; + + if (controle == null) + return; + + if (controleBase.Controle.PercentualVelocidadeSP != null) + controle.PercentualVelocidadeSP = + (double)controleBase + .Controle + .PercentualVelocidadeSP; + + if (controleBase.Controle.AnguloSP != null) + controle.Angulo = + (double)controleBase + .Controle + .AnguloSP; + + if (controleBase.Controle.TipoMovimentoDirecional != null) + controle.TipoMovimento = + (TipoMovimentoDirecional)controleBase + .Controle + .TipoMovimentoDirecional; + + if (controleBase.Controle.EmFreio != null) + controle.EmFreio = + (bool)controleBase + .Controle + .EmFreio; + + switch (cmdRover.Dispositivo) + { + case T_Code.Mov: + GeneralJoystick.ProcessarDadosControle( + cmdRover.botaoJoy, + controleBase.Solto, + valorDesejado: + controle.PercentualVelocidadeSP, + ForcarComando: true + ); + break; + + case T_Code.Dir: + GeneralJoystick.ProcessarDadosControle( + cmdRover.botaoJoy, + controleBase.Solto, + valorDesejado: controle.Angulo, + ForcarComando: true + ); + break; + } + } + + + // ============================================================================ + // RESOLUÇÃO DE COMPONENTES + // ============================================================================ + + private static S_Code ResolverComponenteAtuador( + dynamic dadosAtu, + string id) + { + if (string.IsNullOrWhiteSpace(id)) + return S_Code.sMOD; + + if (dadosAtu?.BicosPulverizadores != null) + { + foreach (var item in dadosAtu.BicosPulverizadores) + if (item.ID == id) + return S_Code.sBIC; + } + + if (dadosAtu?.BombasPressurizadoras != null) + { + foreach (var item in dadosAtu.BombasPressurizadoras) + if (item.ID == id) + return S_Code.sBMB; + } + + if (dadosAtu?.Sensores != null) + { + foreach (var item in dadosAtu.Sensores) + if (item.ID == id) + return item.Componente; + } + + return S_Code.sVZO; + } + + private static S_Code ResolverComponenteSensoriamento( + dynamic dadosSen, + string id) + { + if (string.IsNullOrWhiteSpace(id)) + return S_Code.sMOD; + + if (dadosSen?.Reles != null) + { + foreach (var item in dadosSen.Reles) + if (item.ID == id) + return S_Code.sRLE; + } + + if (dadosSen?.Servos != null) + { + foreach (var item in dadosSen.Servos) + if (item.ID == id) + return S_Code.sSRV; + } + + if (dadosSen?.Sinaleiros != null) + { + foreach (var item in dadosSen.Sinaleiros) + if (item.ID == id) + return S_Code.sLED; + } + + if (dadosSen?.Sensores != null) + { + foreach (var item in dadosSen.Sensores) + if (item.ID == id) + return item.Componente; + } + + return S_Code.sVZO; + } + + + // ============================================================================ + // DEDUPE + // ============================================================================ + + private bool EhComandoDuplicado( + OperacaoComandoBaseModel comando) + { + if (!DeveDeduplicar(comando)) + return false; + + string key = CriarChaveDedupe(comando); + + if (string.IsNullOrWhiteSpace(key)) + return false; + + DateTime now = DateTime.UtcNow; + + lock (_baseCommandDedupeLock) + { + /* + * Limpeza barata. + */ + var expirados = _baseCommandDedupe + .Where(x => now - x.Value > + TimeSpan.FromSeconds(5)) + .Select(x => x.Key) + .ToList(); + + foreach (string item in expirados) + _baseCommandDedupe.Remove(item); + + DateTime last; + + if (_baseCommandDedupe.TryGetValue(key, out last) && + now - last < BaseCommandDedupeWindow) + { + Variaveis.MostrarLog( + "[ComandoBase] Comando duplicado ignorado: " + + comando.Dispositivo + "/" + comando.Tecla + ); + + return true; + } + + _baseCommandDedupe[key] = now; + return false; + } + } + + private static bool DeveDeduplicar( + OperacaoComandoBaseModel comando) + { + if (comando == null) + return false; + + /* + * Não deduplicamos MOV/DIR genérico porque o controle manual + * pode repetir o mesmo valor para manter atuação/estado. + * + * Deduplicamos comandos de alto nível e configuração, que são + * perigosos se QoS1 entregar duplicado. + */ + switch (comando.Dispositivo) + { + case T_Code.Mod: + case T_Code.Ipb: + case T_Code.Gps: + case T_Code.Snr: + case T_Code.Cam: + case T_Code.Npc: + case T_Code.Trj: + return true; + default: + return false; + } + } + + private static string CriarChaveDedupe( + OperacaoComandoBaseModel comando) + { + try + { + return string.Join( + "|", + comando.Dispositivo, + comando.Tecla, + comando._comp_id ?? string.Empty, + comando.Solto, + comando.Emergencia, + comando.Pausa, + JsonConvert.SerializeObject(comando._comp_value), + JsonConvert.SerializeObject(comando.Parametros), + JsonConvert.SerializeObject(comando.Controle) + ); + } + catch + { + return comando.Dispositivo + "|" + + comando.Tecla + "|" + + comando._comp_id; + } + } + + + // ============================================================================ + // CONVERSÃO SEGURA DE PAYLOAD + // ============================================================================ + + private static bool TryDeserializeValue( + object value, + out T result) + { + result = default(T); + + try + { + if (value == null) + return false; + + if (value is T typed) + { + result = typed; + return true; + } + + JToken token = value as JToken; + + if (token != null) + { + result = token.ToObject(); + return true; + } + + string text = value as string; + + if (!string.IsNullOrWhiteSpace(text)) + { + result = JsonConvert.DeserializeObject(text); + return true; + } + + Type target = + Nullable.GetUnderlyingType(typeof(T)) ?? + typeof(T); + + if (target.IsEnum) + { + object enumValue = + Enum.ToObject(target, value); + + result = (T)enumValue; + return true; + } + + result = (T)Convert.ChangeType(value, target); + return true; + } + catch + { + result = default(T); + return false; + } + } + + + // ============================================================================ + // TASK FIRE-AND-FORGET OBSERVADO + // ============================================================================ + + private static void Forget( + Task task, + string context) + { + if (task == null) + return; + + _ = task.ContinueWith( + completed => + { + Exception ex = + completed.Exception? + .GetBaseException(); + + if (ex != null) + { + try + { + Variaveis.MostrarLog( + "[ComandoBase] " + + context + ": " + + ex.Message + ); + } + catch { } + } + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + + #endregion #region REGISTRO DE LOGS diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs index 83143958b..f441468e1 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs @@ -150,8 +150,20 @@ namespace AgroBase.Models.Operacoes public MapaFeatureCollectionModel Mapa { get; set; } public List RuasPercorrer { get; set; } public List PontosRetorno { get; set; } - - public OperacaoParametrosDadosModel DadosLeitura { get; set; } + + private OperacaoParametrosDadosModel _dadosLeitura; + public OperacaoParametrosDadosModel DadosLeitura + { + get => _dadosLeitura; + set + { + if (!object.ReferenceEquals(_dadosLeitura, value)) + { + _dadosLeitura = value; + OnPropertyChanged(nameof(DadosLeitura)); + } + } + } public event PropertyChangedEventHandler PropertyChanged; @@ -561,75 +573,155 @@ namespace AgroBase.Models.Operacoes public DateTime Momento { get; set; } public StatusModulo StatusRover { get; set; } public CanServiceTipo TipoCanAtivo { get; set; } + public OperacaoParametrosDadosOperacaoModel Operacao { get; set; } public OperacaoParametrosDadosTrajetoriaModel Trajetoria { get; set; } public OperacaoParametrosDadosControleModel Controle { get; set; } + public List ModulosSaude { get; set; } public List DispositivosMapeados { get; set; } + public OperacaoSensoriamentoLogImuModel Imu { get; set; } public OperacaoParametrosDadosAtuadorModel Atuador { get; set; } public OperacaoParametrosDadosSensoriamentoModel Sensoriamento { get; set; } public OperacaoParametrosDadosMovimentacaoModel Movimentacao { get; set; } public OperacaoParametrosDadosDirecionalModel Direcional { get; set; } + public OperacaoSensoriamentoLogBateriaModel Bateria { get; set; } public CoolerControlDataModel Refrigeracao { get; set; } public OperacaoParametrosDadosGNSSModel Gnss { get; set; } public PerformanceModel PerformanceNcp { get; set; } public LivoxModel LivoxLidar { get; set; } + public DateTime UltimoEnvioLog { get; set; } = DateTime.MinValue; + public List Logs { get; set; } public List Cameras { get; set; } public VisualWorkerResumoModel OperadorVisual { get; set; } public void Atualizar() { + DateTime agora = DateTime.Now; + + Momento = agora; + var op = Variaveis.OperacaoEmAndamento; - var sen = op.Sensoriamento; - var dAtu = op.DispAtu?.Dados; - var dSen = op.DispSen?.Dados; - var dMvd = op.DispMvd?.Dados; + var sen = op?.Sensoriamento; - if (dAtu == null || dSen == null || dMvd == null) return; + var dAtu = op?.DispAtu?.Dados; + var dSen = op?.DispSen?.Dados; + var dMvd = op?.DispMvd?.Dados; - Momento = DateTime.Now; - StatusRover = op.Status; + StatusRover = op?.Status ?? StatusModulo.Desconectado; TipoCanAtivo = CanManager.TipoServico; - Operacao = new OperacaoParametrosDadosOperacaoModel() + + AtualizarOperacao(op); + AtualizarTrajetoria(sen); + AtualizarControle(op); + AtualizarSaude(sen); + AtualizarImu(sen); + AtualizarAtuador(sen, dAtu); + AtualizarSensoriamento(dSen); + AtualizarMovimentacao(sen, dMvd); + AtualizarDirecional(sen, dMvd); + AtualizarGnss(sen); + AtualizarOutrosBlocos(sen); + AtualizarCameras(); + AtualizarLogs(sen); + } + + private void AtualizarOperacao(dynamic op) + { + var parametros = op?.Parametros; + + Operacao = new OperacaoParametrosDadosOperacaoModel { - Iniciada = op.Iniciado, - Liberada = op.OperacaoLiberada, - Status = op.StatusAtual, - Modo = op.Parametros?.Modo ?? ModoOperacao.NaoDefinido, - ImpedimentoErro = op.ErroOperacaoLiberada, - TempoAguardandoSegs = op.TempoAguardandoSegs, - TempoDecorridoSegs = op.TempoDecorridoSegs, - Emergencia = op.Emergencia, - Pausa = op.Pausa, - IniciadoEm = op.DataInicio, - FinalizadoEm = op.DataFim, - ModulosMandatorios = new List(op.Parametros.ModulosMandatorios ?? new List()), - ParametrosMandatorios = new List(op.Parametros.ParametrosMandatorios ?? new List()) + Iniciada = op?.Iniciado ?? false, + Liberada = op?.OperacaoLiberada ?? false, + Status = op?.StatusAtual ?? StatusOperacao.Erro, + Modo = parametros?.Modo ?? ModoOperacao.NaoDefinido, + ImpedimentoErro = op?.ErroOperacaoLiberada, + TempoAguardandoSegs = op?.TempoAguardandoSegs ?? 0, + TempoDecorridoSegs = op?.TempoDecorridoSegs ?? 0, + Emergencia = op?.Emergencia ?? false, + Pausa = op?.Pausa ?? false, + IniciadoEm = op?.DataInicio ?? DateTime.MinValue, + FinalizadoEm = op?.DataFim ?? DateTime.MinValue, + ModulosMandatorios = CloneList( + parametros?.ModulosMandatorios + ), + ParametrosMandatorios = CloneList( + parametros?.ParametrosMandatorios + ) }; + } + + private void AtualizarTrajetoria(dynamic sen) + { var t = sen?.Trajetoria; - Trajetoria = t != null ? new OperacaoParametrosDadosTrajetoriaModel() + + if (t == null) { - AutonomiaCorredor = new OperacaoParametrosDadosTrajetoriaAutonomiaCorredorModel() + Trajetoria = new OperacaoParametrosDadosTrajetoriaModel { - BateriaSuficiente = t.AutonomiaCooredor.BateriaSuficiente, - DistanciaCorredor_m = t.AutonomiaCooredor.DistanciaSeguraBateria_m, - DistanciaSeguraBateria_m = t.AutonomiaCooredor.DistanciaSeguraBateria_m, - DistanciaSeguraHerbicida_m = t.AutonomiaCooredor.DistanciaSeguraBateria_m, - HerbicidaSuficiente = t.AutonomiaCooredor.HerbicidaSuficiente, - Liberado = t.AutonomiaCooredor.Liberado, - Motivo = t.AutonomiaCooredor.Motivo, - Motivos = t.AutonomiaCooredor.Motivos.ToList(), - Status = t.AutonomiaCooredor.Status - }, + AutonomiaCorredor = + new OperacaoParametrosDadosTrajetoriaAutonomiaCorredorModel() + }; + return; + } + + var autonomia = t.AutonomiaCooredor; + var corredorAtual = t.CorredorAtual; + var pontoAtual = t.PontoAtual; + var proximoPonto = t.ProximoPonto; + + Trajetoria = new OperacaoParametrosDadosTrajetoriaModel + { + AutonomiaCorredor = + autonomia != null + ? new OperacaoParametrosDadosTrajetoriaAutonomiaCorredorModel + { + BateriaSuficiente = autonomia.BateriaSuficiente, + DistanciaCorredor_m = TryGetDouble( + autonomia, + "DistanciaCorredor_m", + TryGetDouble( + autonomia, + "DistanciaCorredor", + autonomia.DistanciaSeguraBateria_m + ) + ), + DistanciaSeguraBateria_m = + autonomia.DistanciaSeguraBateria_m, + DistanciaSeguraHerbicida_m = TryGetDouble( + autonomia, + "DistanciaSeguraHerbicida_m", + TryGetDouble( + autonomia, + "DistanciaSeguraHerbicida", + autonomia.DistanciaSeguraBateria_m + ) + ), + HerbicidaSuficiente = + autonomia.HerbicidaSuficiente, + Liberado = autonomia.Liberado, + Motivo = autonomia.Motivo, + Motivos = CloneListString(autonomia.Motivos), + Status = autonomia.Status + } + : new OperacaoParametrosDadosTrajetoriaAutonomiaCorredorModel(), + AnguloCaminho = t.AnguloCaminho, - CorredorAtualDentro = t.CorredorAtual.Dentro, - CorredorAtualDistanciaPercorrida = t.CorredorAtual.DistanciaPercorridaCorredor, - CorredorAtualDistanciaTotal = t.CorredorAtual.DistanciaTotal, - CorredorAtualIdx = t.CorredorAtual.Idx, + + CorredorAtualDentro = + corredorAtual?.Dentro ?? false, + CorredorAtualDistanciaPercorrida = + corredorAtual?.DistanciaPercorridaCorredor ?? 0, + CorredorAtualDistanciaTotal = + corredorAtual?.DistanciaTotal ?? 0, + CorredorAtualIdx = + corredorAtual?.Idx ?? 0, + DistanciaDireita = t.DistanciaDireita, DistanciaEsquerda = t.DistanciaEsquerda, DistanciaPercorrida = t.DistanciaPercorrida, @@ -637,260 +729,874 @@ namespace AgroBase.Models.Operacoes NaMargemDoCorredor = t.NaMargemDoCorredor, PercentualOperacao = t.PercentualOperacao, PercentualRuaAtual = t.PercentualRuaAtual, - PontoAtualDistanciaAtual = t.PontoAtual.DistanciaAtual, - PontoAtualIdx = t.PontoAtual.idxPonto, - PontoAtualIdxCorredor = t.PontoAtual.idxCorredor, - ProximoPontoAproximando = t.ProximoPonto.Aproximando, - ProximoPontoDistanciaAtual = t.ProximoPonto.DistanciaAtual, - ProximoPontoIdx = t.ProximoPonto.idxPonto, + + PontoAtualDistanciaAtual = + pontoAtual?.DistanciaAtual ?? 0, + PontoAtualIdx = + pontoAtual?.idxPonto ?? 0, + PontoAtualIdxCorredor = + pontoAtual?.idxCorredor ?? 0, + + ProximoPontoAproximando = + proximoPonto?.Aproximando ?? false, + ProximoPontoDistanciaAtual = + proximoPonto?.DistanciaAtual ?? 0, + ProximoPontoIdx = + proximoPonto?.idxPonto ?? 0, + QtdPontos = t.QtdPontos, StatusCarro = t.StatusCarro, TempoEstimadoOperacao = t.TempoEstimadoOperacao - } : new OperacaoParametrosDadosTrajetoriaModel(); - var c = op.Controle?.Clone(); - Controle = c != null ? new OperacaoParametrosDadosControleModel() - { - Angulo = c.Angulo, - AlturaBarra = c.AlturaBarra, - PercentualVelocidadeSPKmh = c.PercentualVelocidadeSPKmh, - TipoMovimento = c.TipoMovimento, - SimulacaoMPC = c.SimulacaoMPC, - Motivos = c.Motivos - } : new OperacaoParametrosDadosControleModel(); - ModulosSaude = new List(sen?.ModulosSaude ?? new List()); - DispositivosMapeados = new List(sen?.DispositivosMapeados ?? new List()); - Imu = new OperacaoSensoriamentoLogImuModel() + }; + } + + private void AtualizarControle(dynamic op) + { + var c = op?.Controle?.Clone(); + + Controle = c != null + ? new OperacaoParametrosDadosControleModel + { + Angulo = c.Angulo, + AlturaBarra = c.AlturaBarra, + PercentualVelocidadeSPKmh = + c.PercentualVelocidadeSPKmh, + TipoMovimento = c.TipoMovimento, + SimulacaoMPC = CloneList(c.SimulacaoMPC), + Motivos = CloneListString(c.Motivos) + } + : new OperacaoParametrosDadosControleModel(); + } + + private void AtualizarSaude(dynamic sen) + { + ModulosSaude = CloneList( + sen?.ModulosSaude + ); + + DispositivosMapeados = CloneList( + sen?.DispositivosMapeados + ); + } + + private void AtualizarImu(dynamic sen) + { + Imu = new OperacaoSensoriamentoLogImuModel { Iniciado = sen?.IMU?.Iniciado ?? false, InclinacaoFrontal = sen?.IMU?.PitchSeguro ?? 0, InclinacaoLateral = sen?.IMU?.RollSeguro ?? 0, - Rotacao = sen?.IMU?.YawSeguro ?? 0, + Rotacao = sen?.IMU?.YawSeguro ?? 0 }; - Atuador = new OperacaoParametrosDadosAtuadorModel() + } + + private void AtualizarAtuador(dynamic sen, dynamic dAtu) + { + var atu = sen?.Atuador; + + var bicosPulverizadores = ToDynamicList( + dAtu == null ? null : dAtu.BicosPulverizadores + ); + + var bombasPressurizadoras = ToDynamicList( + dAtu == null ? null : dAtu.BombasPressurizadoras + ); + + var sensoresAtuador = ToDynamicList( + dAtu == null ? null : dAtu.Sensores + ); + + Atuador = new OperacaoParametrosDadosAtuadorModel { Iniciado = dAtu?.Conectado ?? false, Latencia = dAtu?.DadosLeitura?.LatenciaLoop ?? 0, - - AnguloBarraDireita = sen?.Atuador?.AnguloBarraDireita ?? 0, - AnguloBarraEsquerda = sen?.Atuador?.AnguloBarraEsquerda ?? 0, - CapacidadeReservatorio = sen?.Atuador?.CapacidadeReservatorio ?? 0, - ErvasNoRadar = sen?.Atuador?.ErvasNoRadar ?? false, - DistanciaEstimadaRestanteMetros = sen?.Atuador?.DistanciaEstimadaRestanteMetros ?? 0, - HerbicidaConsumidoMl = sen?.Atuador?.HerbicidaConsumido ?? 0, - HerbicidaPorAtuacaoMl = sen?.Atuador?.HerbicidaPorErva ?? 0, - LPorMetro = sen?.Atuador?.LPorMetro ?? 0, - LPorMinuto = sen?.Atuador?.LPorMinuto ?? 0, - MassaReservatorioKg = sen?.Atuador?.MassaReservatorio ?? 0, - VolumeReservatorioL = sen?.Atuador?.VolumeReservatorio ?? 0, - VolumeVazaoMl = sen?.Atuador?.VolumeVazaoMl ?? 0, - VazaoMediaMLs = sen?.Atuador?.VazaoMedia ?? 0, - VazaoInstantaneaMLs = sen?.Atuador?.VazaoInstantanea ?? 0, - TempoEstimadoRestanteMinutos = sen?.Atuador?.TempoEstimadoRestanteMinutos ?? 0, - QtdCamerasSolo = sen?.Atuador?.QtdCamerasSolo ?? 0, - PercentualErvasNoRadar = sen?.Atuador?.PercentualErvasNoRadar ?? 0, - PercentualErvasTerreno = sen?.Atuador?.PercentualErvasTerreno ?? 0, - PercentualReservatorio = sen?.Atuador?.PercentualReservatorio ?? 0, - PressaoLinhaPsi = sen?.Atuador?.PressaoLinha ?? 0, - Bicos = dAtu?.BicosPulverizadores?.Select(x => new OperacaoParametrosDadosBicoModel() - { - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - Posicao = x.Posicao, - AnguloAbertura = x.AnguloAbertura, - ComandoAngulo = (int)x.AnguloControle, - LeituraAngulo = (int)x._AnguloBicoLeitura, - ComandoEstado = x.ComandoAtuar, - LeituraEstado = x._EstadoLeitura == Estado.Ligado, - VazaoMediaMLs = x.VazaoMediaMLs, - VazaoInstantaneaMLs = x.VazaoInstantaneaMLs, - VolumeVazadoML = x.VolumeVazadoML, - QtdAtuacoes = x.Atuacoes, - TempoAtuado = x.TempoAtuado, - TrechosPulverizando = new List>(x.TrechosAtuado ?? new List>()), - UltimoComandoRespondido = x.UltimaLeitura - }).ToList(), - Bombas = dAtu?.BombasPressurizadoras?.Select(x => new OperacaoParametrosDadosBombaModel() - { - ID = x.ID, - ID_Num = x.ID_Num, - Sensor_ID_Num = x.Sensor_ID_Num, - Inicializado = x.Inicializado, - ComandoEstado = x.ComandoAtuar, - LeituraEstado = x._EstadoLeitura == Estado.Ligado, - ComandoPressao = x.PressaoSP, - LeituraPressao = x._PressaoAtual, - ComandoPotencia = x.PotenciaSP, - LeituraPotencia = x._Potencia, - TempoAtuado = dAtu?.TempoAtuado ?? 0, - UltimoComandoRespondido = x.UltimaLeitura, - }).ToList(), - Sensores = dAtu?.Sensores?.Select(x => new OperacaoParametrosDadosSensorModel() - { - Componente = x.Componente, - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - Valores = x.ValoresLeituras?.Where(y => y.funcao != FuncoesPinout.Iniciado)?.Select(y => y.atual?.valor).ToList(), - UltimoComandoRespondido = x.UltimaLeitura, - }).ToList(), - UltimoComandoRespondido = dAtu.DadosLeitura.UltimoComandoRespondido, + + AnguloBarraDireita = atu?.AnguloBarraDireita ?? 0, + AnguloBarraEsquerda = atu?.AnguloBarraEsquerda ?? 0, + CapacidadeReservatorio = + atu?.CapacidadeReservatorio ?? 0, + ErvasNoRadar = atu?.ErvasNoRadar ?? false, + DistanciaEstimadaRestanteMetros = + atu?.DistanciaEstimadaRestanteMetros ?? 0, + HerbicidaConsumidoMl = + atu?.HerbicidaConsumido ?? 0, + HerbicidaPorAtuacaoMl = + atu?.HerbicidaPorErva ?? 0, + LPorMetro = atu?.LPorMetro ?? 0, + LPorMinuto = atu?.LPorMinuto ?? 0, + MassaReservatorioKg = + atu?.MassaReservatorio ?? 0, + VolumeReservatorioL = + atu?.VolumeReservatorio ?? 0, + VolumeVazaoMl = atu?.VolumeVazaoMl ?? 0, + VazaoMediaMLs = atu?.VazaoMedia ?? 0, + VazaoInstantaneaMLs = + atu?.VazaoInstantanea ?? 0, + TempoEstimadoRestanteMinutos = + atu?.TempoEstimadoRestanteMinutos ?? 0, + QtdCamerasSolo = atu?.QtdCamerasSolo ?? 0, + PercentualErvasNoRadar = + atu?.PercentualErvasNoRadar ?? 0, + PercentualErvasTerreno = + atu?.PercentualErvasTerreno ?? 0, + PercentualReservatorio = + atu?.PercentualReservatorio ?? 0, + PressaoLinhaPsi = atu?.PressaoLinha ?? 0, + + Bicos = + dAtu?.BicosPulverizadores != null + ? ((IEnumerable)dAtu.BicosPulverizadores) + .Select(x => new OperacaoParametrosDadosBicoModel + { + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + Posicao = x.Posicao, + AnguloAbertura = x.AnguloAbertura, + ComandoAngulo = (int)x.AnguloControle, + LeituraAngulo = (int)x._AnguloBicoLeitura, + + /* + * Mantém a decisão correta que já fizemos: + * comando é mais confiável que leitura CAN de 1 s + * para métrica instantânea de atuação. + */ + ComandoEstado = x.ComandoAtuar, + LeituraEstado = x._EstadoLeitura == Estado.Ligado, + + VazaoMediaMLs = x.VazaoMediaMLs, + VazaoInstantaneaMLs = x.VazaoInstantaneaMLs, + VolumeVazadoML = x.VolumeVazadoML, + QtdAtuacoes = x.Atuacoes, + TempoAtuado = x.TempoAtuado, + TrechosPulverizando = + CloneTrechos(x.TrechosAtuado), + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + Bombas = + dAtu?.BombasPressurizadoras != null + ? ((IEnumerable)dAtu.BombasPressurizadoras) + .Select(x => new OperacaoParametrosDadosBombaModel + { + ID = x.ID, + ID_Num = x.ID_Num, + Sensor_ID_Num = x.Sensor_ID_Num, + Inicializado = x.Inicializado, + ComandoEstado = x.ComandoAtuar, + LeituraEstado = x._EstadoLeitura == Estado.Ligado, + ComandoPressao = x.PressaoSP, + LeituraPressao = x._PressaoAtual, + ComandoPotencia = x.PotenciaSP, + LeituraPotencia = x._Potencia, + TempoAtuado = dAtu?.TempoAtuado ?? 0, + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + Sensores = + dAtu?.Sensores != null + ? ((IEnumerable)dAtu.Sensores) + .Select(x => new OperacaoParametrosDadosSensorModel + { + Componente = x.Componente, + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + Valores = ExtrairValoresSensor(x.ValoresLeituras), + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + UltimoComandoRespondido = + dAtu?.DadosLeitura?.UltimoComandoRespondido + ?? DateTime.MinValue }; - Sensoriamento = new OperacaoParametrosDadosSensoriamentoModel() + } + + private void AtualizarSensoriamento(dynamic dSen) + { + Sensoriamento = new OperacaoParametrosDadosSensoriamentoModel { Iniciado = dSen?.Conectado ?? false, - Latencia = dSen?.DadosLeitura?.LatenciaLoop ?? 0, + Latencia = dSen?.DadosLeitura?.LatenciaLoop ?? 0, - Sensores = dSen?.Sensores?.Select(x => new OperacaoParametrosDadosSensorModel() - { - Componente = x.Componente, - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - Valores = x.ValoresLeituras?.Where(y => y.funcao != FuncoesPinout.Iniciado)?.Select(y => y.atual?.valor).ToList(), - UltimoComandoRespondido = x.UltimaLeitura, - }).ToList(), - Servos = dSen?.Servos?.Select(x => new OperacaoParametrosDadosServoModel() - { - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - Incremental = x.Incremental, - ComandoAngulo = x._AnguloControle, - LeituraAngulo = x._AnguloLeiutra, - UltimoComandoRespondido = x.UltimaLeitura, - }).ToList(), - Reles = dSen?.Reles?.Select(x => new OperacaoParametrosDadosReleModel() - { - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - ComandoEstado = x.StatusControle == Estado.Ligado, - LeituraEstado = x._EstadoLeitura == Estado.Ligado, - UltimoComandoRespondido = x.UltimaLeitura, - }).ToList(), - Sinaleiros = dSen?.Sinaleiros?.Select(x => new OperacaoParametrosDadosSinaleiroModel() - { - ID = x.ID, - ID_Num = x.ID_Num, - Inicializado = x.Inicializado, - Comportamento = x.Comportamento, - StatusLedId = x._StatusLedId, - StatusLed = x._StatusLed, - UltimoComandoRespondido = x.UltimaLeitura - }).ToList(), - UltimoComandoRespondido = dSen.DadosLeitura.UltimoComandoRespondido + Sensores = + dSen?.Sensores != null + ? ((IEnumerable)dSen.Sensores) + .Select(x => new OperacaoParametrosDadosSensorModel + { + Componente = x.Componente, + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + Valores = ExtrairValoresSensor(x.ValoresLeituras), + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + Servos = + dSen?.Servos != null + ? ((IEnumerable)dSen.Servos) + .Select(x => new OperacaoParametrosDadosServoModel + { + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + Incremental = x.Incremental, + ComandoAngulo = x._AnguloControle, + LeituraAngulo = x._AnguloLeiutra, + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + Reles = + dSen?.Reles != null + ? ((IEnumerable)dSen.Reles) + .Select(x => new OperacaoParametrosDadosReleModel + { + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + ComandoEstado = + x.StatusControle == Estado.Ligado, + LeituraEstado = + x._EstadoLeitura == Estado.Ligado, + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + Sinaleiros = + dSen?.Sinaleiros != null + ? ((IEnumerable)dSen.Sinaleiros) + .Select(x => new OperacaoParametrosDadosSinaleiroModel + { + ID = x.ID, + ID_Num = x.ID_Num, + Inicializado = x.Inicializado, + Comportamento = x.Comportamento, + StatusLedId = x._StatusLedId, + StatusLed = x._StatusLed, + UltimoComandoRespondido = x.UltimaLeitura + }) + .ToList() + : new List(), + + UltimoComandoRespondido = + dSen?.DadosLeitura?.UltimoComandoRespondido + ?? DateTime.MinValue }; - Movimentacao = new OperacaoParametrosDadosMovimentacaoModel() + } + + private void AtualizarMovimentacao(dynamic sen, dynamic dMvd) + { + var modules = ToDynamicList( + dMvd == null ? null : dMvd.Modulos + ); + + var validMov = + ((IEnumerable)modules) + .Where(x => x?.MovMotor != null) + .ToList(); + + Movimentacao = new OperacaoParametrosDadosMovimentacaoModel { - VelocidadeMediaMs = sen?.Movimentacao?.VelocidadeMedia ?? 0, - RPMMedio = sen?.Movimentacao?.RPMMedio ?? 0, - TemperaturaMedia = sen?.Movimentacao?.TemperaturaMedia ?? 0, - TempoMovimentoSegs = sen?.Movimentacao?.TempoMovimentoSegs ?? 0, - DistanciaPercorridaParcial = sen?.Movimentacao?.DistanciaPercorridaParcial ?? 0, - DistanciaPercorridaTotal = sen?.Movimentacao?.DistanciaPercorridaTotal ?? 0, - Modulos = dMvd?.Modulos?.Select(x => new OperacaoSensoriamentoLogMovDadosModel() - { - Mod_ID = x.MovMotor.Mod_ID, - ID = x.MovMotor._EnderecoCAN_Rx, - Iniciado = x.MovMotor.Inicializado, - Controlar = x.MovMotor.Comandar, - CicloTrabalho = x.MovMotor.CicloTrabalho, - Corrente_Barramento = x.MovMotor.Corrente_Barramento, - Corrente_Motor = x.MovMotor.Corrente_Motor, - Potencia = x.MovMotor.Potencia, - RPM_Motor = x.MovMotor.RPM_Motor, - RPM_Roda = x.MovMotor.RPM_Roda, - TemperaturaDriver = x.MovMotor.TemperaturaDriver, - Tensao = x.MovMotor.Tensao, - VelocidadeInstantaneaMs = x.MovMotor.VelocidadeInstantanea, - UltimoComandoRespondido = x.MovMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue, - }).ToList(), - UltimoComandoRespondido = dMvd.Modulos.Max(x => x.MovMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue) + VelocidadeMediaMs = + sen?.Movimentacao?.VelocidadeMedia ?? 0, + RPMMedio = + sen?.Movimentacao?.RPMMedio ?? 0, + TemperaturaMedia = + sen?.Movimentacao?.TemperaturaMedia ?? 0, + TempoMovimentoSegs = + sen?.Movimentacao?.TempoMovimentoSegs ?? 0, + DistanciaPercorridaParcial = + sen?.Movimentacao?.DistanciaPercorridaParcial ?? 0, + DistanciaPercorridaTotal = + sen?.Movimentacao?.DistanciaPercorridaTotal ?? 0, + + Modulos = + validMov + .Select(x => new OperacaoSensoriamentoLogMovDadosModel + { + Mod_ID = x.MovMotor.Mod_ID, + ID = x.MovMotor._EnderecoCAN_Rx, + Iniciado = x.MovMotor.Inicializado, + Controlar = x.MovMotor.Comandar, + CicloTrabalho = x.MovMotor.CicloTrabalho, + Corrente_Barramento = + x.MovMotor.Corrente_Barramento, + Corrente_Motor = + x.MovMotor.Corrente_Motor, + Potencia = x.MovMotor.Potencia, + RPM_Motor = x.MovMotor.RPM_Motor, + RPM_Roda = x.MovMotor.RPM_Roda, + TemperaturaDriver = + x.MovMotor.TemperaturaDriver, + Tensao = x.MovMotor.Tensao, + VelocidadeInstantaneaMs = + x.MovMotor.VelocidadeInstantanea, + UltimoComandoRespondido = + x.MovMotor.Leitura?.UltimoComandoRecebido + ?? DateTime.MinValue + }) + .ToList(), + + UltimoComandoRespondido = + validMov.Any() + ? validMov.Max(x => + x.MovMotor.Leitura?.UltimoComandoRecebido + ?? DateTime.MinValue) + : DateTime.MinValue }; - Direcional = new OperacaoParametrosDadosDirecionalModel() + } + + private void AtualizarDirecional(dynamic sen, dynamic dMvd) + { + var modules = ToDynamicList( + dMvd == null ? null : dMvd.Modulos + ); + + var validDir = + ((IEnumerable)modules) + .Where(x => x?.DirMotor != null) + .ToList(); + + Direcional = new OperacaoParametrosDadosDirecionalModel { - AnguloMedio = sen?.Direcional?.AnguloMedio ?? 0, - Modulos = dMvd.Modulos.Select(x => new DirSensoriamentoLogModel() - { - Mod_ID = x.DirMotor.Mod_ID, - ID = x.DirMotor._EnderecoCAN_Rx, - Iniciado = x.DirMotor.Inicializado, - Controlar = x.DirMotor.Comandar, - AnguloDriver = x.DirMotor.AnguloDriver, - AnguloFolgaCompensar = x.DirMotor.AnguloFolgaCompensar, - AnguloLeitura = x.DirMotor.AnguloLeitura, - AnguloReal = x.DirMotor.AnguloReal, - Angulo_SP = x.DirMotor.Angulo_SP, - FolgaMecanica = x.DirMotor.FolgaMecanica, - Sentido = x.DirMotor.Sentido, - SentidoReal = x.DirMotor.SentidoReal, - Sentido_SP = x.DirMotor.Sentido_SP, - Velocidade = x.DirMotor.Velocidade, - UltimoComandoRespondido = x.DirMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue, - }).ToList(), - UltimoComandoRespondido = dMvd.Modulos.Max(x => x.DirMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue) + AnguloMedio = + sen?.Direcional?.AnguloMedio ?? 0, + + Modulos = + validDir + .Select(x => new DirSensoriamentoLogModel + { + Mod_ID = x.DirMotor.Mod_ID, + ID = x.DirMotor._EnderecoCAN_Rx, + Iniciado = x.DirMotor.Inicializado, + Controlar = x.DirMotor.Comandar, + AnguloDriver = x.DirMotor.AnguloDriver, + AnguloFolgaCompensar = + x.DirMotor.AnguloFolgaCompensar, + AnguloLeitura = x.DirMotor.AnguloLeitura, + AnguloReal = x.DirMotor.AnguloReal, + Angulo_SP = x.DirMotor.Angulo_SP, + FolgaMecanica = x.DirMotor.FolgaMecanica, + Sentido = x.DirMotor.Sentido, + SentidoReal = x.DirMotor.SentidoReal, + Sentido_SP = x.DirMotor.Sentido_SP, + Velocidade = x.DirMotor.Velocidade, + UltimoComandoRespondido = + x.DirMotor.Leitura?.UltimoComandoRecebido + ?? DateTime.MinValue + }) + .ToList(), + + UltimoComandoRespondido = + validDir.Any() + ? validDir.Max(x => + x.DirMotor.Leitura?.UltimoComandoRecebido + ?? DateTime.MinValue) + : DateTime.MinValue }; + } + + private void AtualizarGnss(dynamic sen) + { + var gps = sen?.Gps; + + Gnss = new OperacaoParametrosDadosGNSSModel + { + Iniciado = gps?.Inicializado ?? false, + Altitude = gps?.Altitude ?? 0, + Distancia = gps?.Distancia ?? 0, + IdadeCorrecao = gps?.IdadeCorrecao ?? -1, + Latitude = gps?.Latitude ?? 0, + Longitude = gps?.Longitude ?? 0, + LeverArmFrontal = gps?.LeverArmFrontal ?? 0, + LeverArmLateral = gps?.LeverArmLateral ?? 0, + NtripAtivado = gps?.Ntrip_ativado ?? false, + NumeroSatelites = gps?.NumeroSatelites ?? 0, + OrientacaoMovimento = gps?.OrientacaoMovimento ?? 0, + OrientacaoReal = gps?.OrientacaoReal ?? 0, + PrecisaoCm = gps?.PrecisaoCm ?? 0, + PrecisaoHorizontal = gps?.PrecisaoHorizontal ?? 0, + QualidadeFix = + gps?.QualidadeFix ?? TiposCorrecaoGPS.SemCorrecao, + Velocidade = gps?.Velocidade ?? 0, + UltimoComandoRespondido = + gps?.UltimoComandoRespondido ?? DateTime.MinValue + }; + } + + private void AtualizarOutrosBlocos(dynamic sen) + { Bateria = sen?.Bateria?.Clone(); Refrigeracao = sen?.CoolerControl?.Clone(); - Gnss = new OperacaoParametrosDadosGNSSModel() - { - Iniciado = sen?.Gps?.Inicializado ?? false, - Altitude = sen?.Gps?.Altitude ?? 0, - Distancia = sen?.Gps?.Distancia ?? 0, - IdadeCorrecao = sen?.Gps?.IdadeCorrecao ?? 0, - Latitude = sen?.Gps?.Latitude ?? 0, - Longitude = sen?.Gps?.Longitude ?? 0, - LeverArmFrontal = sen?.Gps?.LeverArmFrontal ?? 0, - LeverArmLateral = sen?.Gps?.LeverArmLateral ?? 0, - NtripAtivado = sen?.Gps?.Ntrip_ativado ?? false, - NumeroSatelites = sen?.Gps?.NumeroSatelites ?? 0, - OrientacaoMovimento = sen?.Gps?.OrientacaoMovimento ?? 0, - OrientacaoReal = sen?.Gps?.OrientacaoReal ?? 0, - PrecisaoCm = sen?.Gps?.PrecisaoCm ?? 0, - PrecisaoHorizontal = sen?.Gps?.PrecisaoHorizontal ?? 0, - QualidadeFix = sen?.Gps?.QualidadeFix ?? TiposCorrecaoGPS.SemCorrecao, - Velocidade = sen?.Gps?.Velocidade ?? 0, - UltimoComandoRespondido = sen?.Gps?.UltimoComandoRespondido ?? DateTime.MinValue, - }; PerformanceNcp = sen?.DadosPerformance?.Clone(); LivoxLidar = sen?.LivoxLidar?.Clone(); - CameraWorkerService.GetListaCameras(); - Cameras = CameraWorkerService.ListaCameras; - OperadorVisual = VisualWorkerService.DadosLeitura?.Resumo?.Clone(); - var l = sen?.Logs; - Logs = l != null ? l.Where(x => x.Momento >= UltimoEnvioLog).ToList() : new List(); + OperadorVisual = VisualWorkerService + .DadosLeitura? + .Resumo? + .Clone(); } - + + private void AtualizarCameras() + { + CameraWorkerService.GetListaCameras(); + + Cameras = CameraWorkerService.ListaCameras != null + ? new List( + CameraWorkerService.ListaCameras + ) + : new List(); + } + + private void AtualizarLogs(dynamic sen) + { + DateTime corte = UltimoEnvioLog; + + var logs = sen?.Logs; + + Logs = logs != null + ? ((IEnumerable)logs) + .Where(x => x != null && x.Momento > corte) + .ToList() + : new List(); + } + public OperacaoParametrosDadosModel Clone() { - return new OperacaoParametrosDadosModel() + return new OperacaoParametrosDadosModel { Momento = Momento, StatusRover = StatusRover, TipoCanAtivo = TipoCanAtivo, - Operacao = Operacao, + + Operacao = CloneOperacao(Operacao), Trajetoria = Trajetoria?.Clone(), Controle = Controle?.Clone(), - ModulosSaude = new List(ModulosSaude ?? new List()), - DispositivosMapeados = new List(DispositivosMapeados ?? new List()), + + ModulosSaude = CloneList(ModulosSaude), + DispositivosMapeados = CloneList(DispositivosMapeados), + Imu = Imu?.Clone(), - Atuador = Atuador, - Sensoriamento = Sensoriamento, - Movimentacao = Movimentacao, - Direcional = Direcional, + Atuador = CloneAtuador(Atuador), + Sensoriamento = CloneSensoriamento(Sensoriamento), + Movimentacao = CloneMovimentacao(Movimentacao), + Direcional = CloneDirecional(Direcional), + Bateria = Bateria?.Clone(), Refrigeracao = Refrigeracao?.Clone(), - Gnss = Gnss, + Gnss = CloneGnss(Gnss), PerformanceNcp = PerformanceNcp?.Clone(), LivoxLidar = LivoxLidar?.Clone(), - Logs = new List(Logs ?? new List()), - Cameras = new List(Cameras ?? new List()), + + Logs = CloneList(Logs), + Cameras = Cameras != null + ? new List(Cameras) + : new List(), + OperadorVisual = OperadorVisual?.Clone(), UltimoEnvioLog = UltimoEnvioLog }; } + private static OperacaoParametrosDadosOperacaoModel CloneOperacao( + OperacaoParametrosDadosOperacaoModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosOperacaoModel + { + Modo = src.Modo, + Status = src.Status, + ImpedimentoErro = src.ImpedimentoErro, + Liberada = src.Liberada, + Iniciada = src.Iniciada, + Emergencia = src.Emergencia, + Pausa = src.Pausa, + IniciadoEm = src.IniciadoEm, + FinalizadoEm = src.FinalizadoEm, + TempoAguardandoSegs = src.TempoAguardandoSegs, + TempoDecorridoSegs = src.TempoDecorridoSegs, + ModulosMandatorios = CloneList(src.ModulosMandatorios), + ParametrosMandatorios = CloneList(src.ParametrosMandatorios) + }; + } + + private static OperacaoParametrosDadosAtuadorModel CloneAtuador( + OperacaoParametrosDadosAtuadorModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosAtuadorModel + { + Iniciado = src.Iniciado, + Latencia = src.Latencia, + CapacidadeReservatorio = src.CapacidadeReservatorio, + QtdCamerasSolo = src.QtdCamerasSolo, + ErvasNoRadar = src.ErvasNoRadar, + PercentualErvasNoRadar = src.PercentualErvasNoRadar, + PercentualErvasTerreno = src.PercentualErvasTerreno, + HerbicidaConsumidoMl = src.HerbicidaConsumidoMl, + HerbicidaPorAtuacaoMl = src.HerbicidaPorAtuacaoMl, + MassaReservatorioKg = src.MassaReservatorioKg, + VolumeReservatorioL = src.VolumeReservatorioL, + PercentualReservatorio = src.PercentualReservatorio, + PressaoLinhaPsi = src.PressaoLinhaPsi, + VazaoInstantaneaMLs = src.VazaoInstantaneaMLs, + VazaoMediaMLs = src.VazaoMediaMLs, + VolumeVazaoMl = src.VolumeVazaoMl, + LPorMetro = src.LPorMetro, + LPorMinuto = src.LPorMinuto, + TempoEstimadoRestanteMinutos = + src.TempoEstimadoRestanteMinutos, + DistanciaEstimadaRestanteMetros = + src.DistanciaEstimadaRestanteMetros, + AnguloBarraEsquerda = src.AnguloBarraEsquerda, + AnguloBarraDireita = src.AnguloBarraDireita, + Bicos = src.Bicos?.Select(CloneBico).ToList() + ?? new List(), + Bombas = src.Bombas?.Select(CloneBomba).ToList() + ?? new List(), + Sensores = src.Sensores?.Select(CloneSensor).ToList() + ?? new List(), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosBicoModel CloneBico( + OperacaoParametrosDadosBicoModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosBicoModel + { + ID = src.ID, + ID_Num = src.ID_Num, + Inicializado = src.Inicializado, + Posicao = src.Posicao, + ComandoEstado = src.ComandoEstado, + LeituraEstado = src.LeituraEstado, + ComandoAngulo = src.ComandoAngulo, + LeituraAngulo = src.LeituraAngulo, + QtdAtuacoes = src.QtdAtuacoes, + TempoAtuado = src.TempoAtuado, + VazaoMediaMLs = src.VazaoMediaMLs, + VazaoInstantaneaMLs = src.VazaoInstantaneaMLs, + VolumeVazadoML = src.VolumeVazadoML, + AnguloAbertura = src.AnguloAbertura, + TrechosPulverizando = CloneTrechos(src.TrechosPulverizando), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosBombaModel CloneBomba( + OperacaoParametrosDadosBombaModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosBombaModel + { + ID = src.ID, + ID_Num = src.ID_Num, + Sensor_ID_Num = src.Sensor_ID_Num, + Inicializado = src.Inicializado, + ComandoEstado = src.ComandoEstado, + LeituraEstado = src.LeituraEstado, + ComandoPressao = src.ComandoPressao, + LeituraPressao = src.LeituraPressao, + TempoAtuado = src.TempoAtuado, + ComandoPotencia = src.ComandoPotencia, + LeituraPotencia = src.LeituraPotencia, + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosSensoriamentoModel + CloneSensoriamento(OperacaoParametrosDadosSensoriamentoModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosSensoriamentoModel + { + Iniciado = src.Iniciado, + Latencia = src.Latencia, + Sinaleiros = src.Sinaleiros?.Select(CloneSinaleiro).ToList() + ?? new List(), + Sensores = src.Sensores?.Select(CloneSensor).ToList() + ?? new List(), + Servos = src.Servos?.Select(CloneServo).ToList() + ?? new List(), + Reles = src.Reles?.Select(CloneRele).ToList() + ?? new List(), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosSensorModel CloneSensor( + OperacaoParametrosDadosSensorModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosSensorModel + { + Componente = src.Componente, + ID = src.ID, + ID_Num = src.ID_Num, + Inicializado = src.Inicializado, + Valores = src.Valores != null + ? new List(src.Valores) + : new List(), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosServoModel CloneServo( + OperacaoParametrosDadosServoModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosServoModel + { + ID = src.ID, + ID_Num = src.ID_Num, + Inicializado = src.Inicializado, + Incremental = src.Incremental, + ComandoAngulo = src.ComandoAngulo, + LeituraAngulo = src.LeituraAngulo, + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosReleModel CloneRele( + OperacaoParametrosDadosReleModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosReleModel + { + ID = src.ID, + ID_Num = src.ID_Num, + Inicializado = src.Inicializado, + ComandoEstado = src.ComandoEstado, + LeituraEstado = src.LeituraEstado, + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosSinaleiroModel CloneSinaleiro( + OperacaoParametrosDadosSinaleiroModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosSinaleiroModel + { + ID = src.ID, + ID_Num = src.ID_Num, + Inicializado = src.Inicializado, + Comportamento = src.Comportamento, + StatusLedId = src.StatusLedId, + StatusLed = src.StatusLed, + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosMovimentacaoModel + CloneMovimentacao(OperacaoParametrosDadosMovimentacaoModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosMovimentacaoModel + { + VelocidadeMediaMs = src.VelocidadeMediaMs, + RPMMedio = src.RPMMedio, + TempoMovimentoSegs = src.TempoMovimentoSegs, + DistanciaPercorridaTotal = src.DistanciaPercorridaTotal, + DistanciaPercorridaParcial = src.DistanciaPercorridaParcial, + TemperaturaMedia = src.TemperaturaMedia, + Modulos = CloneList(src.Modulos), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosDirecionalModel CloneDirecional( + OperacaoParametrosDadosDirecionalModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosDirecionalModel + { + AnguloMedio = src.AnguloMedio, + Modulos = CloneList(src.Modulos), + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static OperacaoParametrosDadosGNSSModel CloneGnss( + OperacaoParametrosDadosGNSSModel src) + { + if (src == null) + return null; + + return new OperacaoParametrosDadosGNSSModel + { + Iniciado = src.Iniciado, + Latitude = src.Latitude, + Longitude = src.Longitude, + Altitude = src.Altitude, + Velocidade = src.Velocidade, + Distancia = src.Distancia, + OrientacaoMovimento = src.OrientacaoMovimento, + OrientacaoReal = src.OrientacaoReal, + NumeroSatelites = src.NumeroSatelites, + PrecisaoHorizontal = src.PrecisaoHorizontal, + PrecisaoCm = src.PrecisaoCm, + QualidadeFix = src.QualidadeFix, + NtripAtivado = src.NtripAtivado, + IdadeCorrecao = src.IdadeCorrecao, + LeverArmLateral = src.LeverArmLateral, + LeverArmFrontal = src.LeverArmFrontal, + UltimoComandoRespondido = src.UltimoComandoRespondido + }; + } + + private static List CloneList(IEnumerable src) + { + return src != null + ? new List(src) + : new List(); + } + + private static List CloneListString(IEnumerable src) + { + return src != null + ? src.Where(x => x != null).ToList() + : new List(); + } + + private static List ToDynamicList(object src) + { + var result = new List(); + + if (src == null) + return result; + + var enumerable = src as System.Collections.IEnumerable; + + if (enumerable == null) + return result; + + foreach (var item in enumerable) + result.Add(item); + + return result; + } + + private static List ExtrairValoresSensor(dynamic valoresLeituras) + { + var result = new List(); + + if (valoresLeituras == null) + return result; + + foreach (var item in valoresLeituras) + { + try + { + if (item.funcao == FuncoesPinout.Iniciado) + continue; + + result.Add(item.atual?.valor); + } + catch { } + } + + return result; + } + + private static List> CloneTrechos(dynamic trechos) + { + var result = new List>(); + + if (trechos == null) + return result; + + foreach (var trecho in trechos) + { + if (trecho == null) + { + result.Add(new List()); + continue; + } + + var novoTrecho = new List(); + + foreach (var ponto in trecho) + { + /* + * Sem assumir que PontoGeo tem Clone(). + * Copia a lista interna para evitar mutação estrutural. + * Se PontoGeo for mutável, o próximo passo é adicionar + * Clone() nele também. + */ + novoTrecho.Add(ponto); + } + + result.Add(novoTrecho); + } + + return result; + } + + private static double TryGetDouble( + object obj, + string property, + double fallback) + { + if (obj == null || + string.IsNullOrWhiteSpace(property)) + { + return fallback; + } + + try + { + var prop = obj.GetType().GetProperty(property); + + if (prop == null) + return fallback; + + object value = prop.GetValue(obj, null); + + if (value == null) + return fallback; + + return Convert.ToDouble(value); + } + catch + { + return fallback; + } + } } public class OperacaoParametrosDadosOperacaoModel @@ -937,7 +1643,7 @@ namespace AgroBase.Models.Operacoes public OperacaoParametrosDadosTrajetoriaModel Clone() { - return new OperacaoParametrosDadosTrajetoriaModel() + return new OperacaoParametrosDadosTrajetoriaModel { TempoEstimadoOperacao = TempoEstimadoOperacao, StatusCarro = StatusCarro, @@ -945,10 +1651,33 @@ namespace AgroBase.Models.Operacoes ProximoPontoIdx = ProximoPontoIdx, ProximoPontoDistanciaAtual = ProximoPontoDistanciaAtual, AnguloCaminho = AnguloCaminho, - AutonomiaCorredor = AutonomiaCorredor, + AutonomiaCorredor = + AutonomiaCorredor == null + ? null + : new OperacaoParametrosDadosTrajetoriaAutonomiaCorredorModel + { + Status = AutonomiaCorredor.Status, + DistanciaCorredor_m = + AutonomiaCorredor.DistanciaCorredor_m, + DistanciaSeguraBateria_m = + AutonomiaCorredor.DistanciaSeguraBateria_m, + DistanciaSeguraHerbicida_m = + AutonomiaCorredor.DistanciaSeguraHerbicida_m, + BateriaSuficiente = + AutonomiaCorredor.BateriaSuficiente, + HerbicidaSuficiente = + AutonomiaCorredor.HerbicidaSuficiente, + Liberado = AutonomiaCorredor.Liberado, + Motivo = AutonomiaCorredor.Motivo, + Motivos = AutonomiaCorredor.Motivos != null + ? new List(AutonomiaCorredor.Motivos) + : new List() + }, CorredorAtualDentro = CorredorAtualDentro, - CorredorAtualDistanciaPercorrida = CorredorAtualDistanciaPercorrida, - CorredorAtualDistanciaTotal = CorredorAtualDistanciaTotal, + CorredorAtualDistanciaPercorrida = + CorredorAtualDistanciaPercorrida, + CorredorAtualDistanciaTotal = + CorredorAtualDistanciaTotal, CorredorAtualIdx = CorredorAtualIdx, DistanciaDireita = DistanciaDireita, DistanciaEsquerda = DistanciaEsquerda, @@ -960,7 +1689,7 @@ namespace AgroBase.Models.Operacoes PontoAtualDistanciaAtual = PontoAtualDistanciaAtual, PontoAtualIdx = PontoAtualIdx, PontoAtualIdxCorredor = PontoAtualIdxCorredor, - ProximoPontoAproximando = ProximoPontoAproximando, + ProximoPontoAproximando = ProximoPontoAproximando }; } } @@ -990,14 +1719,18 @@ namespace AgroBase.Models.Operacoes public OperacaoParametrosDadosControleModel Clone() { - return new OperacaoParametrosDadosControleModel() + return new OperacaoParametrosDadosControleModel { AlturaBarra = AlturaBarra, Angulo = Angulo, PercentualVelocidadeSPKmh = PercentualVelocidadeSPKmh, TipoMovimento = TipoMovimento, - SimulacaoMPC = SimulacaoMPC, - Motivos = new List(Motivos ?? new List()) + SimulacaoMPC = SimulacaoMPC != null + ? new List(SimulacaoMPC) + : new List(), + Motivos = Motivos != null + ? new List(Motivos) + : new List() }; } } diff --git a/AgroBase/AgroBase/Models/Variaveis.cs b/AgroBase/AgroBase/Models/Variaveis.cs index 5275a6d7f..0b46294bf 100644 --- a/AgroBase/AgroBase/Models/Variaveis.cs +++ b/AgroBase/AgroBase/Models/Variaveis.cs @@ -1,4 +1,4 @@ -using AgroBase.Models.Modules; +using AgroBase.Models.Modules; using AgroBase.Services; using System; using System.Collections.Generic; @@ -58,8 +58,39 @@ namespace AgroBase.Models public static string CaminhoModelos { get; } = "C:\\AgroBaseModels\\"; public static List DispositivosConectados { get; set; } = new List(); public static OperacaoModel OperacaoEmAndamento { get; set; } = new OperacaoModel(); - public static MqttService MqttServiceLocal { get; set; } - public static MqttService MqttServiceBase { get; set; } + public static MqttService MqttServiceLocal { get; private set; } + public static MqttService MqttServiceBaseCritical { get; private set; } + public static MqttService MqttServiceBaseTelemetry { get; private set; } + + /// + /// Compatibilidade temporária com o restante do projeto. + /// Publicações antigas feitas por MqttServiceBase seguem pelo canal + /// de telemetria, nunca pelo canal crítico de RTCM/comandos. + /// + public static MqttService MqttServiceBase + { + get { return MqttServiceBaseTelemetry; } + set { MqttServiceBaseTelemetry = value; } + } + + public static BaseLinkState BaseLink { get; } = new BaseLinkState(); + + public static MqttService.MqttTopicosModel TopicoLocalCoordenadasGps { get; private set; } + public static MqttService.MqttTopicosModel TopicoLocalTrajetoriaDinamica { get; private set; } + public static MqttService.MqttTopicosModel TopicoLocalSelecaoRuas { get; private set; } + + public static MqttService.MqttTopicosModel TopicoBaseDiscovery { get; private set; } + public static MqttService.MqttTopicosModel TopicoBaseTelemetria { get; private set; } + public static MqttService.MqttTopicosModel TopicoBaseParametros { get; private set; } + + public static MqttService.MqttTopicosModel TopicoBaseHeartbeat { get; private set; } + public static MqttService.MqttTopicosModel TopicoBaseComandos { get; private set; } + public static MqttService.MqttTopicosModel TopicoBaseRtcm { get; private set; } + public static MqttService.MqttTopicosModel TopicoBasePosicao { get; private set; } + + private static readonly SemaphoreSlim _mqttLifecycleLock = new SemaphoreSlim(1, 1); + private static AsyncTaskTimerModel _tmrMqttLinkMonitor; + private static int _mqttCriticalConnectedState = -1; public static LoRaEspService LoraService { get @@ -91,90 +122,450 @@ namespace AgroBase.Models public static byte ID_Num_sTOD { get; } = 250; public static byte ID_Num_sLRA { get; } = 251; - public static async void IniciarMQTT() + /// + /// Nome antigo preservado para compatibilidade. Novos pontos de startup + /// devem aguardar IniciarMqttAsync diretamente. + /// + public static Task IniciarMQTT() { - if (MqttServiceLocal != null) - { - foreach (var topico in MqttServiceLocal.Topicos.Where(x => x.Inscrever)) - { - await MqttServiceLocal.UnsubscribeAsync(topico); - } - MqttServiceLocal.Topicos.Clear(); - } + return IniciarMqttAsync(); + } - if (MqttServiceBase != null) - { - foreach (var topico in MqttServiceBase.Topicos.Where(x => x.Inscrever)) - { - await MqttServiceBase.UnsubscribeAsync(topico); - } - MqttServiceBase.Topicos.Clear(); - } + /// + /// Inicializa deterministicamente os clientes MQTT e seus tópicos. + /// Uma segunda chamada encerra por completo a geração anterior antes + /// de criar novos clientes. + /// + public static async Task IniciarMqttAsync( + CancellationToken cancellationToken = default(CancellationToken)) + { + await _mqttLifecycleLock.WaitAsync(cancellationToken).ConfigureAwait(false); - MqttServiceLocal = new MqttService("localhost", 1883, VariaveisEquipamento.Parametros.serial_number, true, msg => Console.WriteLine($"[MQTT localhost:{1883}] - {msg}")); - await MqttServiceLocal.AdicionarNovoTopico(MapasVariaveisModel.TopicoCoordenadasGPS); - await MqttServiceLocal.AdicionarNovoTopico(MapasVariaveisModel.TopicoTrajetoriaDinamica); - await MqttServiceLocal.AdicionarNovoTopico(MapasVariaveisModel.TopicoSelecaoRuasMapa, true, 1, async (message) => + try { - OperacaoEmAndamento.Mapa.AtualizarRuasSelecionadas(); - }); - - MqttServiceBase = new MqttService(VariaveisEquipamento.Parametros.base_ip, 1883, VariaveisEquipamento.Parametros.serial_number, false, msg => Console.WriteLine($"[MQTT {VariaveisEquipamento.Parametros.base_ip}:{1883}] - {msg}")); - await MqttServiceBase.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttDispositivos); - await MqttServiceBase.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttTelemetria.Replace("", VariaveisEquipamento.Parametros.serial_number)); - await MqttServiceBase.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttParametros.Replace("", VariaveisEquipamento.Parametros.serial_number)); - await MqttServiceBase.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttHeartbeat.Replace("", VariaveisEquipamento.Parametros.serial_number), true, 1, async (message) => - { - if (string.IsNullOrEmpty(message.Mensagem)) return; - VariaveisOperacao.PosicaoBase.UltimoComandoRespondido = DateTime.Now; - }); - await MqttServiceBase.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttComandos.Replace("", VariaveisEquipamento.Parametros.serial_number), true, 1, async (message) => - { - if (string.IsNullOrEmpty(message.Mensagem)) return; - try - { - string json = message.Mensagem; - var cmd = JsonConvert.DeserializeObject(json); - OperacaoEmAndamento.ExecutaComandoDaBase(cmd); - } - catch (Exception ex) - { - Console.WriteLine($"Erro ao deserializar comando da base: {ex.Message}"); - } - }); - await MqttServiceBase.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttRTCM, true, 1, async (message) => - { - if (message.Bytes == null || message.Bytes.Length == 0) + if (Fechando) return; - try + + string roverId = VariaveisEquipamento.Parametros.serial_number; + string baseIp = VariaveisEquipamento.Parametros.base_ip; + + if (string.IsNullOrWhiteSpace(roverId)) + throw new InvalidOperationException("O serial_number do rover não foi configurado."); + + if (string.IsNullOrWhiteSpace(baseIp)) + throw new InvalidOperationException("O base_ip do rover não foi configurado."); + + await EncerrarMqttInternoAsync("Reinicialização MQTT").ConfigureAwait(false); + + BaseLink.BeginNewSession(roverId); + Interlocked.Exchange(ref _mqttCriticalConnectedState, -1); + + MqttServiceLocal = new MqttService( + "localhost", + 1883, + roverId, + true, + msg => Console.WriteLine($"[MQTT LOCAL localhost:1883] - {msg}")); + + MqttServiceBaseCritical = new MqttService( + baseIp, + 1883, + roverId + "-critical", + false, + msg => Console.WriteLine($"[MQTT CRITICAL {baseIp}:1883] - {msg}")); + + MqttServiceBaseTelemetry = new MqttService( + baseIp, + 1883, + roverId + "-telemetry", + false, + msg => Console.WriteLine($"[MQTT TELEMETRY {baseIp}:1883] - {msg}")); + + await ConfigurarTopicosMqttLocalAsync().ConfigureAwait(false); + await ConfigurarTopicosMqttCriticosAsync(roverId).ConfigureAwait(false); + await ConfigurarTopicosMqttTelemetriaAsync(roverId).ConfigureAwait(false); + + _tmrMqttLinkMonitor = new AsyncTaskTimerModel( + "tmrMqttLinkMonitor", + MonitorarLinkMqttAsync, + interval: 500, + timeout: 2000, + scheduleMode: AsyncTaskTimerScheduleMode.FixedRateSkipMissed, + runImmediately: true); + + _tmrMqttLinkMonitor.DebugMessages = false; + _tmrMqttLinkMonitor.Start(); + + await Task.WhenAll( + MqttServiceLocal.StartAsync(), + MqttServiceBaseCritical.StartAsync(), + MqttServiceBaseTelemetry.StartAsync() + ).ConfigureAwait(false); + + AtualizarEstadoBrokerCritico(); + } + catch (Exception ex) + { + BaseLink.RecordError("Falha ao inicializar MQTT: " + ex.Message); + Console.WriteLine("[MQTT] Falha durante inicialização: " + ex); + + await EncerrarMqttInternoAsync( + "Falha durante inicialização MQTT" + ).ConfigureAwait(false); + + throw; + } + finally + { + _mqttLifecycleLock.Release(); + } + } + + public static async Task EncerrarMqttAsync() + { + await _mqttLifecycleLock.WaitAsync().ConfigureAwait(false); + + try + { + await EncerrarMqttInternoAsync("Encerramento solicitado").ConfigureAwait(false); + } + finally + { + _mqttLifecycleLock.Release(); + } + } + + private static async Task ConfigurarTopicosMqttLocalAsync() + { + TopicoLocalCoordenadasGps = await MqttServiceLocal.AdicionarNovoTopico( + MapasVariaveisModel.TopicoCoordenadasGPS, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoLocalTrajetoriaDinamica = await MqttServiceLocal.AdicionarNovoTopico( + MapasVariaveisModel.TopicoTrajetoriaDinamica, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoLocalSelecaoRuas = await MqttServiceLocal.AdicionarNovoTopico( + MapasVariaveisModel.TopicoSelecaoRuasMapa, + inscrever: true, + mensagensManter: 1, + callback: message => { - var bytes = message.Bytes; - if (bytes != null && bytes.Length > 0) + OperacaoEmAndamento?.Mapa?.AtualizarRuasSelecionadas(); + return Task.CompletedTask; + }, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtLeastOnce, + retain: false + ).ConfigureAwait(false); + } + + private static async Task ConfigurarTopicosMqttCriticosAsync(string roverId) + { + TopicoBaseHeartbeat = await MqttServiceBaseCritical.AdicionarNovoTopico( + VariaveisEquipamento.TopicoMqttHeartbeat.Replace("", roverId), + inscrever: true, + mensagensManter: 1, + callback: message => + { + MarcarBrokerCriticoConectado(); + BaseLink.MarkHeartbeat(); + + // Compatibilidade temporária com tmrComunicacao_Tick. + // Será removido quando discovery/telemetria migrarem para + // serviços próprios baseados em BaseLinkState. + VariaveisOperacao.MarcarHeartbeatBaseLegado(DateTime.Now); + return Task.CompletedTask; + }, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.Inline, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoBaseComandos = await MqttServiceBaseCritical.AdicionarNovoTopico( + VariaveisEquipamento.TopicoMqttComandos.Replace("", roverId), + inscrever: true, + mensagensManter: 2, + callback: message => + { + if (string.IsNullOrWhiteSpace(message.Mensagem)) + return Task.CompletedTask; + + try { + OperacaoComandoBaseModel cmd = + JsonConvert.DeserializeObject(message.Mensagem); + + if (cmd == null) + return Task.CompletedTask; + + MarcarBrokerCriticoConectado(); + BaseLink.MarkCommand(); + OperacaoEmAndamento?.ExecutaComandoDaBase(cmd); + } + catch (Exception ex) + { + BaseLink.RecordError("Erro no comando MQTT: " + ex.Message); + Console.WriteLine("Erro ao deserializar comando da base: " + ex.Message); + } + + return Task.CompletedTask; + }, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.Sequential, + queueCapacity: 64, + overflowPolicy: MqttQueueOverflowPolicy.DropNewest, + qos: MqttQosLevel.AtLeastOnce, + retain: false + ).ConfigureAwait(false); + + TopicoBaseRtcm = await MqttServiceBaseCritical.AdicionarNovoTopico( + VariaveisMonitoramento.TopicoMqttRTCM, + inscrever: true, + mensagensManter: 1, + callback: message => + { + byte[] bytes = message.Bytes; + + if (bytes == null || bytes.Length == 0) + return Task.CompletedTask; + + try + { + MarcarBrokerCriticoConectado(); + BaseLink.MarkRtcm(); GPSService.AplicarCorrecaoRTK_Mqtt(bytes, bytes.Length); } - } - catch (Exception ex) + catch (Exception ex) + { + BaseLink.RecordError("Erro ao aplicar RTCM MQTT: " + ex.Message); + Console.WriteLine("Erro ao aplicar RTCM da base: " + ex.Message); + } + + return Task.CompletedTask; + }, + payloadType: MqttPayloadType.Binary, + dispatchMode: MqttDispatchMode.Sequential, + queueCapacity: 32, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoBasePosicao = await MqttServiceBaseCritical.AdicionarNovoTopico( + VariaveisMonitoramento.TopicoMqttPosicao, + inscrever: true, + mensagensManter: 1, + callback: message => { - Console.WriteLine($"Erro ao deserializar RTCM da base: {ex.Message}"); - } - }); - await MqttServiceBase.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttPosicao, true, 1, async (message) => + if (string.IsNullOrWhiteSpace(message.Mensagem)) + return Task.CompletedTask; + + try + { + GPSModel posicao = JsonConvert.DeserializeObject(message.Mensagem); + + if (posicao == null) + return Task.CompletedTask; + + posicao.Momento = DateTime.Now; + VariaveisOperacao.AtualizarPosicaoBase(posicao); + + MarcarBrokerCriticoConectado(); + BaseLink.MarkPosition(); + } + catch (Exception ex) + { + BaseLink.RecordError("Erro na posição MQTT da base: " + ex.Message); + Console.WriteLine("Erro ao deserializar dados da base: " + ex.Message); + } + + return Task.CompletedTask; + }, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + } + + private static async Task ConfigurarTopicosMqttTelemetriaAsync(string roverId) + { + TopicoBaseDiscovery = await MqttServiceBaseTelemetry.AdicionarNovoTopico( + VariaveisMonitoramento.TopicoMqttDispositivos, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoBaseTelemetria = await MqttServiceBaseTelemetry.AdicionarNovoTopico( + VariaveisEquipamento.TopicoMqttTelemetria.Replace("", roverId), + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoBaseParametros = await MqttServiceBaseTelemetry.AdicionarNovoTopico( + VariaveisEquipamento.TopicoMqttParametros.Replace("", roverId), + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtLeastOnce, + retain: false + ).ConfigureAwait(false); + } + + private static Task MonitorarLinkMqttAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + AtualizarEstadoBrokerCritico(); + return Task.CompletedTask; + } + + private static void AtualizarEstadoBrokerCritico() + { + bool conectado = MqttServiceBaseCritical?.StatusConexao() == true; + + if (conectado) + { + MarcarBrokerCriticoConectado(); + return; + } + + int anterior = Interlocked.Exchange(ref _mqttCriticalConnectedState, 0); + + // O estado inicial desconectado não precisa ser contado como queda. + if (anterior == 1) + { + string motivo = MqttServiceBaseCritical? + .GetMetrics()? + .LastError; + + BaseLink.MarkBrokerDisconnected( + string.IsNullOrWhiteSpace(motivo) + ? "Cliente MQTT crítico desconectado" + : motivo + ); + } + } + + private static void MarcarBrokerCriticoConectado() + { + int anterior = Interlocked.Exchange(ref _mqttCriticalConnectedState, 1); + + if (anterior != 1) + BaseLink.MarkBrokerConnected(); + } + + private static async Task EncerrarMqttInternoAsync(string motivo) + { + AsyncTaskTimerModel monitor = _tmrMqttLinkMonitor; + _tmrMqttLinkMonitor = null; + + if (monitor != null) { - if (string.IsNullOrEmpty(message.Mensagem)) - return; try { - string json = message.Mensagem; - var posicao = JsonConvert.DeserializeObject(json); - posicao.Momento = DateTime.Now; - VariaveisOperacao.PosicaoBase = posicao; + await monitor.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { - Console.WriteLine($"Erro ao deserializar dados da base: {ex.Message}"); + Console.WriteLine("[MQTT] Erro ao encerrar monitor do link: " + ex.Message); } - }); + } + + MqttService local = MqttServiceLocal; + MqttService critical = MqttServiceBaseCritical; + MqttService telemetry = MqttServiceBaseTelemetry; + + MqttServiceLocal = null; + MqttServiceBaseCritical = null; + MqttServiceBaseTelemetry = null; + + LimparReferenciasTopicosMqtt(); + + MqttService[] services = new[] { local, critical, telemetry } + .Where(x => x != null) + .Distinct() + .ToArray(); + + foreach (MqttService service in services) + { + try + { + await service.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine( + "[MQTT] Erro ao encerrar cliente '" + + service.ClientId + "': " + ex.Message + ); + } + } + + int anterior = Interlocked.Exchange(ref _mqttCriticalConnectedState, 0); + + if (anterior == 1 || BaseLink.BrokerConnected) + BaseLink.MarkBrokerDisconnected(motivo); + } + + private static void LimparReferenciasTopicosMqtt() + { + TopicoLocalCoordenadasGps = null; + TopicoLocalTrajetoriaDinamica = null; + TopicoLocalSelecaoRuas = null; + + TopicoBaseDiscovery = null; + TopicoBaseTelemetria = null; + TopicoBaseParametros = null; + + TopicoBaseHeartbeat = null; + TopicoBaseComandos = null; + TopicoBaseRtcm = null; + TopicoBasePosicao = null; } @@ -894,7 +1285,64 @@ namespace AgroBase.Models return 0.7; } } - public static GPSModel PosicaoBase { get; set; } = new GPSModel(); + private static readonly object _posicaoBaseLock = new object(); + private static GPSModel _posicaoBase = new GPSModel(); + + /// + /// Propriedade mantida por compatibilidade. Novos callbacks devem usar + /// AtualizarPosicaoBase e leituras concorrentes devem usar + /// ObterPosicaoBaseSnapshot. + /// + public static GPSModel PosicaoBase + { + get + { + lock (_posicaoBaseLock) + return _posicaoBase; + } + set + { + lock (_posicaoBaseLock) + _posicaoBase = value ?? new GPSModel(); + } + } + + public static void AtualizarPosicaoBase(GPSModel posicao) + { + if (posicao == null) + return; + + lock (_posicaoBaseLock) + { + // Compatibilidade temporária: a lógica antiga de discovery + // ainda consulta este campo. Uma posição nova não deve apagar + // o heartbeat recebido anteriormente. + DateTime heartbeatAnterior = + _posicaoBase?.UltimoComandoRespondido ?? DateTime.MinValue; + + if (posicao.UltimoComandoRespondido < heartbeatAnterior) + posicao.UltimoComandoRespondido = heartbeatAnterior; + + _posicaoBase = posicao; + } + } + + public static void MarcarHeartbeatBaseLegado(DateTime momento) + { + lock (_posicaoBaseLock) + { + if (_posicaoBase == null) + _posicaoBase = new GPSModel(); + + _posicaoBase.UltimoComandoRespondido = momento; + } + } + + public static GPSModel ObterPosicaoBaseSnapshot() + { + lock (_posicaoBaseLock) + return _posicaoBase?.Clone() ?? new GPSModel(); + } public static OperadoresService Operadores { get; set; } = new OperadoresService(); diff --git a/AgroBase/AgroBase/Services/GPSService.cs b/AgroBase/AgroBase/Services/GPSService.cs index e8bf59744..fe02a924e 100644 --- a/AgroBase/AgroBase/Services/GPSService.cs +++ b/AgroBase/AgroBase/Services/GPSService.cs @@ -1,6 +1,7 @@ -using AgroBase.Models; +using AgroBase.Models; using Newtonsoft.Json; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -9,30 +10,41 @@ using System.IO.Ports; using System.Linq; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using static AgroBase.Models.Enums; namespace AgroBase.Services { + /// + /// Serviço GNSS/RTCM do rover. + /// + /// Princípios: + /// - leitura serial nunca executa rede, disco ou tarefas lentas; + /// - toda escrita no UM982 passa por um único lock; + /// - RTCM entra em fila limitada e mensagens velhas são descartadas; + /// - NTRIP e MQTT compartilham o mesmo trilho de escrita; + /// - intervalos e idades usam relógio monotônico; + /// - configuração do módulo é exclusiva; + /// - mantém as assinaturas públicas usadas pelo projeto legado. + /// public class GPSService { + // ============================================================ + // CONTRATO PÚBLICO LEGADO + // ============================================================ + public static SerialPort PortaGPS = null; + + /// + /// Estado puro. Consultar esta propriedade não abre nem altera a porta. + /// public static bool Iniciado { get { - if (PortaGPS != null && !PortaGPS.IsOpen) - { - try - { - PortaGPS.Open(); - } - catch - { - PortaGPS = null; - } - } - return PortaGPS != null && PortaGPS.IsOpen; + SerialPort porta = PortaGPS; + return porta != null && porta.IsOpen; } } @@ -46,1079 +58,2062 @@ namespace AgroBase.Services private static bool InverterHeading = true; private static int rtk_timeout = 60; private static int TempoMin_Ntrip = 10; - private static bool LoopRTK_Ntrip = false; - public static bool CorrecaoRTK_Ntrip = false; + + private static volatile bool LoopRTK_Ntrip = false; + public static volatile bool CorrecaoRTK_Ntrip = false; + + /// + /// Mantido para compatibilidade e diagnóstico humano. + /// Watchdogs internos usam Stopwatch. + /// public static DateTime UltimoEnvioCorrecaoRTK = DateTime.MinValue; - public static GeoLeverArm LeverArm = new GeoLeverArm(offsetFisicoFrontalCm: VariaveisEquipamento.LeverArmFrontalCm, offsetFisicoLateralCm: VariaveisEquipamento.LeverArmLateralCm); - public static void AtualizarPortaCOM(SerialPort Porta) + public static GeoLeverArm LeverArm = new GeoLeverArm( + offsetFisicoFrontalCm: VariaveisEquipamento.LeverArmFrontalCm, + offsetFisicoLateralCm: VariaveisEquipamento.LeverArmLateralCm + ); + + public static Queue historicoPosicao = new Queue(); + + // ============================================================ + // SINCRONIZAÇÃO E CICLO DE VIDA + // ============================================================ + + private static readonly object _stateLock = new object(); + private static readonly object _bufferLock = new object(); + private static readonly object _rtcmQueueLock = new object(); + private static readonly object _workerLock = new object(); + private static readonly object _portLifecycleLock = new object(); + + private static readonly SemaphoreSlim _serialWriteLock = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _configurationLock = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _rtcmSignal = + new SemaphoreSlim(0, int.MaxValue); + + private static readonly SemaphoreSlim _logSignal = + new SemaphoreSlim(0, int.MaxValue); + + private static readonly SemaphoreSlim _localPublishSignal = + new SemaphoreSlim(0, int.MaxValue); + + private static CancellationTokenSource _serviceCts; + private static Task _nmeaWorkerTask; + private static Task _rtcmWriterTask; + private static Task _logWriterTask; + private static Task _localPublisherTask; + + private static CancellationTokenSource _configurationCts; + + private static CancellationTokenSource _ntripCts; + private static Task _ntripTask; + private static TcpClient _ntripClient; + + private static long _portGeneration; + + // ============================================================ + // BUFFERS E FILAS + // ============================================================ + + private static readonly StringBuilder _nmeaBuffer = + new StringBuilder(8192); + + private const int MaxNmeaBufferChars = 64 * 1024; + + private static readonly object _nmeaQueueLock = new object(); + private static readonly Queue _nmeaQueue = + new Queue(); + private static readonly SemaphoreSlim _nmeaSignal = + new SemaphoreSlim(0, int.MaxValue); + private const int NmeaQueueCapacity = 512; + + private static readonly Queue _rtcmQueue = + new Queue(); + + private const int RtcmQueueCapacity = 48; + private const int RtcmMaxAgeMs = 2500; + private const int RtcmMaxPayloadBytes = 64 * 1024; + + private static long _rtcmSequence; + + private static readonly ConcurrentQueue _gpsLogQueue = + new ConcurrentQueue(); + + private static readonly object _localPublishLock = new object(); + private static readonly Dictionary _localLatestPayload = + new Dictionary(StringComparer.Ordinal); + + // ============================================================ + // MÉTRICAS + // ============================================================ + + private static long _lastSerialRxMono; + private static long _lastValidNmeaMono; + private static long _lastValidGgaMono; + private static long _lastValidThsMono; + private static long _lastRtcmReceivedMono; + private static long _lastRtcmWrittenMono; + + private static long _serialBytesReceived; + private static long _nmeaValid; + private static long _nmeaInvalid; + private static long _nmeaChecksumErrors; + private static long _nmeaBufferResets; + private static long _nmeaQueued; + private static long _nmeaDroppedQueue; + + private static long _rtcmReceived; + private static long _rtcmQueued; + private static long _rtcmWritten; + private static long _rtcmDroppedQueue; + private static long _rtcmDroppedStale; + private static long _rtcmDroppedInvalid; + private static long _rtcmWriteErrors; + + private static long _configurationRuns; + private static long _configurationErrors; + + private static long _ntripReconnects; + private static long _ntripBytesReceived; + private static long _ntripErrors; + + private static string _lastError; + private static readonly object _metricsTextLock = new object(); + + // ============================================================ + // INICIALIZAÇÃO DA PORTA + // ============================================================ + + public static void AtualizarPortaCOM(SerialPort porta) { - if (PortaGPS == null) - { - PortaGPS = new SerialPort(); - } - else if (Iniciado) - { - PortaGPS.Close(); - } + if (porta == null) + throw new ArgumentNullException(nameof(porta)); - PortaGPS.BaudRate = Porta.BaudRate; - PortaGPS.PortName = Porta.PortName; - PortaGPS.ReadTimeout = 2000; // Timeout de 2 segundos - PortaGPS.WriteTimeout = 2000; // Timeout de 2 segundos - PortaGPS.DataReceived -= PortaGPS_DataReceived; - PortaGPS.DataReceived += PortaGPS_DataReceived; + EnsureBackgroundWorkersStarted(); - Porta.Close(); + long generation = Interlocked.Increment(ref _portGeneration); - if (Iniciado) + /* + * A troca da porta participa do mesmo trilho de escrita. + * Assim nenhum RTCM ou comando ASCII escreve enquanto a + * SerialPort está sendo fechada e substituída. + */ + _serialWriteLock.Wait(); + + try { - DefinirDispositivo(); - Task.Run(async () => await ConfigurarModulo()); - if (!Variaveis.IsAgroMonitor && CorrecaoRTK_Ntrip) + lock (_portLifecycleLock) { - Task.Run(async () => await AplicarCorrecaoRTK_Ntrip()); + SerialPort anterior = PortaGPS; + + if (anterior != null) + { + try + { + anterior.DataReceived -= PortaGPS_DataReceived; + } + catch { } + + try + { + if (anterior.IsOpen) + anterior.Close(); + } + catch (Exception ex) + { + RecordError( + "[GPSService.AtualizarPortaCOM] Erro ao fechar porta antiga: " + + ex.Message + ); + } + + try + { + anterior.Dispose(); + } + catch { } + } + + var novaPorta = new SerialPort + { + BaudRate = porta.BaudRate, + PortName = porta.PortName, + ReadTimeout = 2000, + WriteTimeout = 2000, + Encoding = Encoding.ASCII, + DtrEnable = false, + RtsEnable = false + }; + + novaPorta.DataReceived += PortaGPS_DataReceived; + + try + { + if (porta.IsOpen) + porta.Close(); + } + catch { } + + PortaGPS = novaPorta; + + try + { + novaPorta.Open(); + } + catch (Exception ex) + { + novaPorta.DataReceived -= PortaGPS_DataReceived; + PortaGPS = null; + RecordError( + "[GPSService.AtualizarPortaCOM] Não foi possível abrir " + + novaPorta.PortName + ": " + ex.Message + ); + return; + } } } + finally + { + _serialWriteLock.Release(); + } + + DefinirDispositivo(); + + _ = Task.Run(async () => + { + try + { + await ConfigurarModulo().ConfigureAwait(false); + + if (generation != Interlocked.Read(ref _portGeneration)) + return; + + if (!Variaveis.IsAgroMonitor && CorrecaoRTK_Ntrip) + await IniciarNtripAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + RecordError( + "[GPSService.AtualizarPortaCOM] Falha pós-conexão: " + + ex.Message + ); + } + }); } private static void DefinirDispositivo() { - if (Iniciado) + SerialPort porta = PortaGPS; + + if (porta == null || !porta.IsOpen) + return; + + try { - SerialService.DispositivosMapeados.Add(new DispositivoDetalhesModel() + bool jaExiste = SerialService.DispositivosMapeados.Any( + x => x.Dispositivo == T_Code.Gps && + string.Equals( + x.Endereco, + porta.PortName, + StringComparison.OrdinalIgnoreCase + ) + ); + + if (!jaExiste) { - Dispositivo = T_Code.Gps, - Endereco = PortaGPS.PortName, - Versao = "1", - }); + SerialService.DispositivosMapeados.Add( + new DispositivoDetalhesModel + { + Dispositivo = T_Code.Gps, + Endereco = porta.PortName, + Versao = "1" + } + ); + } + } + catch (Exception ex) + { + RecordError( + "[GPSService.DefinirDispositivo] " + ex.Message + ); } } + // ============================================================ + // CONFIGURAÇÃO DO UM982 + // ============================================================ + public static async Task ConfigurarModulo() { - Variaveis.MostrarLog($"[GPSService.ConfigurarModulo] Iniciando configuração do módulo GPS..."); - await ConfigurarModuloRover(comprimento_antena: 100); - } + EnsureBackgroundWorkersStarted(); - private static async Task ConfigurarModuloRover(string porta_usb = "com3", string porta_entrada = "com2", int comprimento_antena = 100, int tolerancia_antena = 5) - { - string freq = (1.0 / TaxaAmostragemHz).ToString("0.0").Replace(",", "."); - string[] comandos = { - // Bauds - $"config com1 115200\r\n", - $"config com2 115200\r\n", - $"config com3 115200\r\n", + CancellationTokenSource novaCts = new CancellationTokenSource(); + CancellationTokenSource anterior = + Interlocked.Exchange(ref _configurationCts, novaCts); - // limpa logs das portas - $"unlog com1\r\n", - $"unlog com2\r\n", - $"unlog com3\r\n", - - // modo rover + RTK - $"mode rover uav\r\n", - - // timeouts de correção - $"config rtk timeout {rtk_timeout}\r\n", - $"config dgps timeout 60\r\n", // ou 0 para desabilitar DGPS fallback - - // heading 2 antenas - $"config heading fixlength\r\n", - //$"config heading tractor\r\n", - $"config heading length {comprimento_antena} {tolerancia_antena}\r\n", - // (se precisar, existe 'config heading offset ') - - // NMEA só na USB (COM1) - $"gngga {porta_usb} {freq}\r\n", - $"gpths {porta_usb} {freq}\r\n", - $"gpvtg {porta_usb} {freq}\r\n", - - $"saveconfig\r\n" - }; - - await Task.Delay(2000); - - foreach (string comando in comandos) + if (anterior != null) { - try - { - byte[] bytesComando = Encoding.ASCII.GetBytes(comando); - PortaGPS?.Write(bytesComando, 0, bytesComando.Length); - } - catch (Exception ex) - { - Variaveis.MostrarLog($"[GPSService.ConfigurarModuloRover] Erro ao enviar comando de configuração GNSS: {ex.Message}"); - } - await Task.Delay(1000); // Pequeno delay para evitar sobrecarga na comunicação + try { anterior.Cancel(); } catch { } + anterior.Dispose(); } + + await ConfigurarModuloRover( + comprimento_antena: 100, + cancellationToken: novaCts.Token + ).ConfigureAwait(false); } - private static async Task ConfigurarModuloBase(string porta_usb = "com3", string porta_saida = "com2", int tempo_fixacao = 60) + private static async Task ConfigurarModuloRover( + string porta_usb = "com3", + string porta_entrada = "com2", + int comprimento_antena = 100, + int tolerancia_antena = 5, + CancellationToken cancellationToken = default(CancellationToken)) { - string base_id = "957"; - string distancia_min = "0"; - string[] comandos = { - // Bauds - $"config com1 115200\r\n", - $"config com2 115200\r\n", - $"config com3 115200\r\n", + await _configurationLock.WaitAsync(cancellationToken) + .ConfigureAwait(false); - // limpa logs das portas - $"unlog com1\r\n", - $"unlog com2\r\n", - $"unlog com3\r\n", + Interlocked.Increment(ref _configurationRuns); - // Base com Survey-In (tempo + acurácia) - $"mode base {base_id} time {tempo_fixacao} {distancia_min}\r\n", - - // RTCM perfil (comece leve; ative mais constelações se o LoRa aguentar) - $"RTCM1006 {porta_saida} 10\r\n", - $"RTCM1033 {porta_saida} 30\r\n", - $"RTCM1074 {porta_saida} 1\r\n", // GPS MSM4 - $"RTCM1124 {porta_saida} 1\r\n", // BeiDou MSM4 - - // (Opcional) ativar mais constelações: - $"RTCM1094 {porta_saida} 1\r\n", // Galileo MSM4 - $"RTCM1084 {porta_saida} 1\r\n", // GLONASS MSM4 - //$"RTCM1230 {porta_saida} 10\r\n",// Bias GLONASS (OBRIGATÓRIO se 1084 estiver ativo) - - // NMEA mínimo para debug na USB - $"gngga {porta_usb} 1\r\n", - - $"saveconfig\r\n", - }; - - await Task.Delay(2000); - - foreach (string cmd in comandos) - { - try - { - byte[] bytes = Encoding.ASCII.GetBytes(cmd); - PortaGPS.Write(bytes, 0, bytes.Length); - } - catch (Exception ex) - { - Variaveis.MostrarLog($"[GPSService.ConfigurarModuloBase] Erro ao enviar comando de configuração GNSS: {ex.Message}"); - } - await Task.Delay(500); - } - } - - - - private static readonly object _bufferLock = new object(); - private static readonly StringBuilder _buffer = new StringBuilder(); - - private static void PortaGPS_DataReceived(object sender, SerialDataReceivedEventArgs e) - { try { - if (!(PortaGPS?.IsOpen ?? false)) - return; + Variaveis.MostrarLog( + "[GPSService.ConfigurarModuloRover] Iniciando configuração." + ); - string recebido = PortaGPS.ReadExisting(); + string freq = (1.0 / Math.Max(1, TaxaAmostragemHz)) + .ToString("0.0", CultureInfo.InvariantCulture); + + string[] comandos = + { + "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 rover uav\r\n", + + "config rtk timeout " + rtk_timeout + "\r\n", + "config dgps timeout 60\r\n", + + "config heading fixlength\r\n", + "config heading length " + + comprimento_antena + " " + + tolerancia_antena + "\r\n", + + "gngga " + porta_usb + " " + freq + "\r\n", + "gpths " + porta_usb + " " + freq + "\r\n", + "gpvtg " + porta_usb + " " + freq + "\r\n", + + "saveconfig\r\n" + }; + + await Task.Delay(1500, cancellationToken) + .ConfigureAwait(false); + + /* + * Mantém exclusividade durante a sequência completa. + * RTCMs que envelhecerem enquanto o módulo é configurado + * serão descartados pelo worker, em vez de serem injetados + * entre comandos ASCII. + */ + await _serialWriteLock.WaitAsync(cancellationToken) + .ConfigureAwait(false); + + try + { + foreach (string comando in comandos) + { + cancellationToken.ThrowIfCancellationRequested(); + + WriteSerialUnsafe( + Encoding.ASCII.GetBytes(comando), + comando.Length + ); + + await Task.Delay(350, cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + _serialWriteLock.Release(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Interlocked.Increment(ref _configurationErrors); + RecordError( + "[GPSService.ConfigurarModuloRover] " + ex.Message + ); + throw; + } + finally + { + _configurationLock.Release(); + } + } + + private static async Task ConfigurarModuloBase( + string porta_usb = "com3", + string porta_saida = "com2", + int tempo_fixacao = 60, + CancellationToken cancellationToken = default(CancellationToken)) + { + await _configurationLock.WaitAsync(cancellationToken) + .ConfigureAwait(false); + + Interlocked.Increment(ref _configurationRuns); + + try + { + string[] comandos = + { + "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 957 time " + tempo_fixacao + " 0\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 _serialWriteLock.WaitAsync(cancellationToken) + .ConfigureAwait(false); + + try + { + foreach (string comando in comandos) + { + cancellationToken.ThrowIfCancellationRequested(); + + byte[] bytes = Encoding.ASCII.GetBytes(comando); + WriteSerialUnsafe(bytes, bytes.Length); + + await Task.Delay(350, cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + _serialWriteLock.Release(); + } + } + catch + { + Interlocked.Increment(ref _configurationErrors); + throw; + } + finally + { + _configurationLock.Release(); + } + } + + // ============================================================ + // RECEPÇÃO SERIAL / NMEA + // ============================================================ + + private static void PortaGPS_DataReceived( + object sender, + SerialDataReceivedEventArgs e) + { + SerialPort porta = sender as SerialPort; + + if (porta == null || !porta.IsOpen) + return; + + try + { + string recebido = porta.ReadExisting(); if (string.IsNullOrEmpty(recebido)) return; - string blocoCompleto; - - lock (_bufferLock) - { - _buffer.Append(recebido); - - string acumulado = _buffer.ToString(); - int ultimaQuebraLinha = acumulado.LastIndexOf('\n'); - - // Ainda não chegou nenhuma sentença completa. - if (ultimaQuebraLinha < 0) - return; - - blocoCompleto = acumulado.Substring( - 0, - ultimaQuebraLinha + 1 - ); - - string restante = acumulado.Substring( - ultimaQuebraLinha + 1 - ); - - /* - * Retiramos as sentenças completas do buffer antes - * de processá-las. Assim, uma exceção em qualquer - * consumidor não reapresenta as mesmas sentenças. - */ - _buffer.Clear(); - _buffer.Append(restante); - } - - string[] mensagens = blocoCompleto.Split('\n'); - - foreach (string mensagemRaw in mensagens) - { - string mensagem = mensagemRaw.Trim(); - - if (mensagem.Length == 0) - continue; - - try - { - ProcessarDadosNMEA(mensagem); - } - catch (Exception ex) - { - Variaveis.MostrarLog( - $"[GPSService.PortaGPS_DataReceived] " + - $"Erro ao processar a sentença '{mensagem}': {ex}" - ); - } - } - - var obj = UltimaLeitura.Clone(); - obj.Momento = DateTime.Now; - - Logs.Add(JsonConvert.SerializeObject(obj)); - - VariaveisOperacao.RegistrarLogDispositivo( - Logs, - T_Code.Gps, - ".json", - 300 + Interlocked.Add( + ref _serialBytesReceived, + Encoding.ASCII.GetByteCount(recebido) ); + + Interlocked.Exchange( + ref _lastSerialRxMono, + Stopwatch.GetTimestamp() + ); + + List sentencas = ExtrairSentencasCompletas(recebido); + + foreach (string sentenca in sentencas) + { + EnfileirarSentencaNmea(sentenca); + } } catch (Exception ex) { - Variaveis.MostrarLog( - $"[GPSService.PortaGPS_DataReceived] " + - $"Erro geral na porta serial: {ex}" + RecordError( + "[GPSService.PortaGPS_DataReceived] " + ex.Message ); } } - - - private static void ProcessarDadosNMEA(string nmeaData) + private static void EnfileirarSentencaNmea(string sentenca) { - DateTime Agora = DateTime.Now; - var linhas = nmeaData.Split('\n'); - foreach (var linha in linhas) + if (string.IsNullOrWhiteSpace(sentenca)) + return; + + lock (_nmeaQueueLock) { - if (string.IsNullOrWhiteSpace(linha)) continue; + while (_nmeaQueue.Count >= NmeaQueueCapacity) + { + _nmeaQueue.Dequeue(); + Interlocked.Increment(ref _nmeaDroppedQueue); + } - var sentenca = linha.Trim(); - //Console.WriteLine(sentenca); - - // Identifica o tipo de sentença - - // GPS Antigo - if (sentenca.StartsWith("$GPGGA")) - { - ProcessarGPGGA(sentenca); - AtualizarCoordenadasGPS(); - } - // Coordenadas - else if (sentenca.StartsWith("$GNGGA") || sentenca.StartsWith("$GLGGA")) - { - ProcessarGNGGA(sentenca); - AtualizarCoordenadasGPS(); - } - // Variação magnética - else if (sentenca.StartsWith("$GNRMC")) - { - ProcessarGNRMC(sentenca); - } - // Curso verdadeiro - else if (sentenca.StartsWith("$GNVTG") || sentenca.StartsWith("$GPVTG")) - { - ProcessarGNVTG(sentenca); - } - // Satélites em vista - else if (sentenca.StartsWith("$GPGSV") || sentenca.StartsWith("$GLGSV") || sentenca.StartsWith("$GBGSV") || sentenca.StartsWith("$GAGSV")) - { - ProcessarGSV(sentenca); - } - // Orientação real - else if (sentenca.StartsWith("$GNTHS") || sentenca.StartsWith("$GPTHS") || sentenca.StartsWith("$GATHS")) - { - ProcessarGNTHS(sentenca); - //AtualizarCoordenadasGPS(); - } - // GLL (GP/GN/GL/GA/BD) - else if (sentenca.Length > 6 && sentenca[3] == 'G' && sentenca[4] == 'L' && sentenca[5] == 'L') - { - //ProcessarGNGLL(sentenca); - } - // GSA (GP/GN/GL/GA/BD) - 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') // RMC - { - ProcessarGxRMC(sentenca); - } - // ACK de comando do UM982, não é sentença NMEA de navegação. - else if (sentenca.StartsWith("$command,", StringComparison.OrdinalIgnoreCase)) - { - Variaveis.MostrarLog($"[GPSService.ProcessarDadosNMEA] {sentenca}"); - return; - } - else - { - Variaveis.MostrarLog($"[GPSService.ProcessarDadosNMEA] Sentença desconhecida: {sentenca}"); - } + _nmeaQueue.Enqueue(sentenca); + Interlocked.Increment(ref _nmeaQueued); } - PenultimaLeitura.UltimoComandoRespondido = UltimaLeitura.UltimoComandoRespondido; - UltimaLeitura.UltimoComandoRespondido = Agora; + try { _nmeaSignal.Release(); } + catch (SemaphoreFullException) { } } - private static void ProcessarGPGGA(string sentenca) + private static async Task NmeaWorkerLoopAsync( + CancellationToken cancellationToken) { - string[] parts = sentenca.Split(','); - - PenultimaLeitura.Momento = UltimaLeitura.Momento; - PenultimaLeitura.TimestampPos = UltimaLeitura.TimestampPos.Clone(); - PenultimaLeitura.Latitude = UltimaLeitura.Latitude; - PenultimaLeitura.Longitude = UltimaLeitura.Longitude; - PenultimaLeitura.LatitudeAnt = UltimaLeitura.LatitudeAnt; - PenultimaLeitura.LongitudeAnt = UltimaLeitura.LongitudeAnt; - PenultimaLeitura.Altitude = UltimaLeitura.Altitude; - PenultimaLeitura.PrecisaoHorizontal = UltimaLeitura.PrecisaoHorizontal; - PenultimaLeitura.DataHora = UltimaLeitura.DataHora; - PenultimaLeitura.NumeroSatelites = UltimaLeitura.NumeroSatelites; - - UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; - UltimaLeitura.Momento = DateTime.Now; - - double? lat = null; - double? lon = null; - if (parts[2] != "" && parts[3] != "") + while (!cancellationToken.IsCancellationRequested) { - lat = GPSUtils.ConvertToDecimalDegrees(parts[2], parts[3], 2); - } - if (parts[4] != "" && parts[5] != "") - { - lon = GPSUtils.ConvertToDecimalDegrees(parts[4], parts[5], 3); - } - if (lat != null && lon != null) - { - UltimaLeitura.LatitudeAnt = lat.Value; - UltimaLeitura.LongitudeAnt = lon.Value; - AplicarPosicaoCorrigida(); - } - UltimaLeitura.NumeroSatelites = int.TryParse(parts[7], out int numSat) ? numSat : 0; - UltimaLeitura.PrecisaoHorizontal = double.TryParse(parts[8], NumberStyles.Float, CultureInfo.InvariantCulture, out double hdop) ? hdop : 0; - UltimaLeitura.Altitude = double.TryParse(parts[9], NumberStyles.Float, CultureInfo.InvariantCulture, out double alt) ? alt : 0; - } - - private static void ProcessarGNGGA(string sentenca) - { - var ci = CultureInfo.InvariantCulture; - var campos = sentenca.Split(','); - - string horaUTC = campos.Length > 1 ? campos[1] : ""; - string latitudeRaw = campos.Length > 2 ? campos[2] : ""; - string hemisferioLat = campos.Length > 3 ? campos[3] : ""; - string longitudeRaw = campos.Length > 4 ? campos[4] : ""; - string hemisferioLon = campos.Length > 5 ? campos[5] : ""; - string qualidade = campos.Length > 6 ? campos[6] : "0"; - string satelitesUsados = campos.Length > 7 ? campos[7] : "0"; - string hdop = campos.Length > 8 ? campos[8] : "99.9"; - string altitudeRaw = campos.Length > 9 ? campos[9] : "0"; - string geoidSepRaw = campos.Length > 11 ? campos[11] : "0"; - string idadeCorrecaoRaw = campos.Length > 13 ? campos[13] : ""; - string base_id = campos.Length > 14 ? campos[14].Split('*')[0] : ""; - - // Conversão de Latitude (ddmm.mmmm) - double latitude = 0; - if (!string.IsNullOrEmpty(latitudeRaw)) - { - // lat tem 2 dígitos de graus - var deg = double.Parse(latitudeRaw.Substring(0, 2), ci); - var min = double.Parse(latitudeRaw.Substring(2), ci); - latitude = deg + (min / 60.0); - if (hemisferioLat.Equals("S", StringComparison.OrdinalIgnoreCase)) latitude *= -1; - } - - - // Conversão de Longitude (dddmm.mmmm) - double longitude = 0; - if (!string.IsNullOrEmpty(longitudeRaw)) - { - // lon tem 3 dígitos de graus - var deg = double.Parse(longitudeRaw.Substring(0, 3), ci); - var min = double.Parse(longitudeRaw.Substring(3), ci); - longitude = deg + (min / 60.0); - if (hemisferioLon.Equals("W", StringComparison.OrdinalIgnoreCase)) longitude *= -1; - } - - // Altitude MSL (campo 9) - double altMSL = 0; - double.TryParse(altitudeRaw, NumberStyles.Float, ci, out altMSL); - - // Geoid separation (campo 11) - double geoidSep = 0; - double.TryParse(geoidSepRaw, NumberStyles.Float, ci, out geoidSep); - - // Altura elipsoidal = MSL + geoid separation - double altElipsoidal = altMSL + geoidSep; - - // HDOP (adimensional) - double.TryParse(hdop, NumberStyles.Float, ci, out double hdopVal); - - int.TryParse(satelitesUsados, out int nsatelites); - int.TryParse(qualidade, out int fixCode); - double idadeCorrecao = -1; - if (!string.IsNullOrWhiteSpace(idadeCorrecaoRaw)) - double.TryParse(idadeCorrecaoRaw, NumberStyles.Float, ci, out idadeCorrecao); - - - //Console.WriteLine($"GNGGA: Hora={horaUTC}, Latitude={latitude}, Longitude={longitude}, Qualidade={qualidade}, Satélites={satelitesUsados}, HDOP={hdop}, Altitude={altitude}"); - - PenultimaLeitura.TimestampPos = UltimaLeitura.TimestampPos.Clone(); - PenultimaLeitura.Momento = UltimaLeitura.Momento; - PenultimaLeitura.Latitude = UltimaLeitura.Latitude; - PenultimaLeitura.Longitude = UltimaLeitura.Longitude; - PenultimaLeitura.LatitudeAnt = UltimaLeitura.LatitudeAnt; - PenultimaLeitura.LongitudeAnt = UltimaLeitura.LongitudeAnt; - PenultimaLeitura.Altitude = UltimaLeitura.Altitude; - PenultimaLeitura.AltitudeElipsoidal = UltimaLeitura.AltitudeElipsoidal; - PenultimaLeitura.PrecisaoHorizontal = UltimaLeitura.PrecisaoHorizontal; - PenultimaLeitura.NumeroSatelites = UltimaLeitura.NumeroSatelites; - PenultimaLeitura.QualidadeFix = UltimaLeitura.QualidadeFix; - PenultimaLeitura.DataHora = UltimaLeitura.DataHora; - PenultimaLeitura.IdadeCorrecao = UltimaLeitura.IdadeCorrecao; - PenultimaLeitura.BaseID = UltimaLeitura.BaseID; - - // Armazenar os valores na última leitura - UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; - UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.LatitudeAnt = latitude; - UltimaLeitura.LongitudeAnt = longitude; - UltimaLeitura.Altitude = altMSL; - UltimaLeitura.AltitudeElipsoidal = altElipsoidal; - UltimaLeitura.PrecisaoHorizontal = hdopVal; - UltimaLeitura.NumeroSatelites = nsatelites; - UltimaLeitura.QualidadeFix = (TiposCorrecaoGPS)fixCode; - UltimaLeitura.IdadeCorrecao = idadeCorrecao; - UltimaLeitura.BaseID = base_id; - - AplicarPosicaoCorrigida(); - - // Hora UTC no formato HHmmss.ss (fração opcional) - if (!string.IsNullOrEmpty(horaUTC) && horaUTC.Length >= 6) - { - // Pega HHmmss e, se houver, fração: - var hh = int.Parse(horaUTC.Substring(0, 2), ci); - var mm = int.Parse(horaUTC.Substring(2, 2), ci); - var ssStr = horaUTC.Substring(4); // "ss" ou "ss.ss" - double ss = double.Parse(ssStr, ci); - var ts = new TimeSpan(0, hh, mm, (int)Math.Floor(ss), (int)Math.Round((ss - Math.Floor(ss)) * 1000.0)); - var currentDateUtc = DateTime.UtcNow.Date; - UltimaLeitura.DataHora = currentDateUtc.Add(ts).ToLocalTime(); - } - - // 1) define origem ENU na primeira leitura válida - if (!UltimaLeitura.EnuOriginSet && UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.RTKFixo) // tem fix - { - UltimaLeitura.Lat0 = latitude; - UltimaLeitura.Lon0 = longitude; - UltimaLeitura.EnuOriginSet = true; - } - - } - - private static void ProcessarGNRMC(string sentenca) - { - var campos = sentenca.Split(','); - - string horaUTC = campos[1].Replace(".", ","); - string status = campos[2].Replace(".", ","); - string latitudeRaw = campos[3].Replace(".", ","); - string hemisferioLat = campos[4].Replace(".", ","); - string longitudeRaw = campos[5].Replace(".", ","); - string hemisferioLon = campos[6].Replace(".", ","); - string velocidadeSobreSolo = campos[7].Replace(".", ","); - string curso = campos[8].Replace(".", ","); - string data = campos[9].Replace(".", ","); - string variaçãoMagnetica = campos[10].Replace(".", ","); - - double.TryParse(variaçãoMagnetica, out double variacaoMag); - - PenultimaLeitura.Momento = UltimaLeitura.Momento; - PenultimaLeitura.VariacaoMagnetica = UltimaLeitura.VariacaoMagnetica; - - UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.VariacaoMagnetica = variacaoMag; - - //Console.WriteLine($"GNRMC: Hora={horaUTC}, Status={status}, Latitude={latitudeRaw}{hemisferioLat}, Longitude={longitudeRaw}{hemisferioLon}, Velocidade={velocidadeSobreSolo}, Curso={curso}, Data={data}, Variação Magnética={variaçãoMagnetica}"); - } - - private static void ProcessarGNVTG(string sentenca) - { - var campos = sentenca.Split(','); - - string cursoVerdadeiro = campos[1].Replace(".", ","); - string referenciaCurso = campos[2].Replace(".", ","); // T = Verdadeiro, M = Magnético - string velocidadeSobreSoloKnots = campos[5].Replace(".", ","); - string velocidadeSobreSoloKmh = campos[7].Replace(".", ","); - - //Console.WriteLine($"GNVTG: Curso Verdadeiro={cursoVerdadeiro}{referenciaCurso}, Velocidade (nós)={velocidadeSobreSoloKnots}, Velocidade (km/h)={velocidadeSobreSoloKmh}"); - - PenultimaLeitura.Momento = UltimaLeitura.Momento; - PenultimaLeitura.CursoVerdadeiro = UltimaLeitura.CursoVerdadeiro; - PenultimaLeitura.Velocidade = UltimaLeitura.Velocidade; - - UltimaLeitura.Momento = DateTime.Now; - - double.TryParse(cursoVerdadeiro, out double curso); - UltimaLeitura.CursoVerdadeiro = curso; - - double.TryParse(velocidadeSobreSoloKmh, out double velocidade); - UltimaLeitura.Velocidade = velocidade; - } - - private static void ProcessarGSV(string sentenca) - { - var campos = sentenca.Split(','); - - string tipoSistema = sentenca.Substring(1, 2); // GP = GPS, GL = GLONASS, etc. - string totalSentencas = campos[1]; - string sentencaAtual = campos[2]; - string satelitesVisiveis = campos[3]; - - PenultimaLeitura.Momento = UltimaLeitura.Momento; - PenultimaLeitura.SatelitesEmVista = new List(UltimaLeitura.SatelitesEmVista); - - - UltimaLeitura.Momento = DateTime.Now; - - int.TryParse(sentencaAtual, out int sentAtual); - int.TryParse(totalSentencas, out int sentTotal); - int.TryParse(satelitesVisiveis, out int visiveis); - - if (!UltimaLeitura.SatelitesEmVista.Any(x => x.TipoSistema == tipoSistema)) - { - UltimaLeitura.SatelitesEmVista.Add(new GPSSatelitesEmVistaModel() + try { - TipoSistema = tipoSistema, - Sentencas = new List() - }); - } - - var leitura = UltimaLeitura.SatelitesEmVista.First(x => x.TipoSistema == tipoSistema); - - //Console.WriteLine($"GSV: Sistema={tipoSistema}, Sentença {sentencaAtual}/{totalSentencas}, Satélites Visíveis={satelitesVisiveis}"); - - if (!leitura.Sentencas.Any(x => x.SentencaAtual == sentAtual)) - { - leitura.Sentencas.Add(new GPSSatelitesEmVistaSentencaModel() + await _nmeaSignal.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) { - SentencaAtual = sentAtual, - SentencasTotal = sentTotal, - QuantidadeSatelites = visiveis, - Dados = new List() - }); - } + break; + } - var _sentenca = leitura.Sentencas.First(x => x.SentencaAtual == sentAtual); + bool processouAlgo = false; - _sentenca.Dados = new List(); - - for (int i = 4; i < campos.Length; i += 4) - { - if (i + 3 < campos.Length) + while (true) { - string prn = campos[i].Replace(".", ","); - string elevacaoRaw = campos[i + 1].Replace(".", ","); - string azimuteRaw = campos[i + 2].Replace(".", ","); - string snrRaw = campos[i + 3].Replace(".", ","); + string sentenca = null; - //Console.WriteLine($" Satélite PRN={prn}, Elevação={elevacaoRaw}, Azimute={azimuteRaw}, SNR={snrRaw}"); - - double.TryParse(elevacaoRaw, out double elevacao); - double.TryParse(azimuteRaw, out double azimute); - double.TryParse(snrRaw, out double snr); - - _sentenca.Dados.Add(new GPSSatelitesEmVistaDadosModel() + lock (_nmeaQueueLock) { - PRN = prn, - Elevacao = elevacao, - Azimute = azimute, - QualidadeSinal = snr - }); + if (_nmeaQueue.Count > 0) + sentenca = _nmeaQueue.Dequeue(); + } + + if (sentenca == null) + break; + + ProcessarSentencaSegura(sentenca); + processouAlgo = true; } + + if (processouAlgo) + EnfileirarLogSnapshot(); } } - private static void ProcessarGNTHS(string sentenca) + private static List ExtrairSentencasCompletas( + string recebido) + { + var resultado = new List(); + + lock (_bufferLock) + { + _nmeaBuffer.Append(recebido); + + if (_nmeaBuffer.Length > MaxNmeaBufferChars) + { + /* + * Tenta preservar a última possível sentença. + * Caso não exista '$', limpa completamente. + */ + string acumuladoExcedido = _nmeaBuffer.ToString(); + int ultimoInicio = acumuladoExcedido.LastIndexOf('$'); + + _nmeaBuffer.Clear(); + + if (ultimoInicio >= 0 && + acumuladoExcedido.Length - ultimoInicio < 2048) + { + _nmeaBuffer.Append( + acumuladoExcedido.Substring(ultimoInicio) + ); + } + + Interlocked.Increment(ref _nmeaBufferResets); + } + + while (true) + { + string acumulado = _nmeaBuffer.ToString(); + int fim = acumulado.IndexOf('\n'); + + if (fim < 0) + break; + + string linha = acumulado.Substring(0, fim) + .Trim('\r', '\n', ' ', '\t'); + + _nmeaBuffer.Remove(0, fim + 1); + + if (!string.IsNullOrWhiteSpace(linha)) + resultado.Add(linha); + } + } + + return resultado; + } + + private static void ProcessarSentencaSegura(string sentenca) { if (string.IsNullOrWhiteSpace(sentenca)) return; try { - //var bicos = Variaveis.OperacaoEmAndamento.DispAtu?.Dados?.BicosPulverizadores?.Select(x => x.ComandoAtuar ? "ON" : "OFF").ToList(); - //string estadoSolenoides = bicos == null ? "INDISPONIVEL" : string.Join(",", bicos); - //Variaveis.MostrarLog( - // $"[GPSService.ProcessarGNTHS] " + - // $"Sentença recebida: {sentenca}, " + - // $"Solenoides: {estadoSolenoides}" - //); - - /* - * Exemplos: - * - * Válido: - * $GPTHS,123.4567,A*XX - * - * Inválido: - * $GPTHS,,V*0E - */ - - string conteudo = sentenca.Trim(); - - if (conteudo.StartsWith("$")) - conteudo = conteudo.Substring(1); - - string[] partesChecksum = conteudo.Split( - new[] { '*' }, - 2 - ); - - string corpo = partesChecksum[0]; - - string[] campos = corpo.Split(','); - - // [0] = GPTHS/GNTHS - // [1] = heading - // [2] = status A/V - if (campos.Length < 3) + if (!ValidarChecksumNmea(sentenca)) { - Variaveis.MostrarLog( - $"[GPSService.ProcessarGNTHS] Sentença incompleta: {sentenca}" - ); + Interlocked.Increment(ref _nmeaChecksumErrors); + Interlocked.Increment(ref _nmeaInvalid); return; } - string headingTexto = (campos[1] ?? string.Empty).Trim(); - - string statusRecebido = (campos[2] ?? string.Empty) - .Trim() - .ToUpperInvariant(); - - bool headingNumerico = double.TryParse( - headingTexto, - NumberStyles.Float, - CultureInfo.InvariantCulture, - out double headingTrue - ); - - bool headingValido = - statusRecebido == "A" && - headingNumerico && - !double.IsNaN(headingTrue) && - !double.IsInfinity(headingTrue); - - /* - * Toda sentença recebida gera uma nova leitura, - * mesmo quando o heading está inválido. - * - * Dessa forma, a frequência continua sendo calculada - * pela chegada das sentenças THS. - */ - - PenultimaLeitura.TimestampOri.valor = - UltimaLeitura.TimestampOri.valor; - - PenultimaLeitura.Momento = - UltimaLeitura.Momento; - - PenultimaLeitura.OrientacaoReal = - UltimaLeitura.OrientacaoReal; - - PenultimaLeitura.TipoOrientacao = - UltimaLeitura.TipoOrientacao; - - UltimaLeitura.TimestampOri.valor = - Stopwatch.GetTimestamp() / - (double)Stopwatch.Frequency; - - UltimaLeitura.Momento = DateTime.Now; - - if (!headingValido) + lock (_stateLock) { - /* - * Comunicação está funcionando, mas o UM982 - * não possui solução válida de heading. - */ - UltimaLeitura.OrientacaoReal = 9999; - - UltimaLeitura.TipoOrientacao = "V"; - - /* - * Não aplicar posição corrigida nem atualizar - * o ângulo usado pelo controle do rover. - * - * O valor 9999 existe apenas para telemetria - * e diagnóstico de saúde. - */ - return; + ProcessarDadosNMEA(sentenca); } - headingTrue = GPSUtils.NormalizarAngulo( - headingTrue + Interlocked.Increment(ref _nmeaValid); + Interlocked.Exchange( + ref _lastValidNmeaMono, + Stopwatch.GetTimestamp() ); - - UltimaLeitura.OrientacaoReal = - InverterHeading - ? GPSUtils.NormalizarAngulo( - headingTrue - 180.0 - ) - : headingTrue; - - UltimaLeitura.TipoOrientacao = "A"; - - AplicarPosicaoCorrigida(); - - // Atualiza o ângulo usado pelo restante do sistema - // somente quando a solução é válida. - DefinirAnguloCarro(); } catch (Exception ex) { - Variaveis.MostrarLog( - $"[GPSService.ProcessarGNTHS] " + - $"Erro ao processar a sentença '{sentenca}': {ex.Message}" + Interlocked.Increment(ref _nmeaInvalid); + RecordError( + "[GPSService.ProcessarSentencaSegura] Sentença '" + + sentenca + "': " + ex.Message ); } } - private static void ProcessarGNGLL(string sentenca) + private static bool ValidarChecksumNmea(string sentenca) { - // Aceita GP/GL/GN… qualquer “G?GLL” - var campos = sentenca.Split(','); - if (campos.Length < 7) return; - - // lat/lon - string latRaw = campos[1]; - string latHem = campos[2]; - string lonRaw = campos[3]; - string lonHem = campos[4]; - string horaUTC = campos[5]; // hhmmss.ss - string status = campos[6]; // A/V - string mode = campos.Length > 7 ? campos[7].Split('*')[0] : ""; // pode não existir - - bool valido = status == "A" && mode != "N"; - - if (valido && !string.IsNullOrWhiteSpace(latRaw) && !string.IsNullOrWhiteSpace(lonRaw)) + if (sentenca.StartsWith( + "$command,", + StringComparison.OrdinalIgnoreCase)) { - double lat = GPSUtils.DmmToDecimal(latRaw, 2); - double lon = GPSUtils.DmmToDecimal(lonRaw, 3); - if (!double.IsInfinity(lat) && !double.IsNaN(lat) && !double.IsInfinity(lon) && !double.IsNaN(lon)) + return true; + } + + if (!sentenca.StartsWith("$", StringComparison.Ordinal)) + return false; + + int asterisco = sentenca.LastIndexOf('*'); + + /* + * Alguns ACKs e firmwares podem não fornecer checksum. + * Sentenças de navegação sem '*' são aceitas por compatibilidade, + * mas as que possuem checksum são verificadas rigorosamente. + */ + if (asterisco < 0) + return true; + + if (asterisco + 2 >= sentenca.Length) + return false; + + byte calculado = 0; + + for (int i = 1; i < asterisco; i++) + calculado ^= (byte)sentenca[i]; + + byte informado; + + return byte.TryParse( + sentenca.Substring(asterisco + 1, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out informado + ) && + calculado == informado; + } + + private static void ProcessarDadosNMEA(string sentenca) + { + DateTime agora = DateTime.Now; + + if (sentenca.StartsWith("$GPGGA")) + { + ProcessarGPGGA(sentenca); + AtualizarCoordenadasGPS(); + MarkValidGga(); + } + else if (sentenca.StartsWith("$GNGGA") || + sentenca.StartsWith("$GLGGA")) + { + ProcessarGNGGA(sentenca); + AtualizarCoordenadasGPS(); + MarkValidGga(); + } + else if (sentenca.StartsWith("$GNRMC")) + { + ProcessarGxRMC(sentenca); + } + else if (sentenca.StartsWith("$GNVTG") || + sentenca.StartsWith("$GPVTG")) + { + ProcessarGNVTG(sentenca); + } + else if (sentenca.StartsWith("$GPGSV") || + sentenca.StartsWith("$GLGSV") || + sentenca.StartsWith("$GBGSV") || + sentenca.StartsWith("$GAGSV")) + { + ProcessarGSV(sentenca); + } + else if (sentenca.StartsWith("$GNTHS") || + sentenca.StartsWith("$GPTHS") || + sentenca.StartsWith("$GATHS")) + { + ProcessarGNTHS(sentenca); + + Interlocked.Exchange( + ref _lastValidThsMono, + Stopwatch.GetTimestamp() + ); + } + 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); + } + else if (sentenca.StartsWith( + "$command,", + StringComparison.OrdinalIgnoreCase)) + { + Variaveis.MostrarLog( + "[GPSService.ProcessarDadosNMEA] " + sentenca + ); + } + + PenultimaLeitura.UltimoComandoRespondido = + UltimaLeitura.UltimoComandoRespondido; + + UltimaLeitura.UltimoComandoRespondido = agora; + } + + private static void MarkValidGga() + { + Interlocked.Exchange( + ref _lastValidGgaMono, + Stopwatch.GetTimestamp() + ); + } + + private static void ProcessarGPGGA(string sentenca) + { + string[] parts = sentenca.Split(','); + + if (parts.Length < 10) + throw new FormatException("GPGGA incompleta."); + + CopiarPosicaoParaPenultima(); + + UltimaLeitura.TimestampPos.valor = + Stopwatch.GetTimestamp() / + (double)Stopwatch.Frequency; + + UltimaLeitura.Momento = DateTime.Now; + + double lat; + double lon; + + if (TryParseDmm(parts[2], parts[3], 2, out lat) && + TryParseDmm(parts[4], parts[5], 3, out lon)) + { + UltimaLeitura.LatitudeAnt = lat; + UltimaLeitura.LongitudeAnt = lon; + AplicarPosicaoCorrigida(); + } + + int numSat; + double hdop; + double alt; + + UltimaLeitura.NumeroSatelites = + int.TryParse(parts[7], out numSat) ? numSat : 0; + + UltimaLeitura.PrecisaoHorizontal = + double.TryParse( + parts[8], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out hdop + ) ? hdop : 0; + + UltimaLeitura.Altitude = + double.TryParse( + parts[9], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out alt + ) ? alt : 0; + } + + private static void ProcessarGNGGA(string sentenca) + { + string[] campos = sentenca.Split(','); + + if (campos.Length < 10) + throw new FormatException("GNGGA incompleta."); + + string horaUtc = GetField(campos, 1); + string latRaw = GetField(campos, 2); + string latHem = GetField(campos, 3); + string lonRaw = GetField(campos, 4); + string lonHem = GetField(campos, 5); + string qualidadeRaw = GetField(campos, 6, "0"); + string satRaw = GetField(campos, 7, "0"); + string hdopRaw = GetField(campos, 8, "99.9"); + string altRaw = GetField(campos, 9, "0"); + string geoidRaw = GetField(campos, 11, "0"); + string idadeRaw = GetField(campos, 13); + string baseId = StripChecksum(GetField(campos, 14)); + + double latitude = 0; + double longitude = 0; + bool possuiLat = TryParseDmm( + latRaw, + latHem, + 2, + out latitude + ); + bool possuiLon = TryParseDmm( + lonRaw, + lonHem, + 3, + out longitude + ); + + double altMsl; + double geoid; + double hdop; + int sats; + int fixCode; + + double.TryParse( + altRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out altMsl + ); + + double.TryParse( + geoidRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out geoid + ); + + double.TryParse( + hdopRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out hdop + ); + + int.TryParse(satRaw, out sats); + int.TryParse(qualidadeRaw, out fixCode); + + double idadeCorrecao = -1; + double idadeValida; + + if (!string.IsNullOrWhiteSpace(idadeRaw) && + double.TryParse( + idadeRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out idadeValida)) + { + idadeCorrecao = idadeValida; + } + + CopiarPosicaoParaPenultima(); + + UltimaLeitura.TimestampPos.valor = + Stopwatch.GetTimestamp() / + (double)Stopwatch.Frequency; + + UltimaLeitura.Momento = DateTime.Now; + + if (possuiLat && possuiLon) + { + UltimaLeitura.LatitudeAnt = latitude; + UltimaLeitura.LongitudeAnt = longitude; + } + + UltimaLeitura.Altitude = altMsl; + UltimaLeitura.AltitudeElipsoidal = altMsl + geoid; + UltimaLeitura.PrecisaoHorizontal = hdop; + UltimaLeitura.NumeroSatelites = sats; + UltimaLeitura.QualidadeFix = (TiposCorrecaoGPS)fixCode; + UltimaLeitura.IdadeCorrecao = idadeCorrecao; + UltimaLeitura.BaseID = baseId; + + if (possuiLat && possuiLon) + AplicarPosicaoCorrigida(); + + DateTime dataHora; + + if (TryParseNmeaTime(horaUtc, out dataHora)) + UltimaLeitura.DataHora = dataHora; + + if (!UltimaLeitura.EnuOriginSet && + UltimaLeitura.QualidadeFix == + TiposCorrecaoGPS.RTKFixo && + possuiLat && + possuiLon) + { + UltimaLeitura.Lat0 = latitude; + UltimaLeitura.Lon0 = longitude; + UltimaLeitura.EnuOriginSet = true; + } + } + + private static void CopiarPosicaoParaPenultima() + { + PenultimaLeitura.TimestampPos = + UltimaLeitura.TimestampPos.Clone(); + + PenultimaLeitura.Momento = UltimaLeitura.Momento; + PenultimaLeitura.Latitude = UltimaLeitura.Latitude; + PenultimaLeitura.Longitude = UltimaLeitura.Longitude; + PenultimaLeitura.LatitudeAnt = UltimaLeitura.LatitudeAnt; + PenultimaLeitura.LongitudeAnt = UltimaLeitura.LongitudeAnt; + PenultimaLeitura.Altitude = UltimaLeitura.Altitude; + PenultimaLeitura.AltitudeElipsoidal = + UltimaLeitura.AltitudeElipsoidal; + + PenultimaLeitura.PrecisaoHorizontal = + UltimaLeitura.PrecisaoHorizontal; + + PenultimaLeitura.NumeroSatelites = + UltimaLeitura.NumeroSatelites; + + PenultimaLeitura.QualidadeFix = + UltimaLeitura.QualidadeFix; + + PenultimaLeitura.DataHora = UltimaLeitura.DataHora; + PenultimaLeitura.IdadeCorrecao = + UltimaLeitura.IdadeCorrecao; + + PenultimaLeitura.BaseID = UltimaLeitura.BaseID; + } + + private static void ProcessarGNVTG(string sentenca) + { + string[] campos = sentenca.Split(','); + + if (campos.Length < 8) + throw new FormatException("VTG incompleta."); + + double curso; + double velocidadeKmh; + + double.TryParse( + campos[1], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out curso + ); + + double.TryParse( + campos[7], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out velocidadeKmh + ); + + PenultimaLeitura.Momento = UltimaLeitura.Momento; + PenultimaLeitura.CursoVerdadeiro = + UltimaLeitura.CursoVerdadeiro; + + PenultimaLeitura.Velocidade = + UltimaLeitura.Velocidade; + + UltimaLeitura.Momento = DateTime.Now; + UltimaLeitura.CursoVerdadeiro = curso; + UltimaLeitura.Velocidade = velocidadeKmh; + } + + private static void ProcessarGSV(string sentenca) + { + string[] campos = sentenca.Split(','); + + if (campos.Length < 4 || sentenca.Length < 3) + throw new FormatException("GSV incompleta."); + + string tipoSistema = sentenca.Substring(1, 2); + + int sentAtual; + int sentTotal; + int visiveis; + + int.TryParse(campos[2], out sentAtual); + int.TryParse(campos[1], out sentTotal); + int.TryParse(campos[3], out visiveis); + + if (UltimaLeitura.SatelitesEmVista == null) + { + UltimaLeitura.SatelitesEmVista = + new List(); + } + + PenultimaLeitura.Momento = UltimaLeitura.Momento; + PenultimaLeitura.SatelitesEmVista = + new List( + UltimaLeitura.SatelitesEmVista + ); + + UltimaLeitura.Momento = DateTime.Now; + + GPSSatelitesEmVistaModel leitura = + UltimaLeitura.SatelitesEmVista + .FirstOrDefault(x => x.TipoSistema == tipoSistema); + + if (leitura == null) + { + leitura = new GPSSatelitesEmVistaModel { - if (latHem == "S") lat = -lat; - if (lonHem == "W") lon = -lon; + TipoSistema = tipoSistema, + Sentencas = + new List() + }; - // mantém histórico - PenultimaLeitura.Latitude = UltimaLeitura.Latitude; - PenultimaLeitura.Longitude = UltimaLeitura.Longitude; - PenultimaLeitura.LatitudeAnt = UltimaLeitura.LatitudeAnt; - PenultimaLeitura.LongitudeAnt = UltimaLeitura.LongitudeAnt; - - UltimaLeitura.LatitudeAnt = lat; - UltimaLeitura.LongitudeAnt = lon; - - AplicarPosicaoCorrigida(); - } + UltimaLeitura.SatelitesEmVista.Add(leitura); } - // atualiza hora se veio no frame - if (!string.IsNullOrEmpty(horaUTC) && horaUTC.Length >= 6) + GPSSatelitesEmVistaSentencaModel parte = + leitura.Sentencas.FirstOrDefault( + x => x.SentencaAtual == sentAtual + ); + + if (parte == null) { - // hhmmss(.ss) - var hh = horaUTC.Substring(0, 2); - var mm = horaUTC.Substring(2, 2); - var ss = horaUTC.Substring(4); - if (TimeSpan.TryParseExact($"{hh}:{mm}:{ss}", @"hh\:mm\:ss\.ff", - CultureInfo.InvariantCulture, out var tod) - || TimeSpan.TryParseExact($"{hh}:{mm}:{ss}", @"hh\:mm\:ss", - CultureInfo.InvariantCulture, out tod)) + parte = new GPSSatelitesEmVistaSentencaModel { - UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime(); - } + SentencaAtual = sentAtual, + SentencasTotal = sentTotal, + QuantidadeSatelites = visiveis, + Dados = + new List() + }; + + leitura.Sentencas.Add(parte); } - // mapeia “mode” (se existir) para sua enum, sem sobrepor GGA melhor - // Ex.: A=Autonomous(1), D=DGPS(2), R=RTK Fix(4), F=RTK Float(5) - if (!string.IsNullOrEmpty(mode)) + parte.Dados = + new List(); + + for (int i = 4; i + 3 < campos.Length; i += 4) { - if (mode == "R") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.RTKFixo; - else if (mode == "F") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.RTKFlutuante; - else if (mode == "D") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.DGPS; - else if (mode == "E") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.DeadReckoing; - else if (mode == "A") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.Autonomo; - else if (mode == "N") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.SemCorrecao; - else UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.SemCorrecao; + double elevacao; + double azimute; + double snr; + + double.TryParse( + campos[i + 1], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out elevacao + ); + + double.TryParse( + campos[i + 2], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out azimute + ); + + double.TryParse( + StripChecksum(campos[i + 3]), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out snr + ); + + parte.Dados.Add( + new GPSSatelitesEmVistaDadosModel + { + PRN = campos[i], + Elevacao = elevacao, + Azimute = azimute, + QualidadeSinal = snr + } + ); + } + } + + private static void ProcessarGNTHS(string sentenca) + { + string conteudo = sentenca.Trim(); + + if (conteudo.StartsWith("$")) + conteudo = conteudo.Substring(1); + + string[] partesChecksum = conteudo.Split( + new[] { '*' }, + 2 + ); + + string[] campos = partesChecksum[0].Split(','); + + if (campos.Length < 3) + throw new FormatException("THS incompleta."); + + string headingTexto = + (campos[1] ?? string.Empty).Trim(); + + string status = + (campos[2] ?? string.Empty) + .Trim() + .ToUpperInvariant(); + + double headingTrue; + + bool numerico = double.TryParse( + headingTexto, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out headingTrue + ); + + bool valido = + status == "A" && + numerico && + !double.IsNaN(headingTrue) && + !double.IsInfinity(headingTrue); + + PenultimaLeitura.TimestampOri.valor = + UltimaLeitura.TimestampOri.valor; + + PenultimaLeitura.Momento = + UltimaLeitura.Momento; + + PenultimaLeitura.OrientacaoReal = + UltimaLeitura.OrientacaoReal; + + PenultimaLeitura.TipoOrientacao = + UltimaLeitura.TipoOrientacao; + + UltimaLeitura.TimestampOri.valor = + Stopwatch.GetTimestamp() / + (double)Stopwatch.Frequency; + + UltimaLeitura.Momento = DateTime.Now; + + if (!valido) + { + UltimaLeitura.OrientacaoReal = 9999; + UltimaLeitura.TipoOrientacao = "V"; + return; } - AtualizarCoordenadasGPS(); + headingTrue = GPSUtils.NormalizarAngulo(headingTrue); + + UltimaLeitura.OrientacaoReal = + InverterHeading + ? GPSUtils.NormalizarAngulo( + headingTrue - 180.0 + ) + : headingTrue; + + UltimaLeitura.TipoOrientacao = "A"; + + AplicarPosicaoCorrigida(); + DefinirAnguloCarro(); } private static void ProcessarGxGSA(string sentenca) { - // aceita $GPGSA, $GNGSA, $GLGSA, $GAGSA, $BDGSA… - var campos = sentenca.Split(','); - if (campos.Length < 17) return; + string[] campos = sentenca.Split(','); + + if (campos.Length < 17) + return; + + TiposDimensaoCorrecaoGPS modoSolucao = + (TiposDimensaoCorrecaoGPS) + GPSUtils.ParseInt(campos[2]); - string modoSelecao = campos[1]; // M/A - TiposDimensaoCorrecaoGPS modoSolucao =(TiposDimensaoCorrecaoGPS)GPSUtils.ParseInt(campos[2]); // 1/2/3 - // satélites usados: campos[3]..campos[14] int satsUsados = 0; + for (int i = 3; i <= 14 && i < campos.Length; i++) - if (!string.IsNullOrWhiteSpace(campos[i])) satsUsados++; + { + if (!string.IsNullOrWhiteSpace(campos[i])) + satsUsados++; + } double pdop = GPSUtils.ParseDouble(campos[15]); double hdop = GPSUtils.ParseDouble(campos[16]); - double vdop = (campos.Length > 17) ? GPSUtils.ParseDouble(campos[17].Split('*')[0]) : double.NaN; - // Atualiza apenas o que faz sentido complementar - if (!double.IsInfinity(hdop) && !double.IsNaN(hdop)) UltimaLeitura.PrecisaoHorizontal = hdop; - if (satsUsados > 0) UltimaLeitura.NumeroSatelites = Math.Max(UltimaLeitura.NumeroSatelites, satsUsados); + double vdop = campos.Length > 17 + ? GPSUtils.ParseDouble( + StripChecksum(campos[17]) + ) + : double.NaN; - // Se você quiser guardar os DOPs: - UltimaLeitura.PDOP = !double.IsInfinity(pdop) && !double.IsNaN(pdop) ? pdop : UltimaLeitura.PDOP; - UltimaLeitura.VDOP = !double.IsInfinity(vdop) && !double.IsNaN(vdop) ? vdop : UltimaLeitura.VDOP; + if (!double.IsInfinity(hdop) && + !double.IsNaN(hdop)) + { + UltimaLeitura.PrecisaoHorizontal = hdop; + } - // Mapeia modoSolucao (não é igual ao “fix quality” do GGA!) - // 1=NoFix, 2=Fix2D, 3=Fix3D — pode guardar num campo próprio se tiver - UltimaLeitura.FixDimensao = modoSolucao; // crie int FixDimensao na sua struct, se não existir + if (satsUsados > 0) + { + UltimaLeitura.NumeroSatelites = + Math.Max( + UltimaLeitura.NumeroSatelites, + satsUsados + ); + } + + if (!double.IsInfinity(pdop) && + !double.IsNaN(pdop)) + { + UltimaLeitura.PDOP = pdop; + } + + if (!double.IsInfinity(vdop) && + !double.IsNaN(vdop)) + { + UltimaLeitura.VDOP = vdop; + } + + UltimaLeitura.FixDimensao = modoSolucao; } private static void ProcessarGxRMC(string sentenca) { - // Aceita $GPRMC, $GNRMC, $GLRMC, $GARMC, $BDRMC... - var raw = sentenca; - var campos = raw.Split(','); + string[] campos = sentenca.Split(','); - if (campos.Length < 12) return; + if (campos.Length < 10) + throw new FormatException("RMC incompleta."); - string timeUTC = campos[1]; // hhmmss.ss - string status = campos[2]; // A=ativo, V=inválido - string latRaw = campos[3]; - string latHem = campos[4]; - string lonRaw = campos[5]; - string lonHem = campos[6]; - string spdKtsS = campos[7]; // knots - string cogS = campos[8]; // course over ground (graus) - string date = campos[9]; // ddmmyy - string magVarS = campos[10]; // pode estar vazio - string magHem = campos.Length > 11 ? campos[11] : ""; - // mode pode vir no campo 12 (sem checksum) ou 12 com outro e 13 com checksum, depende do firmware - string mode = ""; - if (campos.Length > 12) + string timeUtc = GetField(campos, 1); + string status = GetField(campos, 2); + string latRaw = GetField(campos, 3); + string latHem = GetField(campos, 4); + string lonRaw = GetField(campos, 5); + string lonHem = GetField(campos, 6); + string spdKnotsRaw = GetField(campos, 7); + string cogRaw = GetField(campos, 8); + string dateRaw = GetField(campos, 9); + string magRaw = GetField(campos, 10); + string magHem = StripChecksum(GetField(campos, 11)); + + string mode = campos.Length > 12 + ? StripChecksum(campos[12]).Trim() + : string.Empty; + + bool valido = + string.Equals( + status, + "A", + StringComparison.OrdinalIgnoreCase + ); + + double lat; + double lon; + + if (valido && + TryParseDmm(latRaw, latHem, 2, out lat) && + TryParseDmm(lonRaw, lonHem, 3, out lon)) { - var tmp = campos[12]; - // tira checksum se estiver grudado - int asterix = tmp.IndexOf('*'); - mode = (asterix >= 0 ? tmp.Substring(0, asterix) : tmp).Trim(); - // alguns mandam mais um campo (navStatus) e o checksum só no final - if (mode.Length == 0 && campos.Length > 13) - { - tmp = campos[13]; - asterix = tmp.IndexOf('*'); - mode = (asterix >= 0 ? tmp.Substring(0, asterix) : tmp).Trim(); - } + PenultimaLeitura.Latitude = + UltimaLeitura.Latitude; + + PenultimaLeitura.Longitude = + UltimaLeitura.Longitude; + + PenultimaLeitura.LatitudeAnt = + UltimaLeitura.LatitudeAnt; + + PenultimaLeitura.LongitudeAnt = + UltimaLeitura.LongitudeAnt; + + UltimaLeitura.LatitudeAnt = lat; + UltimaLeitura.LongitudeAnt = lon; + + AplicarPosicaoCorrigida(); } - bool valido = status == "A"; + double spdKnots; - // Converte lat/lon se válidos - if (valido && !string.IsNullOrWhiteSpace(latRaw) && !string.IsNullOrWhiteSpace(lonRaw)) + if (double.TryParse( + spdKnotsRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out spdKnots)) { - if (latRaw.Length >= 4 && lonRaw.Length >= 5) - { - double lat = GPSUtils.DmmToDecimal(latRaw, 2); - double lon = GPSUtils.DmmToDecimal(lonRaw, 3); - if (!double.IsNaN(lat) && !double.IsInfinity(lat) && !double.IsNaN(lon) && !double.IsInfinity(lon)) - { - if (latHem == "S") lat = -lat; - if (lonHem == "W") lon = -lon; - - PenultimaLeitura.Latitude = UltimaLeitura.Latitude; - PenultimaLeitura.Longitude = UltimaLeitura.Longitude; - PenultimaLeitura.LatitudeAnt = UltimaLeitura.LatitudeAnt; - PenultimaLeitura.LongitudeAnt = UltimaLeitura.LongitudeAnt; - - UltimaLeitura.LatitudeAnt = lat; - UltimaLeitura.LongitudeAnt = lon; - - AplicarPosicaoCorrigida(); - } - } + /* + * Mantém a semântica histórica do projeto: + * o campo recebe o valor da sentença em knots. + */ + UltimaLeitura.Velocidade = spdKnots; } - // Velocidade (knots -> m/s e km/h, se quiser guardar) - if (double.TryParse(spdKtsS, NumberStyles.Float, CultureInfo.InvariantCulture, out double spdKts)) - { - UltimaLeitura.Velocidade = spdKts; - } + double cog; - // Course over ground (graus) - if (double.TryParse(cogS, NumberStyles.Float, CultureInfo.InvariantCulture, out double cog)) + if (double.TryParse( + cogRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out cog)) + { UltimaLeitura.CursoVerdadeiro = cog; + } - // Data/hora (UTC) - // timeUTC: hhmmss(.ss), date: ddmmyy - DateTime? dt = null; - if (!string.IsNullOrEmpty(timeUTC) && timeUTC.Length >= 6 && !string.IsNullOrEmpty(date) && date.Length == 6) + DateTime dataHora; + + if (TryParseRmcDateTime( + dateRaw, + timeUtc, + out dataHora)) { - string hh = timeUTC.Substring(0, 2); - string mm = timeUTC.Substring(2, 2); - string ss = timeUTC.Substring(4, 2); + UltimaLeitura.DataHora = + dataHora.ToLocalTime(); + } - string dd = date.Substring(0, 2); - string MM = date.Substring(2, 2); - string yy = date.Substring(4, 2); + double mag; - // yy -> 20yy (assumindo 2000+; ajuste se precisar 19xx) - int year = 2000 + int.Parse(yy, CultureInfo.InvariantCulture); - if (int.TryParse(dd, out int d) && int.TryParse(MM, out int M) && - int.TryParse(hh, out int H) && int.TryParse(mm, out int m) && int.TryParse(ss, out int s)) + if (double.TryParse( + magRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out mag)) + { + if (string.Equals( + magHem, + "W", + StringComparison.OrdinalIgnoreCase)) { - try - { - dt = new DateTime(year, M, d, H, m, s, DateTimeKind.Utc); - } - catch { /* ignora datas inválidas */ } + mag = -mag; } - } - if (dt.HasValue) UltimaLeitura.DataHora = dt.Value.ToLocalTime(); - // Variação magnética (se quiser armazenar) - if (double.TryParse(magVarS, NumberStyles.Float, CultureInfo.InvariantCulture, out double magVar)) - { - if (magHem == "W") magVar = -magVar; - UltimaLeitura.VariacaoMagnetica = magVar; - } - - // Mode → promove QualidadeFix (não rebaixa) - // A=Autônomo, D=DGPS, R=RTK Fix, F=RTK Float, N=No Fix - if (!string.IsNullOrEmpty(mode)) - { - if (mode == "R") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.RTKFixo; - else if (mode == "F") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.RTKFlutuante; - else if (mode == "D") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.DGPS; - else if (mode == "E") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.DeadReckoing; - else if (mode == "A") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.Autonomo; - else if (mode == "N") UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.SemCorrecao; - else UltimaLeitura.QualidadeFix = TiposCorrecaoGPS.SemCorrecao; + UltimaLeitura.VariacaoMagnetica = mag; } + AplicarModoRmc(mode); AtualizarCoordenadasGPS(); } - - private static async Task AplicarCorrecaoRTK_Ntrip() + private static void AplicarModoRmc(string mode) { - if (!Iniciado || LoopRTK_Ntrip || !APIService.HasInternet) + if (string.IsNullOrWhiteSpace(mode)) return; - LoopRTK_Ntrip = true; + switch (mode.Trim().ToUpperInvariant()) + { + case "R": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.RTKFixo; + break; - // Configurações do NTRIP caster para RTK2Go - string host = "gps-ntrip.ibge.gov.br"; - int port = 2101; - string mountpoint = "EESC0"; - string username = "Zendion"; // Geralmente vazio para RTK2Go - string password = "QD&m1p60"; // Geralmente vazio para RTK2Go + case "F": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.RTKFlutuante; + break; - while (CorrecaoRTK_Ntrip && APIService.HasInternet) // Loop para reconectar em caso de falha + case "D": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.DGPS; + break; + + case "E": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.DeadReckoing; + break; + + case "A": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.Autonomo; + break; + + case "N": + UltimaLeitura.QualidadeFix = + TiposCorrecaoGPS.SemCorrecao; + break; + } + } + + // ============================================================ + // RTCM VIA MQTT / FILA DE ESCRITA + // ============================================================ + + public static void AplicarCorrecaoRTK_Mqtt( + byte[] correcao, + int bytesRead) + { + if (correcao == null || + bytesRead <= 0 || + bytesRead > correcao.Length || + bytesRead > RtcmMaxPayloadBytes) + { + Interlocked.Increment(ref _rtcmDroppedInvalid); + return; + } + + byte[] copia = new byte[bytesRead]; + Buffer.BlockCopy(correcao, 0, copia, 0, bytesRead); + + EnfileirarRtcm(copia, RtcmSource.Mqtt); + } + + private static void EnfileirarRtcm( + byte[] bytes, + RtcmSource source) + { + EnsureBackgroundWorkersStarted(); + + long agora = Stopwatch.GetTimestamp(); + + Interlocked.Increment(ref _rtcmReceived); + Interlocked.Exchange(ref _lastRtcmReceivedMono, agora); + + var envelope = new RtcmEnvelope + { + Bytes = bytes, + ReceivedMonotonic = agora, + ReceivedUtc = DateTime.UtcNow, + Sequence = Interlocked.Increment( + ref _rtcmSequence + ), + Source = source, + MessageType = TryGetRtcmMessageType(bytes) + }; + + lock (_rtcmQueueLock) + { + while (_rtcmQueue.Count >= RtcmQueueCapacity) + { + _rtcmQueue.Dequeue(); + Interlocked.Increment( + ref _rtcmDroppedQueue + ); + } + + _rtcmQueue.Enqueue(envelope); + Interlocked.Increment(ref _rtcmQueued); + } + + try { _rtcmSignal.Release(); } + catch (SemaphoreFullException) { } + } + + private static async Task RtcmWriterLoopAsync( + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) { try { - // Construa o cabeçalho da solicitação - string credentials = string.IsNullOrEmpty(username) - ? "" - : Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); + await _rtcmSignal.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } - string request = $"GET /{mountpoint} HTTP/1.0\r\n" + - $"User-Agent: NTRIP Client/2.0\r\n" + - $"Accept: */*\r\n" + - $"Connection: keep-alive\r\n" + - (!string.IsNullOrEmpty(credentials) ? $"Authorization: Basic {credentials}\r\n" : "") + - "\r\n"; + while (true) + { + RtcmEnvelope envelope = null; - // Estabeleça a conexão - using (TcpClient client = new TcpClient(host, port)) - using (NetworkStream stream = client.GetStream()) - using (StreamWriter writer = new StreamWriter(stream, Encoding.ASCII)) + lock (_rtcmQueueLock) { - writer.Write(request); - writer.Flush(); + if (_rtcmQueue.Count > 0) + envelope = _rtcmQueue.Dequeue(); + } - // Leia a resposta - using (StreamReader reader = new StreamReader(stream, Encoding.ASCII)) + if (envelope == null) + break; + + double idadeMs = GetAgeMs( + envelope.ReceivedMonotonic + ); + + if (idadeMs > RtcmMaxAgeMs) + { + Interlocked.Increment( + ref _rtcmDroppedStale + ); + continue; + } + + if (!Iniciado) + { + Interlocked.Increment( + ref _rtcmDroppedQueue + ); + continue; + } + + try + { + await _serialWriteLock + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + try { - string response = await reader.ReadLineAsync(); - if (CorrecaoRTK_Ntrip) - { - if ((response ?? "").Contains("200 OK")) - { - Variaveis.MostrarLog($"[GPSService.AplicarCorrecaoRTK_Ntrip] Conexão bem-sucedida ao mountpoint!"); + /* + * Verifica novamente depois de esperar o lock. + * A configuração pode ter ocupado a serial. + */ + idadeMs = GetAgeMs( + envelope.ReceivedMonotonic + ); - byte[] buffer = new byte[4096]; - int bytesRead; - while (CorrecaoRTK_Ntrip && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - try - { - if (PortaGPS?.IsOpen ?? false) - { - PortaGPS.Write(buffer, 0, bytesRead); // Envia os dados RTCM para o GPS - //Console.WriteLine($"Enviando {bytesRead} bytes de correção RTCM para o GPS"); - UltimoEnvioCorrecaoRTK = DateTime.Now; - } - } - catch (Exception ex) - { - Variaveis.MostrarLog($"[GPSService.AplicarCorrecaoRTK_Ntrip] Erro ao enviar correção RTCM para o módulo GNSS: {ex.Message}"); - } - } - } - else - { - Variaveis.MostrarLog($"[GPSService.AplicarCorrecaoRTK_Ntrip] Falha na conexão com NTRIP: {response}"); - await Task.Delay(5000); // Aguarde antes de tentar novamente - break; - } + if (idadeMs > RtcmMaxAgeMs) + { + Interlocked.Increment( + ref _rtcmDroppedStale + ); + continue; } + + WriteSerialUnsafe( + envelope.Bytes, + envelope.Bytes.Length + ); + + UltimoEnvioCorrecaoRTK = + DateTime.Now; + + Interlocked.Exchange( + ref _lastRtcmWrittenMono, + Stopwatch.GetTimestamp() + ); + + Interlocked.Increment( + ref _rtcmWritten + ); + } + finally + { + _serialWriteLock.Release(); } } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + Interlocked.Increment( + ref _rtcmWriteErrors + ); + + RecordError( + "[GPSService.RtcmWriter] " + + ex.Message + ); + } } + } + } + + private static int TryGetRtcmMessageType(byte[] bytes) + { + /* + * RTCM3: + * D3 | 6 bits reservados + 10 bits length | payload + * Os 12 primeiros bits do payload formam o message type. + */ + if (bytes == null || + bytes.Length < 5 || + bytes[0] != 0xD3) + { + return -1; + } + + return ((bytes[3] << 4) | (bytes[4] >> 4)) + & 0x0FFF; + } + + // ============================================================ + // NTRIP + // ============================================================ + + public static async Task IniciarNtripAsync() + { + if (Variaveis.IsAgroMonitor || + !CorrecaoRTK_Ntrip) + { + return; + } + + lock (_workerLock) + { + if (_ntripTask != null && + !_ntripTask.IsCompleted) + { + return; + } + + _ntripCts = new CancellationTokenSource(); + _ntripTask = Task.Run( + () => AplicarCorrecaoRTK_Ntrip( + _ntripCts.Token + ) + ); + } + + await Task.CompletedTask; + } + + public static async Task PararNtripAsync() + { + CancellationTokenSource cts; + Task task; + TcpClient client; + + lock (_workerLock) + { + cts = _ntripCts; + task = _ntripTask; + client = _ntripClient; + + _ntripCts = null; + _ntripTask = null; + _ntripClient = null; + } + + CorrecaoRTK_Ntrip = false; + + if (cts != null) + { + try { cts.Cancel(); } catch { } + } + + if (client != null) + { + try { client.Close(); } catch { } + try { client.Dispose(); } catch { } + } + + if (task != null) + { + try { await task.ConfigureAwait(false); } + catch (OperationCanceledException) { } catch (Exception ex) { - Variaveis.MostrarLog($"[GPSService.AplicarCorrecaoRTK_Ntrip] Erro na conexão RTK: {ex.Message}"); - await Task.Delay(5000); // Aguarde antes de tentar novamente + RecordError( + "[GPSService.PararNtripAsync] " + + ex.Message + ); } } - LoopRTK_Ntrip = false; + if (cts != null) + cts.Dispose(); } - public static void AplicarCorrecaoRTK_Mqtt(byte[] correcao, int bytesRead) + private static async Task AplicarCorrecaoRTK_Ntrip( + CancellationToken cancellationToken) { - if (PortaGPS?.IsOpen ?? false) + if (!Iniciado || + LoopRTK_Ntrip || + !APIService.HasInternet) { - PortaGPS.Write(correcao, 0, bytesRead); - UltimoEnvioCorrecaoRTK = DateTime.Now; + 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 = "QD&m1p60"; + //Environment.GetEnvironmentVariable( + // "AGRO_NTRIP_PASSWORD" + //) ?? string.Empty; + + int backoffMs = 1000; + var random = new Random(); + + try + { + while (!cancellationToken.IsCancellationRequested && + CorrecaoRTK_Ntrip && + APIService.HasInternet) + { + TcpClient client = null; + + try + { + client = new TcpClient(); + + 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, + cancellationToken + ).ConfigureAwait(false); + + NtripHeaderResult header = + await ReadNtripHeaderAsync( + stream, + cancellationToken + ).ConfigureAwait(false); + + if (!header.Success) + { + throw new IOException( + "Resposta NTRIP inválida: " + + header.StatusLine + ); + } + + Variaveis.MostrarLog( + "[GPSService.NTRIP] Conectado a " + + host + "/" + mountpoint + ); + + backoffMs = 1000; + + if (header.RemainingBytes != null && + header.RemainingBytes.Length > 0) + { + Interlocked.Add( + ref _ntripBytesReceived, + header.RemainingBytes.Length + ); + + EnfileirarRtcm( + header.RemainingBytes, + RtcmSource.Ntrip + ); + } + + byte[] buffer = new byte[4096]; + + while (!cancellationToken + .IsCancellationRequested && + CorrecaoRTK_Ntrip) + { + 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 + ); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Interlocked.Increment( + ref _ntripErrors + ); + + RecordError( + "[GPSService.NTRIP] " + ex.Message + ); + } + finally + { + lock (_workerLock) + { + if (ReferenceEquals( + _ntripClient, + client)) + { + _ntripClient = null; + } + } + + if (client != null) + { + try { client.Close(); } catch { } + try { client.Dispose(); } catch { } + } + } + + if (cancellationToken.IsCancellationRequested || + !CorrecaoRTK_Ntrip) + { + break; + } + + Interlocked.Increment(ref _ntripReconnects); + + int jitter = random.Next(0, 350); + + await Task.Delay( + backoffMs + jitter, + cancellationToken + ).ConfigureAwait(false); + + backoffMs = Math.Min( + 30000, + (int)(backoffMs * 1.8) + ); + } + } + finally + { + LoopRTK_Ntrip = false; } } + private static async Task + ReadNtripHeaderAsync( + NetworkStream stream, + CancellationToken cancellationToken) + { + var bytes = new List(2048); + byte[] buffer = new byte[512]; - private static bool PossuiHeadingValido(GPSModel leitura) + while (bytes.Count < 16 * 1024) + { + int read = await stream.ReadAsync( + buffer, + 0, + buffer.Length, + cancellationToken + ).ConfigureAwait(false); + + if (read <= 0) + break; + + for (int i = 0; i < read; i++) + bytes.Add(buffer[i]); + + int headerEnd = FindHeaderEnd(bytes); + + if (headerEnd >= 0) + { + byte[] all = bytes.ToArray(); + string headerText = Encoding.ASCII.GetString( + all, + 0, + headerEnd + ); + + string statusLine = headerText + .Split(new[] { "\r\n" }, + StringSplitOptions.None) + .FirstOrDefault() ?? string.Empty; + + bool success = + statusLine.IndexOf( + "200", + StringComparison.OrdinalIgnoreCase + ) >= 0 || + statusLine.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 + { + Success = success, + StatusLine = statusLine, + RemainingBytes = remaining + }; + } + } + + return new NtripHeaderResult + { + Success = false, + StatusLine = "Cabeçalho NTRIP incompleto.", + RemainingBytes = new byte[0] + }; + } + + private static int FindHeaderEnd(List bytes) + { + for (int i = 3; i < bytes.Count; i++) + { + if (bytes[i - 3] == 13 && + bytes[i - 2] == 10 && + bytes[i - 1] == 13 && + bytes[i] == 10) + { + return i - 3; + } + } + + return -1; + } + + // ============================================================ + // SNAPSHOT E ESTADO GNSS + // ============================================================ + + public static GPSModel GetSnapshot() + { + lock (_stateLock) + return UltimaLeitura.Clone(); + } + + public static GPSModel GetPreviousSnapshot() + { + lock (_stateLock) + return PenultimaLeitura.Clone(); + } + + private static bool PossuiHeadingValido( + GPSModel leitura) { if (leitura == null) return false; @@ -1152,7 +2147,7 @@ namespace AgroBase.Services return; } - (double latCor, double lonCorr) = + var corrigida = LeverArm.FixLeverArmLatLon_Fast( UltimaLeitura.LatitudeAnt, UltimaLeitura.LongitudeAnt, @@ -1160,213 +2155,449 @@ namespace AgroBase.Services UltimaLeitura.TimestampOri.frequencia ); - UltimaLeitura.Latitude = latCor; - UltimaLeitura.Longitude = lonCorr; + UltimaLeitura.Latitude = corrigida.lat; + UltimaLeitura.Longitude = corrigida.lon; } public static void AtualizarCoordenadasGPS() { - PenultimaLeitura.Ntrip_ativado = UltimaLeitura.Ntrip_ativado; - PenultimaLeitura.Heartbeat = UltimaLeitura.Heartbeat; - PenultimaLeitura.LeverArmFrontal = UltimaLeitura.LeverArmFrontal; - PenultimaLeitura.LeverArmLateral = UltimaLeitura.LeverArmLateral; + PenultimaLeitura.Ntrip_ativado = + UltimaLeitura.Ntrip_ativado; - UltimaLeitura.Ntrip_ativado = CorrecaoRTK_Ntrip; - UltimaLeitura.Heartbeat = (UltimaLeitura.Heartbeat + 1) % 10; - UltimaLeitura.LeverArmFrontal = LeverArm?.FrontalTotalCm ?? 0; - UltimaLeitura.LeverArmLateral = LeverArm?.LateralTotalCm ?? 0; + PenultimaLeitura.Heartbeat = + UltimaLeitura.Heartbeat; + + PenultimaLeitura.LeverArmFrontal = + UltimaLeitura.LeverArmFrontal; + + PenultimaLeitura.LeverArmLateral = + UltimaLeitura.LeverArmLateral; + + UltimaLeitura.Ntrip_ativado = + CorrecaoRTK_Ntrip; + + UltimaLeitura.Heartbeat = + (UltimaLeitura.Heartbeat + 1) % 10; + + UltimaLeitura.LeverArmFrontal = + LeverArm != null + ? LeverArm.FrontalTotalCm + : 0; + + UltimaLeitura.LeverArmLateral = + LeverArm != null + ? LeverArm.LateralTotalCm + : 0; if (Variaveis.IsAgroMonitor) - { - //EnviarCoordenadasParaMapa(UltimaLeitura.Latitude, UltimaLeitura.Longitude, UltimaLeitura.AnguloCarroDefinido, false, LoRaBaseService.parametrosModel.address); return; + + UltimasLeituras.Add( + UltimaLeitura.Clone() + ); + + while (UltimasLeituras.Count > + Math.Max(1, TaxaAmostragemHz)) + { + UltimasLeituras.RemoveAt(0); } - UltimasLeituras.Add(UltimaLeitura.Clone()); - if (UltimasLeituras.Count > TaxaAmostragemHz) UltimasLeituras.Remove(UltimasLeituras.First()); - DefinirOrientacaoMovimento(); - AtualizaDadosRedis(); if (Variaveis.OperacaoEmAndamento.Iniciado) { - var _Trajetoria = Variaveis.OperacaoEmAndamento.Trajetoria; - CorredorTrajetoriaModel CorredorAtual = _Trajetoria?.CorredorAtual; - var _GPSTrajetoria = Variaveis.OperacaoEmAndamento.GPSTrajetoria; + var trajetoria = + Variaveis.OperacaoEmAndamento.Trajetoria; - // ✅ Criando uma cópia eficiente de UltimaLeitura sem copiar manualmente cada propriedade - var novaLeitura = UltimaLeitura.Clone(); + var gpsTrajetoria = + Variaveis.OperacaoEmAndamento + .GPSTrajetoria; - // ✅ Adiciona a nova leitura à trajetória GPS - _GPSTrajetoria.Add(novaLeitura); + GPSModel novaLeitura = + UltimaLeitura.Clone(); - // ✅ Calcula a distância entre os dois últimos pontos, mas só se houver pelo menos 2 pontos - if (_GPSTrajetoria.Count > 1) + gpsTrajetoria.Add(novaLeitura); + + if (gpsTrajetoria.Count > 1) { - int ultimo = _GPSTrajetoria.Count - 1; - int penultimo = _GPSTrajetoria.Count - 2; + int ultimo = + gpsTrajetoria.Count - 1; - double distancia = GPSUtils.DistanciaEntrePontos( - _GPSTrajetoria[ultimo], // Último ponto - _GPSTrajetoria[penultimo] // Penúltimo ponto - ); + int penultimo = ultimo - 1; - _Trajetoria?.AtualizarDistanciaPercorrida(distancia); + double distancia = + GPSUtils.DistanciaEntrePontos( + gpsTrajetoria[ultimo], + gpsTrajetoria[penultimo] + ); + + if (trajetoria != null) + trajetoria.AtualizarDistanciaPercorrida( + distancia + ); } - var bicos = Variaveis.OperacaoEmAndamento.DispAtu?.Dados?.BicosPulverizadores; + var bicos = + Variaveis.OperacaoEmAndamento + .DispAtu? + .Dados? + .BicosPulverizadores; + if (bicos != null) { foreach (var bico in bicos) { - if (!bico.Inicializado || !bico.Comandar || !bico.ComandoAtuar) + if (!bico.Inicializado || + !bico.Comandar || + !bico.ComandoAtuar) + { continue; + } - bico.AdicionarPontoTrechoAtivo(UltimaLeitura.Latitude, UltimaLeitura.Longitude); + bico.AdicionarPontoTrechoAtivo( + UltimaLeitura.Latitude, + UltimaLeitura.Longitude + ); } } } - Variaveis.OperacaoEmAndamento.Trajetoria?.LoopAtualizaDados(); + if (Variaveis.OperacaoEmAndamento.Trajetoria != null) + { + Variaveis.OperacaoEmAndamento + .Trajetoria + .LoopAtualizaDados(); + } AtualizarTrajetoriaDinamica(); - int EnderecoEquipamento = Convert.ToInt32(Variaveis.LoraService?.ParametrosGet?.address ?? Variaveis.LoraService?.ParametrosSet?.address ?? 0x00); - EnviarCoordenadasParaMapa(UltimaLeitura.Latitude, UltimaLeitura.Longitude, UltimaLeitura.AnguloCarroDefinido, Variaveis.OperacaoEmAndamento.Iniciado, EnderecoEquipamento); + int enderecoEquipamento = Convert.ToInt32( + Variaveis.LoraService? + .ParametrosGet? + .address ?? + Variaveis.LoraService? + .ParametrosSet? + .address ?? + 0x00 + ); - if (Variaveis.LoraService?.Iniciado ?? false && (Variaveis.LoraService?._ultimoRxDados ?? DateTime.MinValue) > DateTime.UtcNow.AddSeconds(-60)) + EnviarCoordenadasParaMapa( + UltimaLeitura.Latitude, + UltimaLeitura.Longitude, + UltimaLeitura.AnguloCarroDefinido, + Variaveis.OperacaoEmAndamento.Iniciado, + enderecoEquipamento + ); + + if ((Variaveis.LoraService?.Iniciado ?? false) && + (Variaveis.LoraService?._ultimoRxDados ?? + DateTime.MinValue) > + DateTime.UtcNow.AddSeconds(-60)) { - byte EnderecoBase = Variaveis.LoraBaseParametros.address; - EnviarCoordenadasParaMapa(VariaveisOperacao.PosicaoBase.Latitude, VariaveisOperacao.PosicaoBase.Longitude, VariaveisOperacao.PosicaoBase.OrientacaoReal, false, EnderecoBase); + byte enderecoBase = + Variaveis.LoraBaseParametros.address; + + GPSModel posicaoBase = + VariaveisOperacao + .ObterPosicaoBaseSnapshot(); + + EnviarCoordenadasParaMapa( + posicaoBase.Latitude, + posicaoBase.Longitude, + posicaoBase.OrientacaoReal, + false, + enderecoBase + ); } - PenultimaLeitura.Inicializado = UltimaLeitura.Inicializado; + PenultimaLeitura.Inicializado = + UltimaLeitura.Inicializado; + UltimaLeitura.Inicializado = Iniciado; - if (CorrecaoRTK_Ntrip && UltimoEnvioCorrecaoRTK.AddSeconds(TempoMin_Ntrip) < DateTime.Now) + if (CorrecaoRTK_Ntrip && + GetAgeMs( + Interlocked.Read( + ref _lastRtcmWrittenMono + ) + ) > TempoMin_Ntrip * 1000.0) { - UltimoEnvioCorrecaoRTK = DateTime.Now; - LoopRTK_Ntrip = false; - Task.Run(async () => - { - await Task.Delay(5000); - await AplicarCorrecaoRTK_Ntrip(); - }); + _ = IniciarNtripAsync(); } } + // ============================================================ + // PUBLICAÇÃO LOCAL LATEST-ONLY + // ============================================================ - - public static void EnviarCoordenadasParaMapa(double Latitude, double Longitude, double Orientacao, bool EmFoco, int ID) + public static void EnviarCoordenadasParaMapa( + double Latitude, + double Longitude, + double Orientacao, + bool EmFoco, + int ID) { - var Coordenadas = new + var coordenadas = new { latitude = Latitude, - longitude = Longitude, - orientacao = GPSUtils.NormalizarAngulo(Orientacao - 180.0), - id = ID, + longitude = Longitude, + orientacao = + GPSUtils.NormalizarAngulo( + Orientacao - 180.0 + ), + id = ID, foco = EmFoco }; - Task.Run(async () => - { - //Console.WriteLine(JsonConvert.SerializeObject(Coordenadas)); - await Variaveis.MqttServiceLocal.PublishAsync( - Variaveis.MqttServiceLocal.Topicos.First(x => x.Topico == MapasVariaveisModel.TopicoCoordenadasGPS), - JsonConvert.SerializeObject(Coordenadas) - ); - }); + + QueueLocalPublication( + MapasVariaveisModel.TopicoCoordenadasGPS, + JsonConvert.SerializeObject(coordenadas) + ); } public static void AtualizarTrajetoriaDinamica() { - if (Variaveis.OperacaoEmAndamento.Trajetoria?._TrajetoriaDinamica?.Any() ?? false) - { - var Trajetoria = Variaveis.OperacaoEmAndamento.Trajetoria.TrajetoriaDinamica.Select(x => new + var dinamica = + Variaveis.OperacaoEmAndamento + .Trajetoria? + ._TrajetoriaDinamica; + + if (!(dinamica?.Any() ?? false)) + return; + + var trajetoria = Variaveis + .OperacaoEmAndamento + .Trajetoria + .TrajetoriaDinamica + .Select(x => new { latitude = x.Latitude, longitude = x.Longitude - }).ToArray(); + }) + .ToArray(); - Task.Run(async () => + QueueLocalPublication( + MapasVariaveisModel + .TopicoTrajetoriaDinamica, + JsonConvert.SerializeObject(trajetoria) + ); + } + + public static void AtualizarRuasSelecionadas( + List RuasSelecionadas) + { + /* + * Preserva o formato legado produzido anteriormente. + */ + QueueLocalPublication( + MapasVariaveisModel + .TopicoSelecaoRuasMapa, + JsonConvert.SerializeObject( + "[" + + string.Join( + ",", + (RuasSelecionadas ?? + new List()).ToArray() + ) + + "]" + ) + ); + } + + private static void QueueLocalPublication( + string topic, + string payload) + { + if (string.IsNullOrWhiteSpace(topic)) + return; + + EnsureBackgroundWorkersStarted(); + + lock (_localPublishLock) + { + _localLatestPayload[topic] = + payload ?? string.Empty; + } + + try { _localPublishSignal.Release(); } + catch (SemaphoreFullException) { } + } + + private static async Task LocalPublisherLoopAsync( + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try { - await Variaveis.MqttServiceLocal.PublishAsync( - Variaveis.MqttServiceLocal.Topicos.First(x => x.Topico == MapasVariaveisModel.TopicoTrajetoriaDinamica), - JsonConvert.SerializeObject(Trajetoria) + await _localPublishSignal + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + Dictionary lote; + + lock (_localPublishLock) + { + lote = new Dictionary( + _localLatestPayload, + StringComparer.Ordinal ); - }); + + _localLatestPayload.Clear(); + } + + MqttService mqtt = + Variaveis.MqttServiceLocal; + + if (mqtt == null || + !mqtt.StatusConexao()) + { + continue; + } + + foreach (var item in lote) + { + if (cancellationToken + .IsCancellationRequested) + { + return; + } + + MqttService.MqttTopicosModel topico = + mqtt.Topicos.FirstOrDefault( + x => x.Topico == item.Key + ); + + if (topico == null) + continue; + + try + { + await mqtt.PublishAsync( + topico, + item.Value, + true + ).ConfigureAwait(false); + } + catch (Exception ex) + { + RecordError( + "[GPSService.LocalPublisher] " + + ex.Message + ); + } + } } } - public static void AtualizarRuasSelecionadas(List RuasSelecionadas) - { - Task.Run(async () => - { - await Variaveis.MqttServiceLocal.PublishAsync( - Variaveis.MqttServiceLocal.Topicos.First(x => x.Topico == MapasVariaveisModel.TopicoSelecaoRuasMapa), - JsonConvert.SerializeObject("[" + string.Join(",", RuasSelecionadas.ToArray()) + "]"), - true - ); - }); - } + // ============================================================ + // ORIENTAÇÃO, REDIS E HISTÓRICO + // ============================================================ private static void DefinirOrientacaoMovimento() { if (UltimasLeituras.Count < 2) { - double ang = GPSUtils.CalcularOrientacao(PenultimaLeitura, UltimaLeitura); - double d = GPSUtils.DistanciaEntrePontos(PenultimaLeitura, UltimaLeitura); + double ang = + GPSUtils.CalcularOrientacao( + PenultimaLeitura, + UltimaLeitura + ); - PenultimaLeitura.OrientacaoMovimento = UltimaLeitura.OrientacaoMovimento; - PenultimaLeitura.Distancia = UltimaLeitura.Distancia; + double d = + GPSUtils.DistanciaEntrePontos( + PenultimaLeitura, + UltimaLeitura + ); + + PenultimaLeitura.OrientacaoMovimento = + UltimaLeitura.OrientacaoMovimento; + + PenultimaLeitura.Distancia = + UltimaLeitura.Distancia; UltimaLeitura.OrientacaoMovimento = ang; - UltimaLeitura.Distancia = d; // aqui fica sendo o deslocamento dessa “janela” mínima + UltimaLeitura.Distancia = d; } else { - // Média circular ponderada pela distância de cada segmento da janela - double sumX = 0.0, sumY = 0.0; - double distAcum = 0.0; + double sumX = 0; + double sumY = 0; + double distAcum = 0; - for (int i = 0; i < UltimasLeituras.Count - 1; i++) + for (int i = 0; + i < UltimasLeituras.Count - 1; + i++) { - var a = UltimasLeituras[i]; - var b = UltimasLeituras[i + 1]; + GPSModel a = UltimasLeituras[i]; + GPSModel b = UltimasLeituras[i + 1]; - double angSeg = GPSUtils.CalcularOrientacao(a, b); // em graus - double dSeg = GPSUtils.DistanciaEntrePontos(a, b); // em metros - if (dSeg <= 0) continue; // ignora degrau zero + double angSeg = + GPSUtils.CalcularOrientacao(a, b); - double rad = angSeg * Math.PI / 180.0; - sumX += Math.Cos(rad) * dSeg; // peso = distância + double dSeg = + GPSUtils.DistanciaEntrePontos(a, b); + + if (dSeg <= 0) + continue; + + double rad = + angSeg * Math.PI / 180.0; + + sumX += Math.Cos(rad) * dSeg; sumY += Math.Sin(rad) * dSeg; distAcum += dSeg; } - // Se tudo foi zero (parado), caia para heading instantâneo double anguloMovimento; + if (distAcum <= 0) { - anguloMovimento = UltimaLeitura.OrientacaoReal; // heading como fallback parado + anguloMovimento = + UltimaLeitura.OrientacaoReal; } else { - anguloMovimento = Math.Atan2(sumY, sumX) * 180.0 / Math.PI; - anguloMovimento = GPSUtils.NormalizarAngulo(anguloMovimento); + anguloMovimento = + Math.Atan2(sumY, sumX) * + 180.0 / Math.PI; + + anguloMovimento = + GPSUtils.NormalizarAngulo( + anguloMovimento + ); } - // Distância linear entre a 1ª e a última da janela (boa para "confiabilidade") - var first = UltimasLeituras[0]; - var last = UltimasLeituras[UltimasLeituras.Count - 1]; - double distLinear = GPSUtils.DistanciaEntrePontos(first, last); + GPSModel first = + UltimasLeituras[0]; - // Atualiza campos - PenultimaLeitura.OrientacaoMovimento = UltimaLeitura.OrientacaoMovimento; - PenultimaLeitura.Distancia = UltimaLeitura.Distancia; + GPSModel last = + UltimasLeituras[ + UltimasLeituras.Count - 1 + ]; - UltimaLeitura.OrientacaoMovimento = anguloMovimento; - UltimaLeitura.Distancia = distLinear; // use a linear como sinal de "Δpos confiável" para a fusão + double distLinear = + GPSUtils.DistanciaEntrePontos( + first, + last + ); + + PenultimaLeitura.OrientacaoMovimento = + UltimaLeitura.OrientacaoMovimento; + + PenultimaLeitura.Distancia = + UltimaLeitura.Distancia; + + UltimaLeitura.OrientacaoMovimento = + anguloMovimento; + + UltimaLeitura.Distancia = distLinear; } DefinirAnguloCarro(); @@ -1374,178 +2605,253 @@ namespace AgroBase.Services public static void DefinirAnguloCarro() { - double anguloFinal = 0.0; + double anguloFinal; + + bool imuIniciado = + Variaveis.OperacaoEmAndamento + .Sensoriamento? + .IMU? + .Iniciado ?? false; - bool imuIniciado = Variaveis.OperacaoEmAndamento.Sensoriamento?.IMU?.Iniciado ?? false; bool gpsIniciado = Iniciado; - bool headingValido = PossuiHeadingValido(UltimaLeitura); - if (imuIniciado) - { - //Variaveis.OperacaoEmAndamento.DispSen.Dados?.DadosLeitura?.SensoresIMU?.FirstOrDefault()?.AtualizarOffset(UltimaLeitura); - } + bool headingValido = + PossuiHeadingValido(UltimaLeitura); if (Variaveis.OperacaoEmAndamento.Simulando) { - anguloFinal = UltimaLeitura.OrientacaoReal; + anguloFinal = + UltimaLeitura.OrientacaoReal; } else if (gpsIniciado && headingValido) { - anguloFinal = UltimaLeitura.OrientacaoReal; + anguloFinal = + UltimaLeitura.OrientacaoReal; } - else if (gpsIniciado && UltimaLeitura.Distancia > 0.25) + else if (gpsIniciado && + UltimaLeitura.Distancia > 0.25) { - anguloFinal = UltimaLeitura.OrientacaoMovimento; + anguloFinal = + UltimaLeitura.OrientacaoMovimento; } else if (imuIniciado) { - anguloFinal = Variaveis.OperacaoEmAndamento.Sensoriamento.IMU.YawSeguro; + anguloFinal = + Variaveis.OperacaoEmAndamento + .Sensoriamento + .IMU + .YawSeguro; } else { - anguloFinal = UltimaLeitura.AnguloCarroDefinido; + anguloFinal = + UltimaLeitura.AnguloCarroDefinido; } - // Atualiza o ângulo final - UltimaLeitura.AnguloCarroDefinido = anguloFinal; // GPSUtils.NormalizarAngulo(anguloFinal + HeadingOffsetSimulador + HeadingOffsetCorrecao); + UltimaLeitura.AnguloCarroDefinido = + anguloFinal; } public static double FundirHeadingComMovimento( - double headingDeg, // OrientacaoReal (antena/corpo) - double angMovDeg, // OrientacaoMovimento (calculada acima) - double distLinearJanela, // UltimaLeitura.Distancia (Δpos linear entre 1ª e última amostra) - double velPercent = 0.0, // se tiver velocidade filtrada, passe aqui; senão 0 - bool rtkFix = true, // se tiver essa info - double angFundidoAnterior = double.NaN, // para low-pass; passe double.NaN para desabilitar - double alfaLowPass = 0.25 // 0→sem low-pass; 0.2–0.35 costuma ser bom - ) + double headingDeg, + double angMovDeg, + double distLinearJanela, + double velPercent = 0.0, + bool rtkFix = true, + double angFundidoAnterior = double.NaN, + double alfaLowPass = 0.25) { - // Parâmetros sintonizados para 5 Hz - double distMin = 0.03; // ~3 cm → considera "parado" - double distFull = 0.25; // ~25 cm → deslocamento confiável - double pesoMinHeading = 0.30; // mantém um "fio" do heading mesmo em alta confiança no movimento - double fatorConfMovSemFix = 0.80; // penaliza confiança no movimento se não for RTK FIX + double distMin = 0.03; + double distFull = 0.25; + double pesoMinHeading = 0.30; + double fatorConfMovSemFix = 0.80; - // Confiança em "movimento" por distância da janela (0..1) - double fd = Smoothstep(0, 1, Norm(distLinearJanela, distMin, distFull)); - // Confiança por velocidade (0..1) - double fv = velPercent / 100.0; + double fd = Smoothstep( + Norm( + distLinearJanela, + distMin, + distFull + ) + ); + + double fv = + FuncoesMatematicas.Clamp( + velPercent / 100.0, + 0.0, + 1.0 + ); double confMov = Math.Max(fd, fv); - if (!rtkFix) confMov *= fatorConfMovSemFix; - // Peso do heading = 1 - confMov, mas preservando contribuição mínima proporcional + if (!rtkFix) + confMov *= fatorConfMovSemFix; + double pesoHeading = 1.0 - confMov; - double minHeading = pesoMinHeading * confMov; - if (pesoHeading < minHeading) pesoHeading = minHeading; - if (pesoHeading > 1.0) pesoHeading = 1.0; + double minHeading = + pesoMinHeading * confMov; - double angMisto = MisturarAngulosCircular(headingDeg, angMovDeg, pesoHeading); + if (pesoHeading < minHeading) + pesoHeading = minHeading; - if (!double.IsNaN(angFundidoAnterior) && alfaLowPass > 0) - angMisto = LowPassAngle0to360(angFundidoAnterior, angMisto, alfaLowPass); - else - angMisto = Normalize0To360(angMisto); + if (pesoHeading > 1.0) + pesoHeading = 1.0; - return angMisto; + double angMisto = + MisturarAngulosCircular( + headingDeg, + angMovDeg, + pesoHeading + ); - // ---------- helpers locais ---------- - double Norm(double v, double lo, double hi) + if (!double.IsNaN(angFundidoAnterior) && + alfaLowPass > 0) { - if (hi <= lo) return v >= hi ? 1.0 : 0.0; - double t = (v - lo) / (hi - lo); - if (t < 0) t = 0; else if (t > 1) t = 1; - return t; - } - double Smoothstep(double a, double b, double x) - { - // aqui x já normalizado 0..1 no uso acima - x = FuncoesMatematicas.Clamp(x, 0.0, 1.0); - return x * x * (3 - 2 * x); - } - double MisturarAngulosCircular(double aDeg, double bDeg, double pesoA /*0..1*/) - { - double a = aDeg * Math.PI / 180.0; - double b = bDeg * Math.PI / 180.0; - double x = Math.Cos(a) * pesoA + Math.Cos(b) * (1.0 - pesoA); - double y = Math.Sin(a) * pesoA + Math.Sin(b) * (1.0 - pesoA); - return Math.Atan2(y, x) * 180.0 / Math.PI; - } - double LowPassAngle(double atualDeg, double novoDeg, double alfa) - { - double diff = GPSUtils.NormalizarAngulo(novoDeg - atualDeg); - return GPSUtils.NormalizarAngulo(atualDeg + alfa * diff); - } - double LowPassAngle0to360(double atualDeg, double novoDeg, double alfa) - { - double diff = NormalizeSigned180(novoDeg - atualDeg); // delta curto - return Normalize0To360(atualDeg + alfa * diff); - } - double NormalizeSigned180(double angDeg) - { - angDeg = (angDeg + 180.0) % 360.0; - if (angDeg < 0) angDeg += 360.0; - return angDeg - 180.0; // [-180, +180) - } - double Normalize0To360(double angDeg) - { - angDeg %= 360.0; - if (angDeg < 0) angDeg += 360.0; - return angDeg; // [0, 360) + angMisto = LowPassAngle0to360( + angFundidoAnterior, + angMisto, + alfaLowPass + ); } + + return Normalize0To360(angMisto); + } + + private static double Norm( + double value, + double min, + double max) + { + if (max <= min) + return value >= max ? 1.0 : 0.0; + + return FuncoesMatematicas.Clamp( + (value - min) / (max - min), + 0.0, + 1.0 + ); + } + + private static double Smoothstep(double value) + { + double x = + FuncoesMatematicas.Clamp( + value, + 0.0, + 1.0 + ); + + return x * x * (3.0 - 2.0 * x); + } + + private static double MisturarAngulosCircular( + double aDeg, + double bDeg, + double pesoA) + { + double a = aDeg * Math.PI / 180.0; + double b = bDeg * Math.PI / 180.0; + + double x = + Math.Cos(a) * pesoA + + Math.Cos(b) * (1.0 - pesoA); + + double y = + Math.Sin(a) * pesoA + + Math.Sin(b) * (1.0 - pesoA); + + return Math.Atan2(y, x) * + 180.0 / Math.PI; + } + + private static double LowPassAngle0to360( + double atualDeg, + double novoDeg, + double alfa) + { + double diff = + NormalizeSigned180( + novoDeg - atualDeg + ); + + return Normalize0To360( + atualDeg + alfa * diff + ); + } + + private static double NormalizeSigned180( + double angDeg) + { + angDeg = (angDeg + 180.0) % 360.0; + + if (angDeg < 0) + angDeg += 360.0; + + return angDeg - 180.0; + } + + private static double Normalize0To360( + double angDeg) + { + angDeg %= 360.0; + + if (angDeg < 0) + angDeg += 360.0; + + return angDeg; } public static void AtualizaDadosRedis() { try { - GPSModel posicaoAtual = - Variaveis.OperacaoEmAndamento.Simulando - ? historicoPosicao.Peek() - : UltimaLeitura; + GPSModel posicaoAtual; + + if (Variaveis.OperacaoEmAndamento.Simulando) + { + lock (_stateLock) + { + posicaoAtual = + historicoPosicao.Count > 0 + ? historicoPosicao.Peek().Clone() + : UltimaLeitura.Clone(); + } + } + else + { + posicaoAtual = GetSnapshot(); + } bool rtkValido = posicaoAtual.IdadeCorrecao >= 0 && - posicaoAtual.IdadeCorrecao < rtk_timeout && + posicaoAtual.IdadeCorrecao < + rtk_timeout && ( - posicaoAtual.QualidadeFix == TiposCorrecaoGPS.RTKFixo || - posicaoAtual.QualidadeFix == TiposCorrecaoGPS.RTKFlutuante || - posicaoAtual.QualidadeFix == TiposCorrecaoGPS.DGPS + posicaoAtual.QualidadeFix == + TiposCorrecaoGPS.RTKFixo || + posicaoAtual.QualidadeFix == + TiposCorrecaoGPS.RTKFlutuante || + posicaoAtual.QualidadeFix == + TiposCorrecaoGPS.DGPS ); - // --------------------------------------------------------- - // ESTADO DO HEADING DUAL-ANTENNA - // --------------------------------------------------------- - string statusOrientacao = - (posicaoAtual.TipoOrientacao ?? string.Empty) + (posicaoAtual.TipoOrientacao ?? + string.Empty) .Trim() .ToUpperInvariant(); double valorOrientacao = posicaoAtual.OrientacaoReal; - bool valorOrientacaoNumerico = + bool orientacaoValida = + statusOrientacao == "A" && !double.IsNaN(valorOrientacao) && - !double.IsInfinity(valorOrientacao); - - bool valorOrientacaoDentroDaFaixa = - valorOrientacaoNumerico && + !double.IsInfinity(valorOrientacao) && valorOrientacao >= 0.0 && valorOrientacao < 360.0; - /* - * A solução só é utilizável quando: - * - * 1. O UM982 informou status "A"; - * 2. O valor é numérico; - * 3. O valor está na faixa válida de heading. - * - * Assim, a sentinela 9999 nunca será considerada válida. - */ - bool orientacaoValida = - statusOrientacao == "A" && - valorOrientacaoDentroDaFaixa; + GpsTransportMetrics metrics = + GetTransportMetrics(); RedisService.AtualizarCampos( RedisService.ModKey(T_Code.Gps), @@ -1553,77 +2859,840 @@ namespace AgroBase.Services ("conectado", Iniciado), ("freq_base", TaxaAmostragemHz), - // Posição e orientação utilizada pelo carro ("lat", posicaoAtual.Latitude), ("lon", posicaoAtual.Longitude), ("theta", posicaoAtual.AnguloCarroDefinido), - // Estado da solução GNSS ("fix", (int)posicaoAtual.QualidadeFix), ("rtk", rtkValido), ("hAcc", posicaoAtual.PrecisaoCm), ("nSatelites", posicaoAtual.NumeroSatelites), ("age", posicaoAtual.IdadeCorrecao), - // Frequências - ("freq.posicao", posicaoAtual.TimestampPos.frequencia), - ("freq.orientacao", posicaoAtual.TimestampOri.frequencia), + ("freq.posicao", + posicaoAtual.TimestampPos.frequencia), + ("freq.orientacao", + posicaoAtual.TimestampOri.frequencia), - // Latências - ("latency.posicao", posicaoAtual.TimestampPos.dt), - ("latency.orientacao", posicaoAtual.TimestampOri.dt), + ("latency.posicao", + posicaoAtual.TimestampPos.dt), + ("latency.orientacao", + posicaoAtual.TimestampOri.dt), - // Timestamps monotônicos - ("timestamp.posicao", posicaoAtual.TimestampPos.valor), - ("timestamp.orientacao", posicaoAtual.TimestampOri.valor), + ("timestamp.posicao", + posicaoAtual.TimestampPos.valor), + ("timestamp.orientacao", + posicaoAtual.TimestampOri.valor), - // Estado específico do heading dual-antenna - ("orientacao.status", statusOrientacao), - ("orientacao.valida", orientacaoValida), - ("orientacao.valor", valorOrientacao), + ("orientacao.status", + statusOrientacao), + ("orientacao.valida", + orientacaoValida), + ("orientacao.valor", + valorOrientacao), - ("heartbeat", posicaoAtual.Heartbeat) + ("heartbeat", + posicaoAtual.Heartbeat), + + ("rtcm.received", + metrics.RtcmReceived), + ("rtcm.written", + metrics.RtcmWritten), + ("rtcm.queue_depth", + metrics.RtcmQueueDepth), + ("rtcm.last_rx_age_ms", + metrics.LastRtcmReceivedAgeMs), + ("rtcm.last_write_age_ms", + metrics.LastRtcmWrittenAgeMs), + ("rtcm.drop_stale", + metrics.RtcmDroppedStale), + ("rtcm.write_errors", + metrics.RtcmWriteErrors) ); } - catch (Exception e) + catch (Exception ex) { - Variaveis.MostrarLog( - $"[GPSService.AtualizarDadosRedis] " + - $"Erro ao salvar dados do GPS no Redis: {e.Message}" + RecordError( + "[GPSService.AtualizaDadosRedis] " + + ex.Message ); - Variaveis.OperacaoEmAndamento.Sensoriamento.InserirLog( - T_Code.Gps, - StatusModulo.Falha, - 0, - $"Erro ao salvar dados do GPS no Redis: {e.Message}" - ); + try + { + Variaveis.OperacaoEmAndamento + .Sensoriamento + .InserirLog( + T_Code.Gps, + StatusModulo.Falha, + 0, + "Erro ao salvar dados do GPS no Redis: " + + ex.Message + ); + } + catch { } } } - public static Queue historicoPosicao = new Queue(); - public static void AtualizarAtrasoPosicoes(int errosConsiderar = 0) + public static void AtualizarAtrasoPosicoes( + int errosConsiderar = 0) { - historicoPosicao.Enqueue(UltimaLeitura); - while (historicoPosicao.Count > 1 && (historicoPosicao.Count > errosConsiderar || errosConsiderar == 0)) + lock (_stateLock) { - historicoPosicao.Dequeue(); + historicoPosicao.Enqueue( + UltimaLeitura.Clone() + ); + + while (historicoPosicao.Count > 1 && + ( + historicoPosicao.Count > + errosConsiderar || + errosConsiderar == 0 + )) + { + historicoPosicao.Dequeue(); + } } } + // ============================================================ + // LOG EM WORKER + // ============================================================ + + private static void EnfileirarLogSnapshot() + { + try + { + string json; + + lock (_stateLock) + { + GPSModel obj = UltimaLeitura.Clone(); + obj.Momento = DateTime.Now; + json = JsonConvert.SerializeObject(obj); + } + + _gpsLogQueue.Enqueue(json); + + try { _logSignal.Release(); } + catch (SemaphoreFullException) { } + } + catch (Exception ex) + { + RecordError( + "[GPSService.EnfileirarLogSnapshot] " + + ex.Message + ); + } + } + + private static async Task LogWriterLoopAsync( + CancellationToken cancellationToken) + { + var lote = new List(300); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await _logSignal.WaitAsync( + cancellationToken + ).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + string item; + + while (lote.Count < 300 && + _gpsLogQueue.TryDequeue( + out item)) + { + lote.Add(item); + } + + if (lote.Count < 300) + continue; + + try + { + /* + * RegistrarLogDispositivo limpa a lista recebida. + * Passamos uma lista exclusiva do worker. + */ + VariaveisOperacao + .RegistrarLogDispositivo( + lote, + T_Code.Gps, + ".json", + 300 + ); + + lote = new List(300); + } + catch (Exception ex) + { + RecordError( + "[GPSService.LogWriter] " + + ex.Message + ); + } + } + + while (_gpsLogQueue.TryDequeue(out var pendente)) + lote.Add(pendente); + + if (lote.Count > 0) + { + try + { + VariaveisOperacao + .RegistrarLogDispositivo( + lote, + T_Code.Gps, + ".json", + 1 + ); + } + catch { } + } + } + + // ============================================================ + // WORKERS E SHUTDOWN + // ============================================================ + + private static void EnsureBackgroundWorkersStarted() + { + lock (_workerLock) + { + if (_serviceCts != null && + !_serviceCts.IsCancellationRequested) + { + return; + } + + _serviceCts = + new CancellationTokenSource(); + + CancellationToken token = + _serviceCts.Token; + + _nmeaWorkerTask = Task.Run( + () => NmeaWorkerLoopAsync(token), + token + ); + + _rtcmWriterTask = Task.Run( + () => RtcmWriterLoopAsync(token), + token + ); + + _logWriterTask = Task.Run( + () => LogWriterLoopAsync(token), + token + ); + + _localPublisherTask = Task.Run( + () => LocalPublisherLoopAsync(token), + token + ); + } + } + + public static async Task EncerrarAsync() + { + await PararNtripAsync().ConfigureAwait(false); + + CancellationTokenSource cts; + Task[] tasks; + + lock (_workerLock) + { + cts = _serviceCts; + + tasks = new[] + { + _nmeaWorkerTask, + _rtcmWriterTask, + _logWriterTask, + _localPublisherTask + } + .Where(x => x != null) + .ToArray(); + + _serviceCts = null; + _nmeaWorkerTask = null; + _rtcmWriterTask = null; + _logWriterTask = null; + _localPublisherTask = null; + } + + if (cts != null) + { + try { cts.Cancel(); } catch { } + } + + try + { + await Task.WhenAll(tasks) + .ConfigureAwait(false); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + RecordError( + "[GPSService.EncerrarAsync] " + + ex.Message + ); + } + + if (cts != null) + cts.Dispose(); + + CancellationTokenSource configCts = + Interlocked.Exchange( + ref _configurationCts, + null + ); + + if (configCts != null) + { + try { configCts.Cancel(); } catch { } + } + + /* + * Aguarda uma configuração em andamento abandonar os locks. + * O token acima interrompe os delays cooperativos. + */ + await _configurationLock.WaitAsync() + .ConfigureAwait(false); + _configurationLock.Release(); + + await _serialWriteLock.WaitAsync() + .ConfigureAwait(false); + + try + { + lock (_portLifecycleLock) + { + SerialPort porta = PortaGPS; + PortaGPS = null; + + if (porta != null) + { + try + { + porta.DataReceived -= + PortaGPS_DataReceived; + } + catch { } + + try + { + if (porta.IsOpen) + porta.Close(); + } + catch { } + + try { porta.Dispose(); } + catch { } + } + } + } + finally + { + _serialWriteLock.Release(); + } + + if (configCts != null) + configCts.Dispose(); + } + + // ============================================================ + // MÉTRICAS + // ============================================================ + + public static GpsTransportMetrics + GetTransportMetrics() + { + int queueDepth; + int nmeaQueueDepth; + + lock (_rtcmQueueLock) + queueDepth = _rtcmQueue.Count; + + lock (_nmeaQueueLock) + nmeaQueueDepth = _nmeaQueue.Count; + + string lastError; + + lock (_metricsTextLock) + lastError = _lastError; + + return new GpsTransportMetrics + { + SerialConnected = Iniciado, + SerialPortName = + PortaGPS != null + ? PortaGPS.PortName + : null, + + LastSerialRxAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastSerialRxMono + ) + ), + + LastValidNmeaAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastValidNmeaMono + ) + ), + + LastValidGgaAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastValidGgaMono + ) + ), + + LastValidThsAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastValidThsMono + ) + ), + + SerialBytesReceived = + Interlocked.Read( + ref _serialBytesReceived + ), + + NmeaValid = + Interlocked.Read(ref _nmeaValid), + + NmeaInvalid = + Interlocked.Read(ref _nmeaInvalid), + + NmeaChecksumErrors = + Interlocked.Read( + ref _nmeaChecksumErrors + ), + + NmeaBufferResets = + Interlocked.Read( + ref _nmeaBufferResets + ), + + NmeaQueued = + Interlocked.Read(ref _nmeaQueued), + + NmeaDroppedQueue = + Interlocked.Read( + ref _nmeaDroppedQueue + ), + + NmeaQueueDepth = nmeaQueueDepth, + + RtcmReceived = + Interlocked.Read(ref _rtcmReceived), + + RtcmQueued = + Interlocked.Read(ref _rtcmQueued), + + RtcmWritten = + Interlocked.Read(ref _rtcmWritten), + + RtcmDroppedQueue = + Interlocked.Read( + ref _rtcmDroppedQueue + ), + + RtcmDroppedStale = + Interlocked.Read( + ref _rtcmDroppedStale + ), + + RtcmDroppedInvalid = + Interlocked.Read( + ref _rtcmDroppedInvalid + ), + + RtcmWriteErrors = + Interlocked.Read( + ref _rtcmWriteErrors + ), + + RtcmQueueDepth = queueDepth, + + LastRtcmReceivedAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastRtcmReceivedMono + ) + ), + + LastRtcmWrittenAgeMs = + GetAgeMs( + Interlocked.Read( + ref _lastRtcmWrittenMono + ) + ), + + ConfigurationRuns = + Interlocked.Read( + ref _configurationRuns + ), + + ConfigurationErrors = + Interlocked.Read( + ref _configurationErrors + ), + + NtripRunning = LoopRTK_Ntrip, + + NtripReconnects = + Interlocked.Read( + ref _ntripReconnects + ), + + NtripBytesReceived = + Interlocked.Read( + ref _ntripBytesReceived + ), + + NtripErrors = + Interlocked.Read( + ref _ntripErrors + ), + + LastError = lastError + }; + } + + private static void RecordError(string error) + { + lock (_metricsTextLock) + _lastError = error; + + Variaveis.MostrarLog(error); + } + + // ============================================================ + // HELPERS + // ============================================================ + + private static void WriteSerialUnsafe( + byte[] bytes, + int count) + { + SerialPort porta = PortaGPS; + + if (porta == null || !porta.IsOpen) + throw new IOException( + "Porta GPS não está aberta." + ); + + porta.Write(bytes, 0, count); + } + + private static double GetAgeMs(long timestamp) + { + if (timestamp <= 0) + return double.PositiveInfinity; + + long delta = + Stopwatch.GetTimestamp() - timestamp; + + if (delta <= 0) + return 0; + + return delta * 1000.0 / + Stopwatch.Frequency; + } + + private static string GetField( + string[] fields, + int index, + string fallback = "") + { + return fields != null && + index >= 0 && + index < fields.Length + ? fields[index] ?? fallback + : fallback; + } + + private static string StripChecksum(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + int index = value.IndexOf('*'); + + return index >= 0 + ? value.Substring(0, index) + : value; + } + + private static bool TryParseDmm( + string raw, + string hemisphere, + int degreeDigits, + out double result) + { + result = 0; + + if (string.IsNullOrWhiteSpace(raw) || + raw.Length <= degreeDigits) + { + return false; + } + + double degrees; + double minutes; + + if (!double.TryParse( + raw.Substring(0, degreeDigits), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out degrees) || + !double.TryParse( + raw.Substring(degreeDigits), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out minutes)) + { + return false; + } + + result = degrees + minutes / 60.0; + + if (string.Equals( + hemisphere, + "S", + StringComparison.OrdinalIgnoreCase) || + string.Equals( + hemisphere, + "W", + StringComparison.OrdinalIgnoreCase)) + { + result = -result; + } + + return !double.IsNaN(result) && + !double.IsInfinity(result); + } + + private static bool TryParseNmeaTime( + string raw, + out DateTime result) + { + result = default(DateTime); + + if (string.IsNullOrWhiteSpace(raw) || + raw.Length < 6) + { + return false; + } + + int hh; + int mm; + double ss; + + if (!int.TryParse( + raw.Substring(0, 2), + out hh) || + !int.TryParse( + raw.Substring(2, 2), + out mm) || + !double.TryParse( + raw.Substring(4), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out ss)) + { + return false; + } + + int seconds = (int)Math.Floor(ss); + int milliseconds = (int)Math.Round( + (ss - seconds) * 1000.0 + ); + + if (milliseconds >= 1000) + { + seconds++; + milliseconds = 0; + } + + try + { + result = DateTime.UtcNow.Date + .AddHours(hh) + .AddMinutes(mm) + .AddSeconds(seconds) + .AddMilliseconds(milliseconds) + .ToLocalTime(); + + return true; + } + catch + { + return false; + } + } + + private static bool TryParseRmcDateTime( + string dateRaw, + string timeRaw, + out DateTime result) + { + result = default(DateTime); + + if (string.IsNullOrWhiteSpace(dateRaw) || + dateRaw.Length != 6 || + string.IsNullOrWhiteSpace(timeRaw) || + timeRaw.Length < 6) + { + return false; + } + + int day; + int month; + int yearShort; + int hour; + int minute; + int second; + + if (!int.TryParse( + dateRaw.Substring(0, 2), + out day) || + !int.TryParse( + dateRaw.Substring(2, 2), + out month) || + !int.TryParse( + dateRaw.Substring(4, 2), + out yearShort) || + !int.TryParse( + timeRaw.Substring(0, 2), + out hour) || + !int.TryParse( + timeRaw.Substring(2, 2), + out minute) || + !int.TryParse( + timeRaw.Substring(4, 2), + out second)) + { + return false; + } + + try + { + result = new DateTime( + 2000 + yearShort, + month, + day, + hour, + minute, + second, + DateTimeKind.Utc + ); + + return true; + } + catch + { + return false; + } + } + + private sealed class RtcmEnvelope + { + public byte[] Bytes { get; set; } + public long ReceivedMonotonic { get; set; } + public DateTime ReceivedUtc { get; set; } + public long Sequence { get; set; } + public int MessageType { get; set; } + public RtcmSource Source { get; set; } + } + + private enum RtcmSource + { + Mqtt, + Ntrip + } + + private sealed class NtripHeaderResult + { + public bool Success { get; set; } + public string StatusLine { get; set; } + public byte[] RemainingBytes { get; set; } + } } + public sealed class GpsTransportMetrics + { + public bool SerialConnected { get; set; } + public string SerialPortName { get; set; } + + public double LastSerialRxAgeMs { get; set; } + public double LastValidNmeaAgeMs { get; set; } + public double LastValidGgaAgeMs { get; set; } + public double LastValidThsAgeMs { get; set; } + + public long SerialBytesReceived { get; set; } + + public long NmeaValid { get; set; } + public long NmeaInvalid { get; set; } + public long NmeaChecksumErrors { get; set; } + public long NmeaBufferResets { get; set; } + public long NmeaQueued { get; set; } + public long NmeaDroppedQueue { get; set; } + public int NmeaQueueDepth { get; set; } + + public long RtcmReceived { get; set; } + public long RtcmQueued { get; set; } + public long RtcmWritten { get; set; } + public long RtcmDroppedQueue { get; set; } + public long RtcmDroppedStale { get; set; } + public long RtcmDroppedInvalid { get; set; } + public long RtcmWriteErrors { get; set; } + public int RtcmQueueDepth { get; set; } + public double LastRtcmReceivedAgeMs { get; set; } + public double LastRtcmWrittenAgeMs { get; set; } + + public long ConfigurationRuns { get; set; } + public long ConfigurationErrors { get; set; } + + public bool NtripRunning { get; set; } + public long NtripReconnects { get; set; } + public long NtripBytesReceived { get; set; } + public long NtripErrors { get; set; } + + public string LastError { get; set; } + } public class GgaFix { - public DateTime TsUtc { get; } - public double LatDeg { get; } - public double LonDeg { get; } - public double AltElipsoidalM { get; } - public double HeadingDeg { get; } - public TiposCorrecaoGPS FixQuality { get; } + public DateTime TsUtc { get; private set; } + public double LatDeg { get; private set; } + public double LonDeg { get; private set; } + public double AltElipsoidalM { get; private set; } + public double HeadingDeg { get; private set; } + public TiposCorrecaoGPS FixQuality { get; private set; } - public GgaFix(DateTime tsUtc, double latDeg, double lonDeg, double altElipsoidalM, double headingDeg, TiposCorrecaoGPS fixQuality) + public GgaFix( + DateTime tsUtc, + double latDeg, + double lonDeg, + double altElipsoidalM, + double headingDeg, + TiposCorrecaoGPS fixQuality) { TsUtc = tsUtc; LatDeg = latDeg; @@ -1634,28 +3703,26 @@ namespace AgroBase.Services } } - - public class GeoLeverArm { - // Lever arm físico fixo do equipamento - private readonly double xFisico; // +frente (m) - private readonly double yFisico; // +direita (m) - - // Offset operacional/de campo - private readonly double xCampo; // +frente (m) - private readonly double yCampo; // +direita (m) + private readonly double xFisico; + private readonly double yFisico; + private readonly double xCampo; + private readonly double yCampo; private readonly bool invertHeading; private readonly bool mirrorLateral; - public GeoLeverArm(double offsetFisicoFrontalCm = 0, double offsetFisicoLateralCm = 0, double offsetCampoFrontalCm = 0, double offsetCampoLateralCm = 0, bool invH = false, bool mirrL = false) + public GeoLeverArm( + double offsetFisicoFrontalCm = 0, + double offsetFisicoLateralCm = 0, + double offsetCampoFrontalCm = 0, + double offsetCampoLateralCm = 0, + bool invH = false, + bool mirrL = false) { - // fixos do equipamento xFisico = offsetFisicoFrontalCm / 100.0; yFisico = offsetFisicoLateralCm / 100.0; - - // ajustes de campo xCampo = offsetCampoFrontalCm / 100.0; yCampo = offsetCampoLateralCm / 100.0; @@ -1663,26 +3730,31 @@ namespace AgroBase.Services mirrorLateral = mirrL; } - private double ToRad(double deg) => deg * Math.PI / 180.0; - - public double FrontalTotalCm => (xFisico + xCampo) * 100.0; - public double LateralTotalCm => (yFisico + yCampo) * 100.0; - - /// - /// Convenção: - /// - Entrada = posição da antena GNSS - /// - Saída = posição corrigida do centro/VRP - /// - heading: 0° = Norte, 90° = Leste, sentido horário - /// - frontal positivo = antena à frente do centro - /// - lateral positivo = antena à direita do centro - /// Consequência: - /// - frontal positivo desloca a posição corrigida para trás do robô - /// - lateral positivo desloca a posição corrigida para a esquerda do robô - /// - public (double lat, double lon) FixLeverArmLatLon_Fast(double latAnt_deg, double lonAnt_deg, double headingDeg, double headingFreq) + private static double ToRad(double deg) { - if (headingFreq < 1) return (latAnt_deg, lonAnt_deg); - + return deg * Math.PI / 180.0; + } + + public double FrontalTotalCm + { + get { return (xFisico + xCampo) * 100.0; } + } + + public double LateralTotalCm + { + get { return (yFisico + yCampo) * 100.0; } + } + + public (double lat, double lon) + FixLeverArmLatLon_Fast( + double latAnt_deg, + double lonAnt_deg, + double headingDeg, + double headingFreq) + { + if (headingFreq < 1) + return (latAnt_deg, lonAnt_deg); + double xTotal = xFisico + xCampo; double yTotal = yFisico + yCampo; @@ -1690,40 +3762,69 @@ namespace AgroBase.Services yTotal = -yTotal; double th = ToRad(headingDeg); + if (invertHeading) th = -th; - // Convenção NAV - double fE = Math.Sin(th); // forward east - double fN = Math.Cos(th); // forward north + double fE = Math.Sin(th); + double fN = Math.Cos(th); - double rE = fN; // right east - double rN = -fE; // right north + double rE = fN; + double rN = -fE; - // deslocamento da antena no mundo - double dE = xTotal * fE + yTotal * rE; - double dN = xTotal * fN + yTotal * rN; + double dE = + xTotal * fE + + yTotal * rE; + + double dN = + xTotal * fN + + yTotal * rN; double latRad = ToRad(latAnt_deg); - double dLat_deg = (dN / GPSUtils.RaioDaTerra) * 180.0 / Math.PI; - double dLon_deg = (dE / (GPSUtils.RaioDaTerra * Math.Cos(latRad))) * 180.0 / Math.PI; + double dLatDeg = + dN / GPSUtils.RaioDaTerra * + 180.0 / Math.PI; - // retorna o centro/VRP - return (latAnt_deg - dLat_deg, lonAnt_deg - dLon_deg); + double cosLat = Math.Cos(latRad); + + if (Math.Abs(cosLat) < 1e-12) + return (latAnt_deg, lonAnt_deg); + + double dLonDeg = + dE / + (GPSUtils.RaioDaTerra * cosLat) * + 180.0 / Math.PI; + + return ( + latAnt_deg - dLatDeg, + lonAnt_deg - dLonDeg + ); } - // lat/lon -> ENU (aproximação local, boa para ~km) - public (double x, double y) GeodeticToENU(double lat, double lon, double lat0, double lon0) + public (double x, double y) GeodeticToENU( + double lat, + double lon, + double lat0, + double lon0) { - double latR = ToRad(lat), lonR = ToRad(lon); - double lat0R = ToRad(lat0), lon0R = ToRad(lon0); - double dLat = latR - lat0R, dLon = lonR - lon0R; - double xEast = dLon * Math.Cos(lat0R) * GPSUtils.RaioDaTerra; - double yNorth = dLat * GPSUtils.RaioDaTerra; + double latR = ToRad(lat); + double lonR = ToRad(lon); + double lat0R = ToRad(lat0); + double lon0R = ToRad(lon0); + + double dLat = latR - lat0R; + double dLon = lonR - lon0R; + + double xEast = + dLon * + Math.Cos(lat0R) * + GPSUtils.RaioDaTerra; + + double yNorth = + dLat * GPSUtils.RaioDaTerra; + return (xEast, yNorth); } - } - } diff --git a/AgroBase/AgroBase/Services/MqttService.cs b/AgroBase/AgroBase/Services/MqttService.cs index 32a8f4f15..033c67ca0 100644 --- a/AgroBase/AgroBase/Services/MqttService.cs +++ b/AgroBase/AgroBase/Services/MqttService.cs @@ -1,293 +1,1925 @@ -using AgroBase.Models; +using AgroBase.Models; using MQTTnet; using MQTTnet.Client; using MQTTnet.Client.Options; +using MQTTnet.Client.Disconnecting; +using MQTTnet.Client.Subscribing; +using MQTTnet.Client.Unsubscribing; +using MQTTnet.Protocol; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; -public class MqttService +/// +/// Cliente MQTT resiliente para uso em campo. +/// +/// Garantias principais: +/// - apenas uma tentativa de conexão por vez; +/// - reconexão com backoff exponencial e jitter; +/// - somente tópicos marcados para inscrição são inscritos; +/// - callbacks não criam um Task.Run por mensagem; +/// - cada tópico possui worker próprio, preservando ordem; +/// - suporte a fila sequencial limitada, latest-only e callback inline explícito; +/// - payload binário não é convertido para texto sem necessidade; +/// - publicação não acumula mensagens quando o broker está desconectado; +/// - ciclo de vida explícito com StartAsync, StopAsync e Dispose; +/// - uma nova instância com o mesmo broker/client-id desarma a anterior. +/// +/// A API antiga foi preservada para substituição direta no projeto. +/// +public sealed class MqttService : IDisposable { - private IMqttClient _client; - private IMqttClientOptions _options; - private string _brokerAddr; - private int _brokerPort; - private AsyncTaskTimerModel tmrCheck; + private static readonly object _registryLock = new object(); + private static readonly Dictionary _activeClients = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private readonly object _topicsLock = new object(); + private readonly object _stateLock = new object(); + private readonly object _metricsLock = new object(); + private readonly object _stopTaskLock = new object(); + + private readonly SemaphoreSlim _connectLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _publishLock = new SemaphoreSlim(1, 1); + + private readonly CancellationTokenSource _serviceCts = new CancellationTokenSource(); private readonly Action _logDebug; + private readonly Random _random; - public List Topicos; + private readonly string _brokerAddr; + private readonly int _brokerPort; + private readonly string _clientId; + private readonly string _registryKey; + private readonly bool _local; - public MqttService(string brokerAddress, int brokerPort, string client_id, bool local, Action logDebug) + private readonly IMqttClient _client; + private readonly IMqttClientOptions _options; + private readonly AsyncTaskTimerModel _reconnectTimer; + + private int _started; + private int _stopping; + private int _disposed; + private int _manualDisconnect; + private Task _stopTask; + + private long _nextReconnectTimestamp; + private double _reconnectBackoffSeconds = 1.0; + + private const double ReconnectBackoffInitialSeconds = 1.0; + private const double ReconnectBackoffMaxSeconds = 30.0; + private const int ReconnectCheckIntervalMs = 500; + private const int ConnectTimeoutMs = 8000; + private const int PublishTimeoutMs = 5000; + private const int SubscribeTimeoutMs = 5000; + + private long _connectionAttempts; + private long _connectionSuccesses; + private long _disconnects; + private long _reconnectSchedules; + + private long _publishAttempts; + private long _publishSuccesses; + private long _publishFailures; + private long _publishDroppedDisconnected; + private long _publishTimeouts; + private long _publishedBytes; + + private long _messagesReceived; + private long _receivedBytes; + private long _messagesWithoutTopic; + private long _callbackFailures; + private long _dispatchDropped; + + private DateTime _lastConnectedUtc = DateTime.MinValue; + private DateTime _lastDisconnectedUtc = DateTime.MinValue; + private DateTime _lastPublishUtc = DateTime.MinValue; + private DateTime _lastReceiveUtc = DateTime.MinValue; + private DateTime _lastErrorUtc = DateTime.MinValue; + + private string _lastError; + private double _lastPublishDurationMs; + private double _lastConnectDurationMs; + + /// + /// Mantido público por compatibilidade com o código existente. + /// Para iteração interna, o serviço sempre usa snapshots protegidos. + /// Código externo deve evitar modificar esta lista durante callbacks. + /// + public List Topicos { get; private set; } + + public string BrokerAddress { get { return _brokerAddr; } } + public int BrokerPort { get { return _brokerPort; } } + public string ClientId { get { return _clientId; } } + public bool Local { get { return _local; } } + public bool IsDisposed { get { return Volatile.Read(ref _disposed) == 1; } } + public bool IsStopping { get { return Volatile.Read(ref _stopping) == 1; } } + + public MqttService( + string brokerAddress, + int brokerPort, + string client_id, + bool local, + Action logDebug) { - _logDebug = logDebug; + if (string.IsNullOrWhiteSpace(brokerAddress)) + throw new ArgumentException("O endereço do broker MQTT é obrigatório.", nameof(brokerAddress)); - _brokerAddr = brokerAddress; + if (brokerPort <= 0 || brokerPort > 65535) + throw new ArgumentOutOfRangeException(nameof(brokerPort)); + + if (string.IsNullOrWhiteSpace(client_id)) + throw new ArgumentException("O client_id MQTT é obrigatório.", nameof(client_id)); + + _logDebug = logDebug ?? (_ => { }); + _brokerAddr = brokerAddress.Trim(); _brokerPort = brokerPort; + _local = local; + _clientId = local ? client_id + "_local" : client_id; + _registryKey = _brokerAddr + ":" + _brokerPort + "/" + _clientId; + _random = new Random(unchecked(Environment.TickCount * 397) ^ _registryKey.GetHashCode()); + + Topicos = new List(); var factory = new MqttFactory(); _client = factory.CreateMqttClient(); - string client = !local ? client_id : (client_id + "_local"); - _options = new MqttClientOptionsBuilder() - .WithClientId(client) + .WithClientId(_clientId) .WithTcpServer(_brokerAddr, _brokerPort) .WithCleanSession() + .WithKeepAlivePeriod(TimeSpan.FromSeconds(15)) + .WithCommunicationTimeout(TimeSpan.FromMilliseconds(ConnectTimeoutMs)) .Build(); - _client.UseConnectedHandler(async e => - { - _logDebug("Connected to MQTT Broker."); - if (Topicos?.Any(x => x.Inscrever) ?? false) - { - foreach (var topic in Topicos) - { - await SubscribeAsync(topic); - } - } - }); + ConfigureHandlers(); + RegisterAsActiveClient(); - _client.UseDisconnectedHandler(e => - { - _logDebug("Disconnected from MQTT Broker."); - }); + _reconnectTimer = new AsyncTaskTimerModel( + "mqtt-reconnect-" + SanitizeTimerId(_clientId) + "@" + SanitizeTimerId(_brokerAddr) + "-" + _brokerPort, + ReconnectTickAsync, + ReconnectCheckIntervalMs, + timeout: ConnectTimeoutMs + 2000, + scheduleMode: AsyncTaskTimerScheduleMode.FixedRateSkipMissed, + runImmediately: true); - _client.UseApplicationMessageReceivedHandler(e => - { - var payloadBytes = e.ApplicationMessage.Payload ?? Array.Empty(); - var messageText = string.Empty; + _reconnectTimer.DebugMessages = false; - // Só tenta decodificar texto se for realmente necessário - try { messageText = Encoding.UTF8.GetString(payloadBytes); } catch { /* binário puro */ } - - //Console.WriteLine($"Message received on topic {e.ApplicationMessage.Topic}"); - // Aqui você pode adicionar o código para lidar com a mensagem recebida - - MqttTopicosModel topico = Topicos.FirstOrDefault(x => x.Topico == e.ApplicationMessage.Topic); - - if (topico != null) - { - var Mensagem = new MqttTopicosMensagensModel() - { - Momento = DateTime.Now, - Cliente = e.ClientId, - Mensagem = messageText, - Bytes = payloadBytes - }; - topico.Mensagens.Add(Mensagem); - - // Limitar o número de mensagens mantidas, removendo as mais antigas se necessário - while (topico.Mensagens.Count > topico.MensagensManter) - { - topico.Mensagens.RemoveAt(0); // Remove a mensagem mais antiga (index 0) - } - - if (topico.Callback != null) - { - _ = Task.Run(async () => - { - try - { - await topico.Callback(Mensagem); // await real e funcional - } - catch (Exception ex) - { - _logDebug($"Erro no callback do tópico '{topico.Topico}': {ex}"); - } - }); - } - } - }); - - Topicos = new List(); - - tmrCheck?.Dispose(); - tmrCheck = new AsyncTaskTimerModel("tmrCheck", tmrCheck_Tick, 1000); - tmrCheck.Start(); + // Compatibilidade: o serviço antigo começava a tentar conectar no construtor. + Volatile.Write(ref _started, 1); + _reconnectTimer.Start(); } - private async Task tmrCheck_Tick() + private void ConfigureHandlers() { - if (!_client.IsConnected) + _client.UseConnectedHandler(async e => { - await ConnectAsync(); + await HandleConnectedAsync().ConfigureAwait(false); + }); + + _client.UseDisconnectedHandler(async e => + { + await HandleDisconnectedAsync(e).ConfigureAwait(false); + }); + + _client.UseApplicationMessageReceivedHandler(async e => + { + await HandleApplicationMessageAsync(e).ConfigureAwait(false); + }); + } + + private void RegisterAsActiveClient() + { + MqttService previous = null; + + lock (_registryLock) + { + if (_activeClients.TryGetValue(_registryKey, out previous) && + !ReferenceEquals(previous, this)) + { + _activeClients[_registryKey] = this; + } + else + { + _activeClients[_registryKey] = this; + previous = null; + } } + + if (previous != null) + { + previous.RequestSuperseded(); + SafeLog("Uma instância MQTT anterior com o mesmo broker/client-id foi desarmada."); + } + } + + private void RequestSuperseded() + { + Volatile.Write(ref _started, 0); + Volatile.Write(ref _manualDisconnect, 1); + + try { _reconnectTimer?.Stop(); } catch { } + + // Uma única tarefa encerra a instância substituída. O registry já aponta + // para a nova instância, portanto a antiga não deve remover a chave. + Task stopTask = GetOrCreateStopTask(removeFromRegistry: false); + + _ = stopTask.ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + SafeLog("Erro ao encerrar instância MQTT substituída: " + t.Exception.GetBaseException().Message); + }, TaskScheduler.Default); + } + + public async Task StartAsync() + { + ThrowIfDisposed(); + + Volatile.Write(ref _manualDisconnect, 0); + Volatile.Write(ref _stopping, 0); + Volatile.Write(ref _started, 1); + + if (!_reconnectTimer.IsRunning) + _reconnectTimer.Start(); + + await ConnectAsync().ConfigureAwait(false); } public bool StatusConexao() { - return _client.IsConnected; + return !IsDisposed && _client.IsConnected; } public async Task ConnectAsync() { + ThrowIfDisposed(); + + Volatile.Write(ref _manualDisconnect, 0); + + if (IsStopping || Volatile.Read(ref _started) == 0) + return; + + await ConnectInternalAsync(_serviceCts.Token).ConfigureAwait(false); + } + + private async Task ConnectInternalAsync(CancellationToken cancellationToken) + { + if (_client.IsConnected) + return true; + + if (IsDisposed || IsStopping || Volatile.Read(ref _manualDisconnect) == 1) + return false; + + bool lockTaken = false; + long started = Stopwatch.GetTimestamp(); + try { - await _client.ConnectAsync(_options); + await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false); + lockTaken = true; + + if (_client.IsConnected) + return true; + + if (IsDisposed || IsStopping || Volatile.Read(ref _manualDisconnect) == 1) + return false; + + Interlocked.Increment(ref _connectionAttempts); + SafeLog("Conectando ao broker MQTT " + _brokerAddr + ":" + _brokerPort + "..."); + + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeoutCts.CancelAfter(ConnectTimeoutMs); + + try + { + await _client.ConnectAsync(_options, timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + RegisterError("Timeout ao conectar ao broker MQTT."); + ScheduleReconnect(increaseBackoff: true); + return false; + } + } + + return _client.IsConnected; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; } catch (Exception ex) { - _logDebug("Erro ao tentar se conectar ao MQTT Broker: " + ex.Message); + RegisterError("Erro ao conectar ao broker MQTT: " + ex.Message); + ScheduleReconnect(increaseBackoff: true); + return false; } + finally + { + double elapsed = ElapsedMilliseconds(started); + lock (_metricsLock) + { + _lastConnectDurationMs = elapsed; + } + + if (lockTaken) + _connectLock.Release(); + } + } + + private async Task ReconnectTickAsync(CancellationToken cancellationToken) + { + if (IsDisposed || IsStopping || Volatile.Read(ref _started) == 0) + return; + + if (Volatile.Read(ref _manualDisconnect) == 1) + return; + + if (_client.IsConnected) + return; + + long next = Interlocked.Read(ref _nextReconnectTimestamp); + long now = Stopwatch.GetTimestamp(); + + if (next > 0 && now < next) + return; + + await ConnectInternalAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task HandleConnectedAsync() + { + if (IsDisposed || IsStopping) + return; + + Interlocked.Increment(ref _connectionSuccesses); + + lock (_metricsLock) + { + _lastConnectedUtc = DateTime.UtcNow; + _lastError = null; + } + + lock (_stateLock) + { + _reconnectBackoffSeconds = ReconnectBackoffInitialSeconds; + Interlocked.Exchange(ref _nextReconnectTimestamp, 0L); + } + + SafeLog("Conectado ao MQTT Broker."); + + MqttTopicosModel[] subscriptions = GetTopicsSnapshot() + .Where(x => x != null && x.Inscrever && x.Habilitado) + .ToArray(); + + foreach (MqttTopicosModel topic in subscriptions) + { + topic.MarcarNaoInscrito(); + } + + foreach (MqttTopicosModel topic in subscriptions) + { + if (IsDisposed || IsStopping || !_client.IsConnected) + break; + + await SubscribeInternalAsync(topic, _serviceCts.Token).ConfigureAwait(false); + } + } + + private Task HandleDisconnectedAsync(MqttClientDisconnectedEventArgs e) + { + Interlocked.Increment(ref _disconnects); + + lock (_metricsLock) + { + _lastDisconnectedUtc = DateTime.UtcNow; + } + + foreach (MqttTopicosModel topic in GetTopicsSnapshot()) + { + topic?.MarcarNaoInscrito(); + } + + string reason = e == null + ? "motivo não informado" + : (e.Exception != null ? e.Exception.Message : e.Reason.ToString()); + + SafeLog("Desconectado do MQTT Broker: " + reason); + + if (!IsDisposed && + !IsStopping && + Volatile.Read(ref _started) == 1 && + Volatile.Read(ref _manualDisconnect) == 0) + { + ScheduleReconnect(increaseBackoff: true); + } + + return Task.CompletedTask; + } + + private void ScheduleReconnect(bool increaseBackoff) + { + if (IsDisposed || IsStopping || Volatile.Read(ref _manualDisconnect) == 1) + return; + + double delaySeconds; + + lock (_stateLock) + { + double current = Math.Max(ReconnectBackoffInitialSeconds, _reconnectBackoffSeconds); + double jitter = 0.85 + (_random.NextDouble() * 0.30); // 85% a 115% + delaySeconds = Math.Min(ReconnectBackoffMaxSeconds, current * jitter); + + if (increaseBackoff) + { + _reconnectBackoffSeconds = Math.Min( + ReconnectBackoffMaxSeconds, + Math.Max(ReconnectBackoffInitialSeconds, current * 1.8)); + } + } + + long due = Stopwatch.GetTimestamp() + MillisecondsToStopwatchTicks(delaySeconds * 1000.0); + Interlocked.Exchange(ref _nextReconnectTimestamp, due); + Interlocked.Increment(ref _reconnectSchedules); } public async Task DisconnectAsync() { - await _client.DisconnectAsync(); + if (IsDisposed) + return; + + Volatile.Write(ref _manualDisconnect, 1); + Interlocked.Exchange(ref _nextReconnectTimestamp, 0L); + + if (!_client.IsConnected) + return; + + try + { + await _client.DisconnectAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + RegisterError("Erro ao desconectar MQTT: " + ex.Message); + } } - public async Task PublishAsync(MqttTopicosModel topic, string message, bool Forcar = false) + public async Task StopAsync() { - if (topic == null || (topic.Inscrever && !Forcar)) + await GetOrCreateStopTask(removeFromRegistry: true).ConfigureAwait(false); + } + + private Task GetOrCreateStopTask(bool removeFromRegistry) + { + lock (_stopTaskLock) { - return; + if (_stopTask == null) + _stopTask = StopCoreAsync(removeFromRegistry); + + return _stopTask; + } + } + + private async Task StopCoreAsync(bool removeFromRegistry) + { + Interlocked.Exchange(ref _stopping, 1); + Volatile.Write(ref _started, 0); + Volatile.Write(ref _manualDisconnect, 1); + + try { _serviceCts.Cancel(); } catch { } + + try + { + await _reconnectTimer.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + SafeLog("Erro ao parar timer de reconexão MQTT: " + ex.Message); + } + + MqttTopicosModel[] topics = GetTopicsSnapshot(); + foreach (MqttTopicosModel topic in topics) + { + if (topic != null) + await topic.PararWorkerAsync().ConfigureAwait(false); } if (_client.IsConnected) { try { - var payload = Encoding.UTF8.GetBytes(message); - var mqttMessage = new MqttApplicationMessageBuilder() - .WithTopic(topic.Topico) - .WithPayload(payload) - .Build(); - - await _client.PublishAsync(mqttMessage); + await _client.DisconnectAsync().ConfigureAwait(false); } catch (Exception ex) { - _logDebug(ex.Message); + SafeLog("Erro ao desconectar MQTT durante encerramento: " + ex.Message); + } + } + + if (removeFromRegistry) + { + lock (_registryLock) + { + MqttService current; + if (_activeClients.TryGetValue(_registryKey, out current) && + ReferenceEquals(current, this)) + { + _activeClients.Remove(_registryKey); + } } } } + // ----------------------------------------------------------------- + // PUBLICAÇÃO + // ----------------------------------------------------------------- + + /// + /// Assinatura antiga preservada. Para obter o resultado detalhado, + /// use PublishWithResultAsync. + /// + public async Task PublishAsync(MqttTopicosModel topic, string message, bool Forcar = false) + { + await PublishWithResultAsync(topic, message, Forcar).ConfigureAwait(false); + } + + /// + /// Assinatura antiga preservada. Para obter o resultado detalhado, + /// use PublishWithResultAsync. + /// public async Task PublishAsync(MqttTopicosModel topic, byte[] payloadBytes, bool Forcar = false) { - if (topic.Inscrever || (topic.Inscrever && !Forcar)) + await PublishWithResultAsync(topic, payloadBytes, Forcar).ConfigureAwait(false); + } + + public Task PublishWithResultAsync( + MqttTopicosModel topic, + string message, + bool forcar = false, + CancellationToken cancellationToken = default(CancellationToken)) + { + byte[] payload = Encoding.UTF8.GetBytes(message ?? string.Empty); + return PublishCoreAsync(topic, payload, forcar, cancellationToken); + } + + public Task PublishWithResultAsync( + MqttTopicosModel topic, + byte[] payloadBytes, + bool forcar = false, + CancellationToken cancellationToken = default(CancellationToken)) + { + return PublishCoreAsync( + topic, + payloadBytes ?? Array.Empty(), + forcar, + cancellationToken); + } + + private async Task PublishCoreAsync( + MqttTopicosModel topic, + byte[] payloadBytes, + bool forcar, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _publishAttempts); + + if (topic == null) + return MqttPublishResult.Failure(MqttPublishStatus.InvalidTopic, "Tópico MQTT nulo."); + + if (string.IsNullOrWhiteSpace(topic.Topico)) + return MqttPublishResult.Failure(MqttPublishStatus.InvalidTopic, "Nome do tópico MQTT vazio."); + + if (!topic.Habilitado) + return MqttPublishResult.Failure(MqttPublishStatus.TopicDisabled, "Tópico desabilitado."); + + if (topic.Inscrever && !forcar) + return MqttPublishResult.Failure(MqttPublishStatus.PublishNotAllowed, "O tópico foi configurado somente para inscrição."); + + if (IsDisposed || IsStopping) + return MqttPublishResult.Failure(MqttPublishStatus.ServiceStopping, "Serviço MQTT encerrando."); + + if (!_client.IsConnected) { - return; + Interlocked.Increment(ref _publishDroppedDisconnected); + return MqttPublishResult.Failure(MqttPublishStatus.Disconnected, "Cliente MQTT desconectado."); } - if (_client.IsConnected) + long started = Stopwatch.GetTimestamp(); + bool gateTaken = false; + + try { - try + using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _serviceCts.Token)) { + linkedCts.CancelAfter(PublishTimeoutMs); + await _publishLock.WaitAsync(linkedCts.Token).ConfigureAwait(false); + gateTaken = true; + + if (!_client.IsConnected) + { + Interlocked.Increment(ref _publishDroppedDisconnected); + return MqttPublishResult.Failure(MqttPublishStatus.Disconnected, "Cliente MQTT desconectou antes da publicação."); + } + var mqttMessage = new MqttApplicationMessageBuilder() .WithTopic(topic.Topico) .WithPayload(payloadBytes) + .WithQualityOfServiceLevel(ToMqttNetQos(topic.QoS)) + .WithRetainFlag(topic.Retain) .Build(); - await _client.PublishAsync(mqttMessage); + await _client.PublishAsync(mqttMessage, linkedCts.Token).ConfigureAwait(false); } - catch (Exception ex) + + double elapsed = ElapsedMilliseconds(started); + Interlocked.Increment(ref _publishSuccesses); + Interlocked.Add(ref _publishedBytes, payloadBytes.LongLength); + + lock (_metricsLock) { - _logDebug(ex.Message); + _lastPublishUtc = DateTime.UtcNow; + _lastPublishDurationMs = elapsed; + } + + topic.RegistrarPublicacao(payloadBytes.LongLength, elapsed, true, null); + + return MqttPublishResult.Success(payloadBytes.Length, elapsed); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_serviceCts.IsCancellationRequested) + { + double elapsed = ElapsedMilliseconds(started); + Interlocked.Increment(ref _publishTimeouts); + Interlocked.Increment(ref _publishFailures); + + string error = "Timeout ao publicar no tópico '" + topic.Topico + "'."; + RegisterError(error); + topic.RegistrarPublicacao(payloadBytes.LongLength, elapsed, false, error); + + return MqttPublishResult.Failure(MqttPublishStatus.Timeout, error, payloadBytes.Length, elapsed); + } + catch (OperationCanceledException) + { + double elapsed = ElapsedMilliseconds(started); + Interlocked.Increment(ref _publishFailures); + return MqttPublishResult.Failure(MqttPublishStatus.Canceled, "Publicação cancelada.", payloadBytes.Length, elapsed); + } + catch (Exception ex) + { + double elapsed = ElapsedMilliseconds(started); + Interlocked.Increment(ref _publishFailures); + + string error = "Erro ao publicar no tópico '" + topic.Topico + "': " + ex.Message; + RegisterError(error); + topic.RegistrarPublicacao(payloadBytes.LongLength, elapsed, false, error); + + return MqttPublishResult.Failure(MqttPublishStatus.Error, error, payloadBytes.Length, elapsed); + } + finally + { + if (gateTaken) + _publishLock.Release(); + } + } + + private static MqttQualityOfServiceLevel ToMqttNetQos(MqttQosLevel qos) + { + switch (qos) + { + case MqttQosLevel.AtLeastOnce: + return MqttQualityOfServiceLevel.AtLeastOnce; + + case MqttQosLevel.ExactlyOnce: + return MqttQualityOfServiceLevel.ExactlyOnce; + + default: + return MqttQualityOfServiceLevel.AtMostOnce; + } + } + + private static MqttQosLevel FromMqttNetQos(MqttQualityOfServiceLevel qos) + { + switch (qos) + { + case MqttQualityOfServiceLevel.AtLeastOnce: + return MqttQosLevel.AtLeastOnce; + + case MqttQualityOfServiceLevel.ExactlyOnce: + return MqttQosLevel.ExactlyOnce; + + default: + return MqttQosLevel.AtMostOnce; + } + } + + // ----------------------------------------------------------------- + // TÓPICOS / INSCRIÇÃO + // ----------------------------------------------------------------- + + public async Task AdicionarNovoTopico( + string topico, + bool inscrever = false, + int mensagensManter = 2, + Func callback = null) + { + return await AdicionarNovoTopico( + topico, + inscrever, + mensagensManter, + callback, + MqttPayloadType.Auto, + MqttDispatchMode.Sequential, + queueCapacity: 64, + overflowPolicy: MqttQueueOverflowPolicy.DropOldest, + qos: MqttQosLevel.AtMostOnce, + retain: false).ConfigureAwait(false); + } + + public async Task AdicionarNovoTopico( + string topico, + bool inscrever, + int mensagensManter, + Func callback, + MqttPayloadType payloadType, + MqttDispatchMode dispatchMode, + int queueCapacity = 64, + MqttQueueOverflowPolicy overflowPolicy = MqttQueueOverflowPolicy.DropOldest, + MqttQosLevel qos = MqttQosLevel.AtMostOnce, + bool retain = false) + { + ThrowIfDisposed(); + + if (string.IsNullOrWhiteSpace(topico)) + throw new ArgumentException("O nome do tópico MQTT é obrigatório.", nameof(topico)); + + if (mensagensManter < 0) + throw new ArgumentOutOfRangeException(nameof(mensagensManter)); + + if (queueCapacity <= 0) + throw new ArgumentOutOfRangeException(nameof(queueCapacity)); + + MqttTopicosModel topic; + + lock (_topicsLock) + { + Topicos.RemoveAll(x => x == null); + + topic = Topicos.FirstOrDefault(x => + string.Equals(x.Topico, topico, StringComparison.Ordinal)); + + if (topic == null) + { + topic = new MqttTopicosModel( + owner: this, + serviceToken: _serviceCts.Token) + { + Topico = topico, + Inscrever = inscrever, + MensagensManter = mensagensManter, + Callback = callback, + PayloadType = payloadType, + DispatchMode = dispatchMode, + QueueCapacity = queueCapacity, + OverflowPolicy = overflowPolicy, + QoS = qos, + Retain = retain, + Habilitado = true, + }; + + Topicos.Add(topic); + } + else + { + topic.AtualizarConfiguracao( + inscrever, + mensagensManter, + callback, + payloadType, + dispatchMode, + queueCapacity, + overflowPolicy, + qos, + retain); } } + + topic.GarantirWorkerIniciado(); + + if (topic.Inscrever && _client.IsConnected) + await SubscribeInternalAsync(topic, _serviceCts.Token).ConfigureAwait(false); + + // Diferente da versão antiga, cadastrar um tópico não apaga retained. + // A limpeza agora é sempre uma ação explícita via LimparMensagensRetidas. + return topic; } public async Task SubscribeAsync(MqttTopicosModel topic) { + if (topic == null || !topic.Inscrever || !topic.Habilitado) + return; + + if (!_client.IsConnected) + await ConnectAsync().ConfigureAwait(false); + + if (_client.IsConnected) + await SubscribeInternalAsync(topic, _serviceCts.Token).ConfigureAwait(false); + } + + private async Task SubscribeInternalAsync( + MqttTopicosModel topic, + CancellationToken cancellationToken) + { + if (topic == null || !topic.Inscrever || !topic.Habilitado) + return false; + + if (!_client.IsConnected || IsDisposed || IsStopping) + return false; + + if (!await topic.EntrarInscricaoAsync(cancellationToken).ConfigureAwait(false)) + return topic.Inscrito; + try { - if (!_client.IsConnected) + if (topic.Inscrito) + return true; + + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _serviceCts.Token)) { - await ConnectAsync(); + timeoutCts.CancelAfter(SubscribeTimeoutMs); + + var filter = new TopicFilterBuilder() + .WithTopic(topic.Topico) + .WithQualityOfServiceLevel(ToMqttNetQos(topic.QoS)) + .Build(); + + var subscribeOptions = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(filter) + .Build(); + + await _client.SubscribeAsync( + subscribeOptions, + timeoutCts.Token + ).ConfigureAwait(false); } - await _client.SubscribeAsync(new TopicFilterBuilder().WithTopic(topic.Topico).Build()); - _logDebug($"Subscribed to topic: {topic.Topico}"); + + topic.MarcarInscrito(); + SafeLog("Inscrito no tópico: " + topic.Topico); + return true; + } + catch (OperationCanceledException) + { + topic.MarcarNaoInscrito(); + return false; } catch (Exception ex) { - _logDebug($"An error occurred while subscribing from the topic: {ex.Message}"); + topic.MarcarNaoInscrito(); + RegisterError("Erro ao inscrever no tópico '" + topic.Topico + "': " + ex.Message); + return false; + } + finally + { + topic.SairInscricao(); } } public async Task UnsubscribeAsync(MqttTopicosModel topic) { if (topic == null || !topic.Inscrever) - { return; - } + + topic.MarcarNaoInscrito(); + + if (!_client.IsConnected) + return; + try { - if (!_client.IsConnected) + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_serviceCts.Token)) { - await ConnectAsync(); + timeoutCts.CancelAfter(SubscribeTimeoutMs); + + var unsubscribeOptions = new MqttClientUnsubscribeOptionsBuilder() + .WithTopicFilter(topic.Topico) + .Build(); + + await _client.UnsubscribeAsync( + unsubscribeOptions, + timeoutCts.Token + ).ConfigureAwait(false); } - await _client.UnsubscribeAsync(new string[] { topic.Topico }); - _logDebug($"Unsubscribed from topic: {topic.Topico}"); + + SafeLog("Desinscrito do tópico: " + topic.Topico); + } + catch (OperationCanceledException) + { } catch (Exception ex) { - _logDebug($"An error occurred while unsubscribing from the topic: {ex.Message}"); + RegisterError("Erro ao desinscrever do tópico '" + topic.Topico + "': " + ex.Message); } } - public async Task AdicionarNovoTopico(string topico, bool inscrever = false, int mensagensManter = 2, Func callback = null) - { - MqttTopicosModel _mqttTopico = null; - - Topicos.Remove(null); - - _mqttTopico = Topicos.FirstOrDefault(x => x.Topico == topico); - - if (_mqttTopico == null) - { - _mqttTopico = new MqttTopicosModel() - { - Topico = topico, - Mensagens = new List(), - Inscrever = inscrever, - MensagensManter = mensagensManter, - Callback = callback - }; - - Topicos.Add(_mqttTopico); - - if (_mqttTopico.Inscrever) - { - await SubscribeAsync(_mqttTopico); - } - - await LimparMensagensRetidas(_mqttTopico.Topico); - } - - return _mqttTopico; - } - public async Task LimparMensagensRetidas(string topico) { - if (_client.IsConnected) + if (string.IsNullOrWhiteSpace(topico)) + throw new ArgumentException("O tópico é obrigatório.", nameof(topico)); + + if (!_client.IsConnected) + { + SafeLog("Não foi possível limpar retained de '" + topico + "': cliente desconectado."); + return; + } + + try { var mensagem = new MqttApplicationMessageBuilder() .WithTopic(topico) - .WithPayload(string.Empty) // Mensagem vazia - .WithRetainFlag(true) // Marca como retained + .WithPayload(Array.Empty()) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .WithRetainFlag(true) .Build(); - await _client.PublishAsync(mensagem); - _logDebug($"Mensagem retida limpa para o tópico: {topico}"); + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_serviceCts.Token)) + { + timeoutCts.CancelAfter(PublishTimeoutMs); + await _client.PublishAsync(mensagem, timeoutCts.Token).ConfigureAwait(false); + } + + SafeLog("Mensagem retida limpa para o tópico: " + topico); + } + catch (Exception ex) + { + RegisterError("Erro ao limpar retained de '" + topico + "': " + ex.Message); } } - public class MqttTopicosModel + // ----------------------------------------------------------------- + // RECEPÇÃO / DISPATCH + // ----------------------------------------------------------------- + + private async Task HandleApplicationMessageAsync(MqttApplicationMessageReceivedEventArgs e) { + if (e == null || e.ApplicationMessage == null || IsDisposed || IsStopping) + return; + + string topicName = e.ApplicationMessage.Topic ?? string.Empty; + byte[] sourcePayload = e.ApplicationMessage.Payload ?? Array.Empty(); + + // Cópia defensiva. O callback não depende do ciclo de vida interno do MQTTnet. + byte[] payload = sourcePayload.Length == 0 + ? Array.Empty() + : sourcePayload.ToArray(); + + Interlocked.Increment(ref _messagesReceived); + Interlocked.Add(ref _receivedBytes, payload.LongLength); + + lock (_metricsLock) + { + _lastReceiveUtc = DateTime.UtcNow; + } + + MqttTopicosModel topic = FindSubscribedTopic(topicName); + + if (topic == null || !topic.Habilitado) + { + Interlocked.Increment(ref _messagesWithoutTopic); + return; + } + + var mensagem = new MqttTopicosMensagensModel + { + Momento = DateTime.Now, + MomentoUtc = DateTime.UtcNow, + RecebidoMonotonicTimestamp = Stopwatch.GetTimestamp(), + Cliente = e.ClientId, + Topico = topicName, + Bytes = payload, + Retained = e.ApplicationMessage.Retain, + QoS = FromMqttNetQos(e.ApplicationMessage.QualityOfServiceLevel), + PayloadType = topic.PayloadType, + }; + + topic.RegistrarRecebimento(mensagem); + + if (topic.Callback == null) + return; + + if (topic.DispatchMode == MqttDispatchMode.Inline) + { + // Somente para callbacks explicitamente mínimos, como atualizar um timestamp. + // O padrão permanece Sequential para não bloquear o loop MQTT. + try + { + await topic.Callback(mensagem).ConfigureAwait(false); + topic.RegistrarCallbackSucesso(); + } + catch (Exception ex) + { + Interlocked.Increment(ref _callbackFailures); + topic.RegistrarCallbackFalha(ex); + RegisterError("Erro no callback inline do tópico '" + topic.Topico + "': " + ex.Message); + } + + return; + } + + bool accepted = topic.Enfileirar(mensagem); + if (!accepted) + { + Interlocked.Increment(ref _dispatchDropped); + } + } + + private MqttTopicosModel FindSubscribedTopic(string actualTopic) + { + MqttTopicosModel[] snapshot = GetTopicsSnapshot(); + + // Primeiro procura correspondência exata, que é o caso mais comum e rápido. + MqttTopicosModel exact = snapshot.FirstOrDefault(x => + x != null && + x.Inscrever && + string.Equals(x.Topico, actualTopic, StringComparison.Ordinal)); + + if (exact != null) + return exact; + + return snapshot.FirstOrDefault(x => + x != null && + x.Inscrever && + TopicMatchesFilter(x.Topico, actualTopic)); + } + + private static bool TopicMatchesFilter(string filter, string topic) + { + if (string.IsNullOrEmpty(filter) || string.IsNullOrEmpty(topic)) + return false; + + string[] f = filter.Split('/'); + string[] t = topic.Split('/'); + + int i = 0; + for (; i < f.Length; i++) + { + if (f[i] == "#") + return i == f.Length - 1; + + if (i >= t.Length) + return false; + + if (f[i] == "+") + continue; + + if (!string.Equals(f[i], t[i], StringComparison.Ordinal)) + return false; + } + + return i == t.Length; + } + + private MqttTopicosModel[] GetTopicsSnapshot() + { + lock (_topicsLock) + { + return Topicos + .Where(x => x != null) + .ToArray(); + } + } + + internal void NotifyCallbackFailure(MqttTopicosModel topic, Exception ex) + { + Interlocked.Increment(ref _callbackFailures); + RegisterError("Erro no callback do tópico '" + topic.Topico + "': " + ex.Message); + } + + internal void NotifyDispatchDropped() + { + Interlocked.Increment(ref _dispatchDropped); + } + + // ----------------------------------------------------------------- + // MÉTRICAS + // ----------------------------------------------------------------- + + public MqttServiceMetrics GetMetrics() + { + DateTime lastConnected; + DateTime lastDisconnected; + DateTime lastPublish; + DateTime lastReceive; + DateTime lastErrorUtc; + string lastError; + double lastPublishDuration; + double lastConnectDuration; + + lock (_metricsLock) + { + lastConnected = _lastConnectedUtc; + lastDisconnected = _lastDisconnectedUtc; + lastPublish = _lastPublishUtc; + lastReceive = _lastReceiveUtc; + lastErrorUtc = _lastErrorUtc; + lastError = _lastError; + lastPublishDuration = _lastPublishDurationMs; + lastConnectDuration = _lastConnectDurationMs; + } + + MqttTopicosModel[] topics = GetTopicsSnapshot(); + + return new MqttServiceMetrics + { + BrokerAddress = _brokerAddr, + BrokerPort = _brokerPort, + ClientId = _clientId, + Local = _local, + Started = Volatile.Read(ref _started) == 1, + Connected = _client.IsConnected, + Stopping = IsStopping, + Disposed = IsDisposed, + ManualDisconnect = Volatile.Read(ref _manualDisconnect) == 1, + + ConnectionAttempts = Interlocked.Read(ref _connectionAttempts), + ConnectionSuccesses = Interlocked.Read(ref _connectionSuccesses), + Disconnects = Interlocked.Read(ref _disconnects), + ReconnectSchedules = Interlocked.Read(ref _reconnectSchedules), + CurrentReconnectBackoffSeconds = GetReconnectBackoff(), + NextReconnectInMs = GetNextReconnectInMs(), + + PublishAttempts = Interlocked.Read(ref _publishAttempts), + PublishSuccesses = Interlocked.Read(ref _publishSuccesses), + PublishFailures = Interlocked.Read(ref _publishFailures), + PublishDroppedDisconnected = Interlocked.Read(ref _publishDroppedDisconnected), + PublishTimeouts = Interlocked.Read(ref _publishTimeouts), + PublishedBytes = Interlocked.Read(ref _publishedBytes), + + MessagesReceived = Interlocked.Read(ref _messagesReceived), + ReceivedBytes = Interlocked.Read(ref _receivedBytes), + MessagesWithoutTopic = Interlocked.Read(ref _messagesWithoutTopic), + CallbackFailures = Interlocked.Read(ref _callbackFailures), + DispatchDropped = Interlocked.Read(ref _dispatchDropped), + + LastConnectedUtc = lastConnected, + LastDisconnectedUtc = lastDisconnected, + LastPublishUtc = lastPublish, + LastReceiveUtc = lastReceive, + LastErrorUtc = lastErrorUtc, + LastError = lastError, + LastPublishDurationMs = lastPublishDuration, + LastConnectDurationMs = lastConnectDuration, + + Topics = topics.Select(x => x.GetMetrics()).ToList(), + ReconnectTimer = _reconnectTimer.GetMetrics(), + }; + } + + private double GetReconnectBackoff() + { + lock (_stateLock) + { + return _reconnectBackoffSeconds; + } + } + + private double GetNextReconnectInMs() + { + long next = Interlocked.Read(ref _nextReconnectTimestamp); + if (next <= 0) + return 0.0; + + long remaining = next - Stopwatch.GetTimestamp(); + return Math.Max(0.0, StopwatchTicksToMilliseconds(remaining)); + } + + private void RegisterError(string message) + { + lock (_metricsLock) + { + _lastError = message; + _lastErrorUtc = DateTime.UtcNow; + } + + SafeLog(message); + } + + private void SafeLog(string message) + { + try + { + _logDebug("[" + _clientId + "] " + message); + } + catch + { + // Logging nunca deve comprometer o transporte. + } + } + + private void ThrowIfDisposed() + { + if (IsDisposed) + throw new ObjectDisposedException(nameof(MqttService)); + } + + private static string SanitizeTimerId(string value) + { + if (string.IsNullOrEmpty(value)) + return "mqtt"; + + var sb = new StringBuilder(value.Length); + foreach (char c in value) + { + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') + sb.Append(c); + else + sb.Append('_'); + } + + return sb.ToString(); + } + + private static long MillisecondsToStopwatchTicks(double milliseconds) + { + return (long)Math.Ceiling(milliseconds * Stopwatch.Frequency / 1000.0); + } + + private static double StopwatchTicksToMilliseconds(long ticks) + { + return ticks * 1000.0 / Stopwatch.Frequency; + } + + private static double ElapsedMilliseconds(long startedTimestamp) + { + return StopwatchTicksToMilliseconds(Stopwatch.GetTimestamp() - startedTimestamp); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + + try + { + GetOrCreateStopTask(removeFromRegistry: true).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + SafeLog("Erro no Dispose MQTT: " + ex.Message); + } + + try { _reconnectTimer.Dispose(); } catch { } + try { _client.Dispose(); } catch { } + try { _serviceCts.Dispose(); } catch { } + try { _connectLock.Dispose(); } catch { } + try { _publishLock.Dispose(); } catch { } + } + + public async Task DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + + try + { + await GetOrCreateStopTask(removeFromRegistry: true).ConfigureAwait(false); + } + finally + { + try { _reconnectTimer.Dispose(); } catch { } + try { _client.Dispose(); } catch { } + try { _serviceCts.Dispose(); } catch { } + try { _connectLock.Dispose(); } catch { } + try { _publishLock.Dispose(); } catch { } + } + } + + // ================================================================= + // MODELOS DE TÓPICO E MENSAGEM + // ================================================================= + + public sealed class MqttTopicosModel + { + private readonly object _historyLock = new object(); + private readonly object _queueLock = new object(); + private readonly object _metricsLock = new object(); + private readonly SemaphoreSlim _queueSignal = new SemaphoreSlim(0, int.MaxValue); + private readonly SemaphoreSlim _subscribeLock = new SemaphoreSlim(1, 1); + private readonly CancellationTokenSource _workerCts; + private readonly ConcurrentQueue _queue = + new ConcurrentQueue(); + + private readonly MqttService _owner; + private Task _workerTask = Task.CompletedTask; + private MqttTopicosMensagensModel _latestPending; + + private int _workerStarted; + private int _pendingCount; + private int _subscribed; + private int _subscribeLockHeld; + + private long _messagesReceived; + private long _receivedBytes; + private long _messagesQueued; + private long _messagesProcessed; + private long _messagesDropped; + private long _messagesReplaced; + private long _callbackFailures; + private long _publishedMessages; + private long _publishedBytes; + private long _publishFailures; + + private DateTime _lastMessageUtc = DateTime.MinValue; + private DateTime _lastProcessedUtc = DateTime.MinValue; + private DateTime _lastPublishUtc = DateTime.MinValue; + private string _lastError; + private double _lastCallbackDurationMs; + private double _lastPublishDurationMs; + + internal MqttTopicosModel(MqttService owner, CancellationToken serviceToken) + { + _owner = owner; + _workerCts = CancellationTokenSource.CreateLinkedTokenSource(serviceToken); + Mensagens = new List(); + } + + // Construtor público mantido para compatibilidade com eventuais criações externas. + public MqttTopicosModel() + { + _workerCts = new CancellationTokenSource(); + Mensagens = new List(); + } + public string Topico { get; set; } public int MensagensManter { get; set; } = 30; public List Mensagens { get; set; } public bool Inscrever { get; set; } public Func Callback { get; set; } + + public bool Habilitado { get; set; } = true; + public MqttPayloadType PayloadType { get; set; } = MqttPayloadType.Auto; + public MqttDispatchMode DispatchMode { get; set; } = MqttDispatchMode.Sequential; + public int QueueCapacity { get; set; } = 64; + public MqttQueueOverflowPolicy OverflowPolicy { get; set; } = MqttQueueOverflowPolicy.DropOldest; + public MqttQosLevel QoS { get; set; } = MqttQosLevel.AtMostOnce; + public bool Retain { get; set; } + public bool Inscrito { get { return Volatile.Read(ref _subscribed) == 1; } } + + internal void AtualizarConfiguracao( + bool inscrever, + int mensagensManter, + Func callback, + MqttPayloadType payloadType, + MqttDispatchMode dispatchMode, + int queueCapacity, + MqttQueueOverflowPolicy overflowPolicy, + MqttQosLevel qos, + bool retain) + { + Inscrever = inscrever; + MensagensManter = mensagensManter; + Callback = callback; + PayloadType = payloadType; + DispatchMode = dispatchMode; + QueueCapacity = queueCapacity; + OverflowPolicy = overflowPolicy; + QoS = qos; + Retain = retain; + Habilitado = true; + } + + internal void GarantirWorkerIniciado() + { + if (Callback == null || DispatchMode == MqttDispatchMode.Inline) + return; + + if (Interlocked.CompareExchange(ref _workerStarted, 1, 0) != 0) + return; + + _workerTask = Task.Run(WorkerLoopAsync); + } + + internal void RegistrarRecebimento(MqttTopicosMensagensModel message) + { + Interlocked.Increment(ref _messagesReceived); + Interlocked.Add(ref _receivedBytes, message?.Bytes?.LongLength ?? 0L); + + lock (_metricsLock) + { + _lastMessageUtc = DateTime.UtcNow; + } + + if (MensagensManter <= 0) + return; + + lock (_historyLock) + { + if (Mensagens == null) + Mensagens = new List(); + + Mensagens.Add(message); + + while (Mensagens.Count > MensagensManter) + Mensagens.RemoveAt(0); + } + } + + internal bool Enfileirar(MqttTopicosMensagensModel message) + { + if (!Habilitado || Callback == null) + return false; + + GarantirWorkerIniciado(); + + if (DispatchMode == MqttDispatchMode.LatestOnly) + { + lock (_queueLock) + { + if (_latestPending != null) + { + Interlocked.Increment(ref _messagesReplaced); + Interlocked.Increment(ref _messagesDropped); + _owner?.NotifyDispatchDropped(); + } + + _latestPending = message; + + if (Interlocked.Exchange(ref _pendingCount, 1) == 0) + _queueSignal.Release(); + } + + Interlocked.Increment(ref _messagesQueued); + return true; + } + + int capacity = Math.Max(1, QueueCapacity); + int pending = Volatile.Read(ref _pendingCount); + + if (pending >= capacity) + { + if (OverflowPolicy == MqttQueueOverflowPolicy.DropNewest) + { + Interlocked.Increment(ref _messagesDropped); + _owner?.NotifyDispatchDropped(); + return false; + } + + MqttTopicosMensagensModel dropped; + if (_queue.TryDequeue(out dropped)) + { + Interlocked.Decrement(ref _pendingCount); + Interlocked.Increment(ref _messagesDropped); + _owner?.NotifyDispatchDropped(); + } + } + + _queue.Enqueue(message); + Interlocked.Increment(ref _pendingCount); + Interlocked.Increment(ref _messagesQueued); + _queueSignal.Release(); + return true; + } + + private async Task WorkerLoopAsync() + { + CancellationToken token = _workerCts.Token; + + try + { + while (!token.IsCancellationRequested) + { + await _queueSignal.WaitAsync(token).ConfigureAwait(false); + + MqttTopicosMensagensModel message = null; + + if (DispatchMode == MqttDispatchMode.LatestOnly) + { + lock (_queueLock) + { + message = _latestPending; + _latestPending = null; + Interlocked.Exchange(ref _pendingCount, 0); + } + } + else + { + if (_queue.TryDequeue(out message)) + Interlocked.Decrement(ref _pendingCount); + } + + if (message == null || Callback == null || !Habilitado) + continue; + + long started = Stopwatch.GetTimestamp(); + + try + { + await Callback(message).ConfigureAwait(false); + Interlocked.Increment(ref _messagesProcessed); + + lock (_metricsLock) + { + _lastProcessedUtc = DateTime.UtcNow; + _lastCallbackDurationMs = ElapsedMilliseconds(started); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + RegistrarCallbackFalha(ex); + _owner?.NotifyCallbackFailure(this, ex); + } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception ex) + { + RegistrarCallbackFalha(ex); + _owner?.NotifyCallbackFailure(this, ex); + } + } + + internal void RegistrarCallbackSucesso() + { + Interlocked.Increment(ref _messagesProcessed); + lock (_metricsLock) + { + _lastProcessedUtc = DateTime.UtcNow; + } + } + + internal void RegistrarCallbackFalha(Exception ex) + { + Interlocked.Increment(ref _callbackFailures); + lock (_metricsLock) + { + _lastError = ex?.ToString(); + } + } + + internal void RegistrarPublicacao(long bytes, double durationMs, bool success, string error) + { + if (success) + { + Interlocked.Increment(ref _publishedMessages); + Interlocked.Add(ref _publishedBytes, bytes); + } + else + { + Interlocked.Increment(ref _publishFailures); + } + + lock (_metricsLock) + { + _lastPublishUtc = DateTime.UtcNow; + _lastPublishDurationMs = durationMs; + if (!success) + _lastError = error; + } + } + + internal async Task EntrarInscricaoAsync(CancellationToken token) + { + await _subscribeLock.WaitAsync(token).ConfigureAwait(false); + Interlocked.Exchange(ref _subscribeLockHeld, 1); + return true; + } + + internal void SairInscricao() + { + if (Interlocked.Exchange(ref _subscribeLockHeld, 0) == 1) + _subscribeLock.Release(); + } + + internal void MarcarInscrito() + { + Volatile.Write(ref _subscribed, 1); + } + + internal void MarcarNaoInscrito() + { + Volatile.Write(ref _subscribed, 0); + } + + internal async Task PararWorkerAsync() + { + try { _workerCts.Cancel(); } catch { } + + try + { + if (_workerTask != null) + await _workerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch + { + } + } + + public MqttTopicMetrics GetMetrics() + { + DateTime lastMessage; + DateTime lastProcessed; + DateTime lastPublish; + string lastError; + double callbackMs; + double publishMs; + + lock (_metricsLock) + { + lastMessage = _lastMessageUtc; + lastProcessed = _lastProcessedUtc; + lastPublish = _lastPublishUtc; + lastError = _lastError; + callbackMs = _lastCallbackDurationMs; + publishMs = _lastPublishDurationMs; + } + + return new MqttTopicMetrics + { + Topic = Topico, + Subscribe = Inscrever, + Subscribed = Inscrito, + Enabled = Habilitado, + PayloadType = PayloadType, + DispatchMode = DispatchMode, + QueueCapacity = QueueCapacity, + QueueDepth = Volatile.Read(ref _pendingCount), + OverflowPolicy = OverflowPolicy, + QoS = QoS, + Retain = Retain, + + MessagesReceived = Interlocked.Read(ref _messagesReceived), + ReceivedBytes = Interlocked.Read(ref _receivedBytes), + MessagesQueued = Interlocked.Read(ref _messagesQueued), + MessagesProcessed = Interlocked.Read(ref _messagesProcessed), + MessagesDropped = Interlocked.Read(ref _messagesDropped), + MessagesReplaced = Interlocked.Read(ref _messagesReplaced), + CallbackFailures = Interlocked.Read(ref _callbackFailures), + + PublishedMessages = Interlocked.Read(ref _publishedMessages), + PublishedBytes = Interlocked.Read(ref _publishedBytes), + PublishFailures = Interlocked.Read(ref _publishFailures), + + LastMessageUtc = lastMessage, + LastProcessedUtc = lastProcessed, + LastPublishUtc = lastPublish, + LastCallbackDurationMs = callbackMs, + LastPublishDurationMs = publishMs, + LastError = lastError, + }; + } } - public class MqttTopicosMensagensModel + public sealed class MqttTopicosMensagensModel { + private string _messageText; + private bool _messageDecoded; + private byte[] _bytes = Array.Empty(); + public DateTime Momento { get; set; } + public DateTime MomentoUtc { get; set; } + public long RecebidoMonotonicTimestamp { get; set; } public string Cliente { get; set; } - public string Mensagem { get; set; } - public byte[] Bytes { get; set; } + public string Topico { get; set; } + public bool Retained { get; set; } + public MqttQosLevel QoS { get; set; } + public MqttPayloadType PayloadType { get; set; } + + /// + /// Conversão UTF-8 preguiçosa. RTCM e outros payloads binários não geram string + /// a menos que algum consumidor realmente leia esta propriedade. + /// + public string Mensagem + { + get + { + if (_messageDecoded) + return _messageText; + + if (PayloadType == MqttPayloadType.Binary) + return string.Empty; + + try + { + _messageText = Encoding.UTF8.GetString(_bytes ?? Array.Empty()); + } + catch + { + _messageText = string.Empty; + } + + _messageDecoded = true; + return _messageText; + } + set + { + _messageText = value ?? string.Empty; + _messageDecoded = true; + } + } + + public byte[] Bytes + { + get { return _bytes; } + set + { + _bytes = value ?? Array.Empty(); + if (!_messageDecoded) + _messageText = null; + } + } + + public double IdadeRecepcaoMs + { + get + { + if (RecebidoMonotonicTimestamp <= 0) + return -1.0; + + return StopwatchTicksToMilliseconds( + Stopwatch.GetTimestamp() - RecebidoMonotonicTimestamp); + } + } } -} \ No newline at end of file +} + +public enum MqttQosLevel +{ + AtMostOnce = 0, + AtLeastOnce = 1, + ExactlyOnce = 2, +} + +public enum MqttPayloadType +{ + Auto = 0, + Text = 1, + Binary = 2, +} + +public enum MqttDispatchMode +{ + /// + /// Callback executado no handler MQTT. Usar apenas para operações mínimas, + /// como atualizar um timestamp monotônico. + /// + Inline = 0, + + /// + /// Worker dedicado por tópico, preservando ordem e limitando backlog. + /// + Sequential = 1, + + /// + /// Mantém somente a mensagem pendente mais recente. + /// Ideal para posição, telemetria e estados substituíveis. + /// + LatestOnly = 2, +} + +public enum MqttQueueOverflowPolicy +{ + DropOldest = 0, + DropNewest = 1, +} + +public enum MqttPublishStatus +{ + Success = 0, + InvalidTopic = 1, + TopicDisabled = 2, + PublishNotAllowed = 3, + Disconnected = 4, + ServiceStopping = 5, + Timeout = 6, + Canceled = 7, + Error = 8, +} + +public sealed class MqttPublishResult +{ + public bool Succeeded { get; private set; } + public MqttPublishStatus Status { get; private set; } + public string Error { get; private set; } + public int PayloadBytes { get; private set; } + public double DurationMs { get; private set; } + + public static MqttPublishResult Success(int payloadBytes, double durationMs) + { + return new MqttPublishResult + { + Succeeded = true, + Status = MqttPublishStatus.Success, + PayloadBytes = payloadBytes, + DurationMs = durationMs, + }; + } + + public static MqttPublishResult Failure( + MqttPublishStatus status, + string error, + int payloadBytes = 0, + double durationMs = 0.0) + { + return new MqttPublishResult + { + Succeeded = false, + Status = status, + Error = error, + PayloadBytes = payloadBytes, + DurationMs = durationMs, + }; + } +} + +public sealed class MqttServiceMetrics +{ + public string BrokerAddress { get; set; } + public int BrokerPort { get; set; } + public string ClientId { get; set; } + public bool Local { get; set; } + public bool Started { get; set; } + public bool Connected { get; set; } + public bool Stopping { get; set; } + public bool Disposed { get; set; } + public bool ManualDisconnect { get; set; } + + public long ConnectionAttempts { get; set; } + public long ConnectionSuccesses { get; set; } + public long Disconnects { get; set; } + public long ReconnectSchedules { get; set; } + public double CurrentReconnectBackoffSeconds { get; set; } + public double NextReconnectInMs { get; set; } + + public long PublishAttempts { get; set; } + public long PublishSuccesses { get; set; } + public long PublishFailures { get; set; } + public long PublishDroppedDisconnected { get; set; } + public long PublishTimeouts { get; set; } + public long PublishedBytes { get; set; } + + public long MessagesReceived { get; set; } + public long ReceivedBytes { get; set; } + public long MessagesWithoutTopic { get; set; } + public long CallbackFailures { get; set; } + public long DispatchDropped { get; set; } + + public DateTime LastConnectedUtc { get; set; } + public DateTime LastDisconnectedUtc { get; set; } + public DateTime LastPublishUtc { get; set; } + public DateTime LastReceiveUtc { get; set; } + public DateTime LastErrorUtc { get; set; } + public string LastError { get; set; } + public double LastPublishDurationMs { get; set; } + public double LastConnectDurationMs { get; set; } + + public List Topics { get; set; } + public AsyncTaskTimerMetrics ReconnectTimer { get; set; } +} + +public sealed class MqttTopicMetrics +{ + public string Topic { get; set; } + public bool Subscribe { get; set; } + public bool Subscribed { get; set; } + public bool Enabled { get; set; } + public MqttPayloadType PayloadType { get; set; } + public MqttDispatchMode DispatchMode { get; set; } + public int QueueCapacity { get; set; } + public int QueueDepth { get; set; } + public MqttQueueOverflowPolicy OverflowPolicy { get; set; } + public MqttQosLevel QoS { get; set; } + public bool Retain { get; set; } + + public long MessagesReceived { get; set; } + public long ReceivedBytes { get; set; } + public long MessagesQueued { get; set; } + public long MessagesProcessed { get; set; } + public long MessagesDropped { get; set; } + public long MessagesReplaced { get; set; } + public long CallbackFailures { get; set; } + + public long PublishedMessages { get; set; } + public long PublishedBytes { get; set; } + public long PublishFailures { get; set; } + + public DateTime LastMessageUtc { get; set; } + public DateTime LastProcessedUtc { get; set; } + public DateTime LastPublishUtc { get; set; } + public double LastCallbackDurationMs { get; set; } + public double LastPublishDurationMs { get; set; } + public string LastError { get; set; } +} diff --git a/AgroBase/AgroBase/Services/SerialService.cs b/AgroBase/AgroBase/Services/SerialService.cs index 27387fb61..9e99b5dc5 100644 --- a/AgroBase/AgroBase/Services/SerialService.cs +++ b/AgroBase/AgroBase/Services/SerialService.cs @@ -1,8 +1,10 @@ -using AgroBase.Forms; +using AgroBase.Forms; using AgroBase.Models; using AgroBase.Services.Operadores; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.IO.Ports; using System.Linq; @@ -14,8 +16,25 @@ using static AgroBase.Models.Enums; namespace AgroBase.Services { + /// + /// Descoberta e supervisão das conexões físicas do rover. + /// + /// Garantias: + /// - somente uma varredura executa por vez; + /// - shutdown cancela e aguarda a varredura atual; + /// - leituras com timeout não abandonam threads bloqueadas; + /// - escritas na mesma porta são serializadas; + /// - uma porta que não é CAN não remove dispositivos CAN válidos; + /// - rotinas de versionamento/sincronização não se acumulam; + /// - tempos internos usam Stopwatch; + /// - operações de interface nunca bloqueiam a varredura. + /// public class SerialService { + // ============================================================ + // CONTRATO PÚBLICO LEGADO + // ============================================================ + public static int InteraloVerificacao = 5000; public static readonly string BeginLine = "@"; @@ -26,436 +45,968 @@ namespace AgroBase.Services public static readonly string SplitParams = ","; public static readonly string SplitConfig = "*"; public static readonly string SplitSubParams = "~"; - public static List DispositivosMotores = new List() { T_Code.Mov, T_Code.Dir }; - public static List DispositivosConexaoInicial = new List() { T_Code.Mvd, T_Code.Atu, T_Code.Sen }; - public static List DispositivosProprios = new List() { T_Code.Atu, T_Code.Sen }; - public static List DispositivosModbus = new List() { T_Code.Pzm, T_Code.Wit, T_Code.A05 }; - public static List DispositivosCan = new List() { T_Code.Mov, T_Code.Oid, T_Code.Dir, T_Code.Mks, T_Code.Sen, T_Code.Atu }; - public static List DispositivosMultiplos = new List() { T_Code.Bld, T_Code.Mks, T_Code.Mvd, T_Code.Oid, T_Code.Mov, T_Code.Dir }; - public static List DispositivosMapeados = new List() + public static readonly List DispositivosMotores = + new List { T_Code.Mov, T_Code.Dir }; + + public static readonly List DispositivosConexaoInicial = + new List { T_Code.Mvd, T_Code.Atu, T_Code.Sen }; + + public static readonly List DispositivosProprios = + new List { T_Code.Atu, T_Code.Sen }; + + public static readonly List DispositivosModbus = + new List { T_Code.Pzm, T_Code.Wit, T_Code.A05 }; + + public static readonly List DispositivosCan = + new List + { + T_Code.Mov, + T_Code.Oid, + T_Code.Dir, + T_Code.Mks, + T_Code.Sen, + T_Code.Atu + }; + + public static readonly List DispositivosMultiplos = + new List + { + T_Code.Bld, + T_Code.Mks, + T_Code.Mvd, + T_Code.Oid, + T_Code.Mov, + T_Code.Dir + }; + + /// + /// Mantido público por compatibilidade. + /// Código novo deve preferir GetDispositivosMapeadosSnapshot(), + /// AdicionarOuAtualizarDispositivoMapeado() e RemoverDispositivoMapeado(). + /// + public static List DispositivosMapeados = + CriarListaInicialDispositivos(); + + // ============================================================ + // SINCRONIZAÇÃO E CICLO DE VIDA + // ============================================================ + + private static readonly SemaphoreSlim _scanGate = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _updateDevicesGate = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _versioningGate = + new SemaphoreSlim(1, 1); + + private static readonly SemaphoreSlim _syncDataGate = + new SemaphoreSlim(1, 1); + + private static readonly object _mappedDevicesLock = + new object(); + + private static readonly object _connectedDevicesLock = + new object(); + + private static readonly object _lifecycleLock = + new object(); + + private static readonly object _metricsTextLock = + new object(); + + private static readonly ConcurrentDictionary< + string, + SemaphoreSlim> _portIoLocks = + new ConcurrentDictionary< + string, + SemaphoreSlim>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + int> _missingPortConfirmations = + new ConcurrentDictionary< + string, + int>( + StringComparer.OrdinalIgnoreCase + ); + + public static int ConfirmacoesPortaAusente { get; set; } = 2; + + private static CancellationTokenSource _lifetimeCts = + new CancellationTokenSource(); + + private static CancellationTokenSource _currentScanCts; + + private static long _scanGeneration; + private static int _isScanning; + private static int _isClosing; + + // ============================================================ + // MANUTENÇÃO + // ============================================================ + + public static int IntervaloVersionamentoMs { get; set; } = 60000; + public static int IntervaloSincronizacaoMs { get; set; } = 60000; + + private static long _lastVersioningStartMono; + private static long _lastSyncStartMono; + + // ============================================================ + // MÉTRICAS + // ============================================================ + + private static long _scanStarted; + private static long _scanCompleted; + private static long _scanSkipped; + private static long _scanCanceled; + private static long _scanErrors; + + private static long _portsEnumerated; + private static long _portsProbed; + private static long _portsBusy; + private static long _portsProbeErrors; + + private static long _gpsFound; + private static long _loraFound; + private static long _canAdapterFound; + private static long _devicesRemoved; + + private static long _serialWriteAttempts; + private static long _serialWriteSuccesses; + private static long _serialWriteTimeouts; + private static long _serialWriteErrors; + + private static long _lastScanStartMono; + private static long _lastScanEndMono; + private static double _lastScanDurationMs; + private static double _maxScanDurationMs; + + private static long _versioningRuns; + private static long _versioningErrors; + private static long _syncRuns; + private static long _syncErrors; + + private static string _lastError; + + private static int _uiUpdateScheduled; + private static string _pendingUiMessage; + + // ============================================================ + // ESCRITA SERIAL + // ============================================================ + + public static bool EnviarDadosPortaSerial( + SerialPort porta, + string dados, + bool DebugMode = false, + int timeout = 2000) { - new DispositivoDetalhesModel() + if (string.IsNullOrEmpty(dados)) { - Dispositivo = T_Code.Npc, - Endereco = EthernetService.ObterIpAtual(VariaveisEquipamento.Parametros.comunicacao_interface), - Versao = Variaveis.Versao, - Mod_ID = "" - } - }; - private static bool VarreduraEmAndamento = false; + if (DebugMode) + Variaveis.MostrarLog("[SerialService.EnviarDadosPortaSerial] Nenhum dado para enviar."); - - public static bool EnviarDadosPortaSerial(SerialPort _porta, string Dados, bool DebugMode = false, int timeout = 2000) - { - bool ForceDebug = false; - if (ForceDebug) - { - DebugMode = true; - } - - // Verifica se a porta está definida - if (_porta == null) - { - Console.WriteLine("A porta serial não está definida."); return false; } - // Verifica se a porta está aberta - if (!_porta.IsOpen) + byte[] bytes = Encoding.ASCII.GetBytes(dados); + + return EnviarDadosPortaSerial( + porta, + bytes, + 0, + bytes.Length, + DebugMode, + timeout + ); + } + + public static bool EnviarDadosPortaSerial( + SerialPort porta, + byte[] dados, + int inicio, + int comprimento, + bool DebugMode = false) + { + return EnviarDadosPortaSerial( + porta, + dados, + inicio, + comprimento, + DebugMode, + 2000 + ); + } + + private static bool EnviarDadosPortaSerial( + SerialPort porta, + byte[] dados, + int inicio, + int comprimento, + bool debugMode, + int timeout) + { + Interlocked.Increment(ref _serialWriteAttempts); + + if (!ValidarFaixaBuffer( + dados, + inicio, + comprimento)) { - Console.WriteLine("A porta serial não está aberta."); + if (debugMode) + Variaveis.MostrarLog("[SerialService.EnviarDadosPortaSerial] Buffer serial inválido."); + + Interlocked.Increment(ref _serialWriteErrors); return false; } + if (porta == null) + { + if (debugMode) + Variaveis.MostrarLog("[SerialService.EnviarDadosPortaSerial] A porta serial não está definida."); + + Interlocked.Increment(ref _serialWriteErrors); + return false; + } + + SemaphoreSlim gate = GetPortIoLock(porta); + + bool acquired = false; + try { - // Define o tempo de espera máximo para a operação de escrita - _porta.WriteTimeout = timeout; // 2 segundos de timeout para escrita (ajuste conforme necessário) + acquired = gate.Wait( + Math.Max(1, timeout) + ); - // Verifica se há dados para enviar - if (string.IsNullOrEmpty(Dados)) + if (!acquired) { - if (DebugMode) - Console.WriteLine("Nenhum dado para enviar."); + Interlocked.Increment( + ref _serialWriteTimeouts + ); + + if (debugMode) + { + Variaveis.MostrarLog( + "[SerialService.EnviarDadosPortaSerial] Timeout aguardando acesso exclusivo à porta." + ); + } + return false; } - // Tenta enviar os dados na porta serial - _porta.Write(Dados); - if (DebugMode) - Console.WriteLine("Dados enviados com sucesso."); + if (!porta.IsOpen) + { + if (debugMode) + Variaveis.MostrarLog("[SerialService.EnviarDadosPortaSerial] A porta serial não está aberta."); + + Interlocked.Increment(ref _serialWriteErrors); + return false; + } + + int oldTimeout = porta.WriteTimeout; + + try + { + porta.WriteTimeout = Math.Max(1, timeout); + porta.Write(dados, inicio, comprimento); + } + finally + { + try { porta.WriteTimeout = oldTimeout; } + catch { } + } + + Interlocked.Increment( + ref _serialWriteSuccesses + ); + + if (debugMode) + Variaveis.MostrarLog("[SerialService.EnviarDadosPortaSerial] Dados enviados com sucesso."); + return true; } - catch (TimeoutException) + catch (TimeoutException ex) { - // Timeout ocorreu, o que significa que não foi possível escrever os dados no tempo estipulado - if (DebugMode) - Console.WriteLine("Erro: Timeout ao tentar enviar dados."); + Interlocked.Increment( + ref _serialWriteTimeouts + ); + + RecordError( + "[SerialService.Write] Timeout em " + + SafePortName(porta) + ": " + ex.Message + ); + return false; } catch (InvalidOperationException ex) { - // A porta não estava aberta ou algo relacionado a uma falha de operação na porta - if (DebugMode) - Console.WriteLine("Erro: A porta serial não está aberta ou houve uma falha na operação. " + ex.Message); + Interlocked.Increment(ref _serialWriteErrors); + + RecordError( + "[SerialService.Write] Porta indisponível " + + SafePortName(porta) + ": " + ex.Message + ); + return false; } catch (UnauthorizedAccessException ex) { - // Caso a porta serial esteja sendo acessada por outro processo - if (DebugMode) - Console.WriteLine("Erro: Acesso negado à porta serial. " + ex.Message); + Interlocked.Increment(ref _serialWriteErrors); + + RecordError( + "[SerialService.Write] Acesso negado em " + + SafePortName(porta) + ": " + ex.Message + ); + + return false; + } + catch (IOException ex) + { + Interlocked.Increment(ref _serialWriteErrors); + + RecordError( + "[SerialService.Write] Erro de I/O em " + + SafePortName(porta) + ": " + ex.Message + ); + return false; } catch (Exception ex) { - // Captura outros tipos de erro inesperados - if (DebugMode) - Console.WriteLine("Erro inesperado: " + ex.Message); + Interlocked.Increment(ref _serialWriteErrors); + + RecordError( + "[SerialService.Write] Erro inesperado em " + + SafePortName(porta) + ": " + ex.Message + ); + return false; } + finally + { + if (acquired) + gate.Release(); + } } - public static bool EnviarDadosPortaSerial(SerialPort _porta, byte[] Dados, int inicio, int comprimento, bool DebugMode = false) + public static async Task EnviarDadosPortaSerialAsync( + SerialPort porta, + byte[] dados, + int inicio, + int comprimento, + int timeout = 2000, + CancellationToken cancellationToken = + default(CancellationToken)) { - bool ForceDebug = false; - if (ForceDebug) - { - DebugMode = true; - } + Interlocked.Increment(ref _serialWriteAttempts); - // Verifica se a porta está definida - if (_porta == null) + if (!ValidarFaixaBuffer( + dados, + inicio, + comprimento) || + porta == null) { - Console.WriteLine("A porta serial não está definida."); + Interlocked.Increment(ref _serialWriteErrors); return false; } - // Verifica se a porta está aberta - if (!_porta.IsOpen) - { - Console.WriteLine("A porta serial não está aberta."); - return false; - } + SemaphoreSlim gate = GetPortIoLock(porta); - try + using (var timeoutCts = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken)) { - // Define o tempo de espera máximo para a operação de escrita - _porta.WriteTimeout = 2000; // 2 segundos de timeout para escrita (ajuste conforme necessário) + timeoutCts.CancelAfter( + Math.Max(1, timeout) + ); - // Verifica se há dados para enviar - if (Dados == null || Dados.Length == 0) + bool acquired = false; + + try { - if (DebugMode) - Console.WriteLine("Nenhum dado para enviar."); + await gate.WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + + acquired = true; + + if (!porta.IsOpen) + { + Interlocked.Increment( + ref _serialWriteErrors + ); + + return false; + } + + /* + * SerialPort.Write é síncrono. O lock e o WriteTimeout + * impedem sobreposição e limitam o bloqueio no driver. + * Não usamos Task.Run, pois ele abandonaria uma thread + * caso o driver ignorasse cancelamento. + */ + int oldTimeout = porta.WriteTimeout; + + try + { + porta.WriteTimeout = + Math.Max(1, timeout); + + porta.Write( + dados, + inicio, + comprimento + ); + } + finally + { + try { porta.WriteTimeout = oldTimeout; } + catch { } + } + + Interlocked.Increment( + ref _serialWriteSuccesses + ); + + return true; + } + catch (OperationCanceledException) + { + Interlocked.Increment( + ref _serialWriteTimeouts + ); + return false; } + catch (TimeoutException) + { + Interlocked.Increment( + ref _serialWriteTimeouts + ); - // Tenta enviar os dados na porta serial - _porta.Write(Dados, inicio, comprimento); - if (DebugMode) - Console.WriteLine("Dados enviados com sucesso."); - return true; - } - catch (TimeoutException) - { - // Timeout ocorreu, o que significa que não foi possível escrever os dados no tempo estipulado - if (DebugMode) - Console.WriteLine("Erro: Timeout ao tentar enviar dados."); - return false; - } - catch (InvalidOperationException ex) - { - // A porta não estava aberta ou algo relacionado a uma falha de operação na porta - if (DebugMode) - Console.WriteLine("Erro: A porta serial não está aberta ou houve uma falha na operação. " + ex.Message); - return false; - } - catch (UnauthorizedAccessException ex) - { - // Caso a porta serial esteja sendo acessada por outro processo - if (DebugMode) - Console.WriteLine("Erro: Acesso negado à porta serial. " + ex.Message); - return false; - } - catch (Exception ex) - { - // Captura outros tipos de erro inesperados - if (DebugMode) - Console.WriteLine("Erro inesperado: " + ex.Message); - return false; + return false; + } + catch (Exception ex) + { + Interlocked.Increment( + ref _serialWriteErrors + ); + + RecordError( + "[SerialService.WriteAsync] " + + SafePortName(porta) + ": " + ex.Message + ); + + return false; + } + finally + { + if (acquired) + gate.Release(); + } } } + // ============================================================ + // VARREDURA PRINCIPAL + // ============================================================ - public static async Task RealizarVarreduraPortasUSB() + public static Task RealizarVarreduraPortasUSB() { - if (VarreduraEmAndamento || Variaveis.Fechando) + return RealizarVarreduraPortasUSB( + CancellationToken.None + ); + } + + public static async Task RealizarVarreduraPortasUSB( + CancellationToken cancellationToken) + { + if (Variaveis.Fechando || + Volatile.Read(ref _isClosing) == 1) { return; } - VarreduraEmAndamento = true; + EnsureLifetimeAvailable(); - try + CancellationToken lifetimeToken; + + lock (_lifecycleLock) + lifetimeToken = _lifetimeCts.Token; + + using (var linkedCts = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + lifetimeToken)) { - var dispositivosMapeados = DispositivosMapeados.ToList(); + bool entered; - var Portas = SerialPort.GetPortNames(); - var PortasNaoMapeadas = Portas.Where(x => !dispositivosMapeados.Select(y => y.Endereco).Contains(x)).ToList(); - List PortasSemResposta = new List(); - - for (int i = 0; i < PortasNaoMapeadas.Count(); i++) - { - string PortaCom = PortasNaoMapeadas[i].ToString(); - AtualizarConsole("Procurando dispositivos conectados à porta " + PortaCom + "..."); - SerialPort _Porta = new SerialPort() { PortName = PortaCom, BaudRate = 115200 }; - - if (!LoRaBaseService.Iniciado && Variaveis.IsAgroMonitor) - { - bool LoRa = await ProcurarDispositivoLoRa(_Porta); - if (LoRa) - { - continue; - } - } - - if (!GPSService.Iniciado) - { - bool GPSEncontrado = await ProcurarDispositivoGPS(_Porta); - if (GPSEncontrado) - { - continue; - } - } - - if (Variaveis.IsAgroMonitor) continue; - - if (CanManager.TipoServico == CanServiceTipo.Serial && !CanManager.CanService.Iniciado) - { - bool dispositivoCan = await ProcurarDispositivosCAN(_Porta); - if (dispositivoCan) - { - AtualizarConsole("Dispositivo encontrado no barramento CAN"); - continue; - } - } - } - - if (Variaveis.IsAgroMonitor) return; - - - ProcurarDispositivosUSB(); - - ProcurarDispositivosEthernet(); - - await ProcurarDispositivosCAN(); - - var DispositivosRemovidos = dispositivosMapeados.Where(x => x.Tipo == TipoConexao.PortaCOM && (!Portas.Contains(x.Endereco))).ToList(); - foreach (var Dispositivo in DispositivosRemovidos) - { - DispositivosMapeados.Remove(Dispositivo); - switch (Dispositivo.Dispositivo) - { - case T_Code.Gps: - GPSService.PortaGPS?.Close(); - GPSService.PortaGPS = null; - break; - case T_Code.Lra: - LoRaBaseService._PortaLoRa?.Close(); - LoRaBaseService._PortaLoRa = null; - break; - } - } - - - - AtualizarDispositivos(); - } - catch (Exception ex) - { - Console.WriteLine("Erro ao realizar varredura de portas: " + ex.Message); - } - finally - { - VarreduraEmAndamento = false; - - RealizarVerificacaoDeRotina(); - - AtualizarConsole("Iteração finalizada, aguardando a próxima..."); - } - } - - private static void RealizarVerificacaoDeRotina() - { - Task.Run(async () => - { - await FuncoesGlobais.SafeExecuteAsync(async () => - { - try - { - await VersionamentoService.AtualizarArquivoVersionamento(false); - } - catch (Exception ex) - { - Console.WriteLine($"Erro ao atualizar arquivo de versionamento: {ex.Message}"); - } - }); - }); - - Task.Run(async () => - { - await FuncoesGlobais.SafeExecuteAsync(async () => - { - try - { - await SyncDataService.SincronizarArquivosComServidor(); - } - catch (Exception ex) - { - Console.WriteLine($"Erro ao sincronizar arquivos com o servidor: {ex.Message}"); - } - }); - }); - } - - public static string ReadLineWithTimeout(SerialPort porta, int timeout) - { - using (CancellationTokenSource cts = new CancellationTokenSource()) - { try { - Task task = Task.Run(() => - { - if (porta.IsOpen) // Verifica se a porta está aberta - { - return porta.ReadLine(); - } - throw new IOException("A porta serial não está aberta."); - }, cts.Token); - - if (task.Wait(timeout)) - { - return task.Result; - } - else - { - cts.Cancel(); - return ""; // Retorna vazio se o timeout for atingido - } + entered = await _scanGate + .WaitAsync(0, linkedCts.Token) + .ConfigureAwait(false); } - catch (IOException ex) + catch (OperationCanceledException) { - Console.WriteLine($"Erro de I/O: {ex.Message}"); - return ""; // Ou trate como necessário + return; + } + + if (!entered) + { + Interlocked.Increment(ref _scanSkipped); + return; + } + + Interlocked.Exchange(ref _isScanning, 1); + Interlocked.Increment(ref _scanStarted); + + long generation = + Interlocked.Increment( + ref _scanGeneration + ); + + long startMono = Stopwatch.GetTimestamp(); + + Interlocked.Exchange( + ref _lastScanStartMono, + startMono + ); + + lock (_lifecycleLock) + _currentScanCts = linkedCts; + + try + { + await ExecutarVarreduraAsync( + generation, + linkedCts.Token + ).ConfigureAwait(false); + + Interlocked.Increment(ref _scanCompleted); + } + catch (OperationCanceledException) + { + Interlocked.Increment(ref _scanCanceled); } catch (Exception ex) { - Console.WriteLine($"Erro: {ex.Message}"); - return ""; // Ou trate como necessário - } - } - } + Interlocked.Increment(ref _scanErrors); - public static void AtualizarDispositivos() - { - bool PrimeiraExecucao = !Variaveis.DispositivosConectados.Any(); - DispositivosConexaoInicial.ForEach(Dispositivo => - { - IDispositivosService dispositivoService = Variaveis.DispositivosConectados.FirstOrDefault(x => x.Dispositivo == Dispositivo); - - if (dispositivoService == null) - { - dispositivoService = DispositivosServiceFactory.CreateDispositivoService( - Dispositivo, - "Nome: " + Enum.GetName(typeof(T_Code), Dispositivo), - "Descrição: " + string.Join(", ", DispositivosMapeados.Where(x => x.Dispositivo == Dispositivo).Select(x => x.Mod_ID).ToArray()) + RecordError( + "[SerialService.Scan] " + ex ); - dispositivoService.CarregarParametrosModulos(); - Variaveis.DispositivosConectados.Add(dispositivoService); - dispositivoService.Dados.IniciarRegistrador(); } - }); - if (PrimeiraExecucao) - { - //OperacaoModel.CarregarParametrosOperacaoPadrao(ModoOperacao.MapaGPS, true); - Func func = new Func(() => + finally { - frmInstancial.frmPrincipal.cmbModoOperacao.SelectedIndex = (int)ModoOperacao.MapaGPS; - return true; - }); - FuncoesGlobais.ExecutarMetodoComVerificacaoCrossThread(frmInstancial.frmPrincipal.cmbModoOperacao, func); - } + long endMono = Stopwatch.GetTimestamp(); + double duration = ElapsedMs( + startMono, + endMono + ); - //DispositivosMapeados.ForEach(d => d.AtualizarDados()); + lock (_metricsTextLock) + { + _lastScanDurationMs = duration; + + if (duration > _maxScanDurationMs) + _maxScanDurationMs = duration; + } + + Interlocked.Exchange( + ref _lastScanEndMono, + endMono + ); + + lock (_lifecycleLock) + { + if (ReferenceEquals( + _currentScanCts, + linkedCts)) + { + _currentScanCts = null; + } + } + + Interlocked.Exchange(ref _isScanning, 0); + _scanGate.Release(); + + TriggerMaintenance(); + AtualizarConsole( + "Iteração finalizada, aguardando a próxima..." + ); + } + } } - private static async Task ProcurarDispositivosCAN(SerialPort Porta = null) + private static async Task ExecutarVarreduraAsync( + long generation, + CancellationToken cancellationToken) { - AtualizarConsole($"{Porta?.PortName ?? CanManager.CanService._portName ?? "CAN"} - Procurando dispositivos no barramento CAN"); + cancellationToken.ThrowIfCancellationRequested(); - bool porta_can = CanManager.CanService.PortaIsCan(Porta); - bool inicializou = !CanManager.CanService.Inicializar(250); - if (!porta_can || inicializou) + /* + * Garante que SEN, ATU e MVD existam antes da primeira + * consulta CAN. Assim a descoberta não precisa esperar + * uma segunda varredura para encontrar os módulos. + */ + if (!Variaveis.IsAgroMonitor) { - AtualizarConsole($"Adaptador {CanManager.CanService._portName ?? "CAN"} não conectado!"); - var dispositivosRemover = DispositivosMapeados.Where(x => DispositivosCan.Contains(x.Dispositivo)).ToList(); - foreach (var d in dispositivosRemover) + await AtualizarDispositivosAsync( + cancellationToken + ).ConfigureAwait(false); + } + + List mappedSnapshot = + GetDispositivosMapeadosSnapshot(); + + string[] ports = SerialPort.GetPortNames() + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(PortSortKey) + .ToArray(); + + Interlocked.Add( + ref _portsEnumerated, + ports.Length + ); + + HashSet mappedComPorts = + new HashSet( + mappedSnapshot + .Where(x => + x != null && + x.Tipo == TipoConexao.PortaCOM && + !string.IsNullOrWhiteSpace( + x.Endereco + )) + .Select(x => x.Endereco), + StringComparer.OrdinalIgnoreCase + ); + + string[] unmappedPorts = ports + .Where(x => !mappedComPorts.Contains(x)) + .ToArray(); + + foreach (string portName in unmappedPorts) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (generation != + Interlocked.Read(ref _scanGeneration)) { - DispositivosMapeados.Remove(d); + return; } + + await ProbePortAsync( + portName, + cancellationToken + ).ConfigureAwait(false); + } + + if (!Variaveis.IsAgroMonitor) + { + ProcurarDispositivosUSB(); + ProcurarDispositivosEthernet(); + + /* + * Se o CAN já está iniciado, apenas consulta os módulos. + * Se estiver em Ethernet, Inicializar() resolve o transporte + * sem depender de uma porta COM candidata. + */ + if (CanManager.TipoServico != + CanServiceTipo.Serial || + CanManager.CanService.Iniciado) + { + await ProcurarDispositivosCAN( + null, + cancellationToken + ).ConfigureAwait(false); + } + } + + await RemoverDispositivosAusentesAsync( + ports, + cancellationToken + ).ConfigureAwait(false); + + await AtualizarDispositivosAsync( + cancellationToken + ).ConfigureAwait(false); + } + + private static async Task ProbePortAsync( + string portName, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _portsProbed); + + AtualizarConsole( + "Procurando dispositivos conectados à porta " + + portName + "..." + ); + + SerialPort candidate = null; + bool claimed = false; + + try + { + candidate = CriarPortaCandidata(portName); + + if (Variaveis.IsAgroMonitor && + !LoRaBaseService.Iniciado) + { + if (await ProcurarDispositivoLoRa( + candidate, + cancellationToken) + .ConfigureAwait(false)) + { + claimed = true; + Interlocked.Increment(ref _loraFound); + return; + } + } + + if (!GPSService.Iniciado) + { + if (await ProcurarDispositivoGPS( + candidate, + cancellationToken) + .ConfigureAwait(false)) + { + Interlocked.Increment(ref _gpsFound); + + /* + * O GPSService robusto cria sua própria instância + * de SerialPort. A candidata não é transferida. + */ + return; + } + } + + if (Variaveis.IsAgroMonitor) + return; + + if (CanManager.TipoServico == + CanServiceTipo.Serial && + !CanManager.CanService.Iniciado) + { + if (await ProcurarDispositivosCAN( + candidate, + cancellationToken) + .ConfigureAwait(false)) + { + claimed = true; + Interlocked.Increment( + ref _canAdapterFound + ); + + AtualizarConsole( + "Dispositivo encontrado no barramento CAN" + ); + + return; + } + } + } + catch (UnauthorizedAccessException) + { + Interlocked.Increment(ref _portsBusy); + } + catch (OperationCanceledException) + { + throw; + } + catch (IOException ex) + { + Interlocked.Increment(ref _portsProbeErrors); + + RecordError( + "[SerialService.ProbePort] " + + portName + ": " + ex.Message + ); + } + catch (Exception ex) + { + Interlocked.Increment(ref _portsProbeErrors); + + RecordError( + "[SerialService.ProbePort] " + + portName + ": " + ex.Message + ); + } + finally + { + if (!claimed && candidate != null) + CloseAndDispose(candidate); + } + } + + // ============================================================ + // DETECÇÃO DE DISPOSITIVOS + // ============================================================ + + private static async Task ProcurarDispositivosCAN( + SerialPort porta = null, + CancellationToken cancellationToken = + default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string name = + porta?.PortName ?? + CanManager.CanService._portName ?? + "CAN"; + + AtualizarConsole( + name + + " - Procurando dispositivos no barramento CAN" + ); + + /* + * Uma candidata que não é CAN é simplesmente ignorada. + * Nunca removemos mapeamentos CAN por causa desse teste. + */ + if (porta != null && + !CanManager.CanService.PortaIsCan(porta)) + { return false; } - if (true && Variaveis.OperacaoEmAndamento.DispSen != null && !Variaveis.OperacaoEmAndamento.DispSen.Dados.Conectado) + if (!CanManager.CanService.Iniciado) { - AtualizarConsole($"{CanManager.CanService._portName} - Procurando dispositivo SEN"); - bool DispSen = await Variaveis.OperacaoEmAndamento.DispSen.Dados.VerificaDispositivoConectado(); - if (DispSen) + bool initialized = + CanManager.CanService.Inicializar(250); + + if (!initialized) { + AtualizarConsole( + "Adaptador " + name + " não conectado!" + ); + + return false; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Variaveis.OperacaoEmAndamento.DispSen != null && + !Variaveis.OperacaoEmAndamento + .DispSen + .Dados + .Conectado) + { + AtualizarConsole( + name + " - Procurando dispositivo SEN" + ); + + bool found = + await Variaveis.OperacaoEmAndamento + .DispSen + .Dados + .VerificaDispositivoConectado() + .ConfigureAwait(false); + + if (found) AtualizarConsole("Dispositivo SEN encontrado"); - } } - - if (true && Variaveis.OperacaoEmAndamento.DispAtu != null && !Variaveis.OperacaoEmAndamento.DispAtu.Dados.Conectado) + + cancellationToken.ThrowIfCancellationRequested(); + + if (Variaveis.OperacaoEmAndamento.DispAtu != null && + !Variaveis.OperacaoEmAndamento + .DispAtu + .Dados + .Conectado) { - AtualizarConsole($"{CanManager.CanService._portName} - Procurando dispositivo ATU"); - bool DispAtu = await Variaveis.OperacaoEmAndamento.DispAtu.Dados.VerificaDispositivoConectado(); - if (DispAtu) - { + AtualizarConsole( + name + " - Procurando dispositivo ATU" + ); + + bool found = + await Variaveis.OperacaoEmAndamento + .DispAtu + .Dados + .VerificaDispositivoConectado() + .ConfigureAwait(false); + + if (found) AtualizarConsole("Dispositivo ATU encontrado"); - } } - if (true && Variaveis.OperacaoEmAndamento.DispMvd != null && !MKS057DCanService.Referenciando && (!MKS057DCanService.Iniciado || MKS057DCanService.DadosLeitura.Count(x => x.Iniciado) < (Variaveis.OperacaoEmAndamento.DispMvd?.Dados?.Modulos?.Count() ?? 4))) + cancellationToken.ThrowIfCancellationRequested(); + + int expectedModules = + Variaveis.OperacaoEmAndamento + .DispMvd? + .Dados? + .Modulos? + .Count() ?? 4; + + if (Variaveis.OperacaoEmAndamento.DispMvd != null && + !MKS057DCanService.Referenciando && + ( + !MKS057DCanService.Iniciado || + MKS057DCanService.DadosLeitura + .Count(x => x.Iniciado) < + expectedModules + )) { - AtualizarConsole($"{CanManager.CanService._portName} - Procurando dispositivos MKS"); - bool DispMks = await MKS057DCanService.VerificaDispositivoConectado(); - if (DispMks) - { + AtualizarConsole( + name + " - Procurando dispositivos MKS" + ); + + bool found = + await MKS057DCanService + .VerificaDispositivoConectado() + .ConfigureAwait(false); + + if (found) AtualizarConsole("Dispositivo MKS encontrado"); - } } - if (true && Variaveis.OperacaoEmAndamento.DispMvd != null && (!OIDCanService.Iniciado || OIDCanService.DadosLeitura.Count(x => x.Iniciado) < (Variaveis.OperacaoEmAndamento.DispMvd?.Dados?.Modulos?.Count() ?? 4))) + cancellationToken.ThrowIfCancellationRequested(); + + if (Variaveis.OperacaoEmAndamento.DispMvd != null && + ( + !OIDCanService.Iniciado || + OIDCanService.DadosLeitura + .Count(x => x.Iniciado) < + expectedModules + )) { - AtualizarConsole($"{CanManager.CanService._portName} - Procurando dispositivo OID"); - bool DispOid = await OIDCanService.VerificaDispositivoConectado(); - if (DispOid) - { + AtualizarConsole( + name + " - Procurando dispositivo OID" + ); + + bool found = + await OIDCanService + .VerificaDispositivoConectado() + .ConfigureAwait(false); + + if (found) AtualizarConsole("Dispositivo OID encontrado"); - } } - if (true && !DalyBMSService.Iniciado) + cancellationToken.ThrowIfCancellationRequested(); + + if (!DalyBMSService.Iniciado) { - AtualizarConsole($"{CanManager.CanService._portName} - Procurando dispositivo BAT"); - bool DispBat = await DalyBMSService.VerificaDispositivoConectado(); - if (DispBat) - { + AtualizarConsole( + name + " - Procurando dispositivo BAT" + ); + + bool found = + await DalyBMSService + .VerificaDispositivoConectado() + .ConfigureAwait(false); + + if (found) AtualizarConsole("Dispositivo BAT encontrado"); - } } return CanManager.CanService.Iniciado; @@ -463,219 +1014,1458 @@ namespace AgroBase.Services private static void ProcurarDispositivosUSB() { - if (!Variaveis.IsAgroMonitor) + if (Variaveis.IsAgroMonitor) + return; + + if (GeneralJoystick.JoystickConectado == null) + GeneralJoystick.AtualizaDispositivo(); + + var cameras = CameraWorkerService.ListaCameras; + + if (cameras == null) + return; + + foreach (var camera in cameras) { - if (false && !KinectService.Iniciado) + var health = + HealthWorkerService.ModulosSaude + .FirstOrDefault( + x => x.modulo == camera.dispositivo + ); + + if (health == null) + continue; + + DispositivoDetalhesModel mapped = + GetDispositivosMapeadosSnapshot() + .FirstOrDefault( + x => x.Dispositivo == + camera.dispositivo + ); + + bool connected = + health.status != + StatusModulo.Desconectado; + + if (mapped == null && connected) { - KinectService.InicializarKinect(); - } - - if (GeneralJoystick.JoystickConectado == null) - { - GeneralJoystick.AtualizaDispositivo(); - } - - var _Cameras = CameraWorkerService.ListaCameras; - foreach (var cam in _Cameras) - { - var disp = HealthWorkerService.ModulosSaude.FirstOrDefault(x => x.modulo == cam.dispositivo); - if (disp == null) continue; - - var added = DispositivosMapeados.FirstOrDefault(x => x.Dispositivo == cam.dispositivo); - bool connected = disp.status != StatusModulo.Desconectado; - - if (added == null && connected) - { - DispositivosMapeados.Add(new DispositivoDetalhesModel() + AdicionarOuAtualizarDispositivoMapeado( + new DispositivoDetalhesModel { - Dispositivo = cam.dispositivo, + Dispositivo = + camera.dispositivo, Endereco = "USB", - Versao = cam.versao - }); - } - else if (added != null && !connected) - { - DispositivosMapeados.Remove(added); - } + Versao = camera.versao + } + ); } - /*var _dMap = DispositivosMapeados.Where(x => x.Tipo == TipoConexao.USB).ToList(); - foreach (var cam in _dMap) + else if (mapped != null && !connected) { - bool camPresente = _Cameras.Any(x => x.Dispositivo == cam.Dispositivo); - if (!camPresente) - { - DispositivosMapeados.Remove(cam); - } - }*/ + RemoverDispositivoMapeado(mapped); + } } } - private static async Task ProcurarDispositivoLoRa(SerialPort porta) + private static async Task ProcurarDispositivoLoRa( + SerialPort porta, + CancellationToken cancellationToken) { - AtualizarConsole($"{porta.PortName} - Procurando dispositivo LRA"); - bool Encontrado = await LoRaBaseService.VerificaPortaLoRa(porta); - if (Encontrado) + if (porta == null) + return false; + + cancellationToken.ThrowIfCancellationRequested(); + + AtualizarConsole( + porta.PortName + + " - Procurando dispositivo LRA" + ); + + bool found = + await LoRaBaseService + .VerificaPortaLoRa(porta) + .ConfigureAwait(false); + + if (found) { AtualizarConsole("Dispositivo LRA encontrado"); return true; } - porta.Close(); + + ClosePortOnly(porta); return false; } - private static async Task ProcurarDispositivoGPS(SerialPort Porta) + private static async Task ProcurarDispositivoGPS( + SerialPort porta, + CancellationToken cancellationToken) { - SerialPort _porta = new SerialPort(Porta.PortName, 115200); + if (porta == null || + string.IsNullOrWhiteSpace(porta.PortName)) + { + return false; + } + + SerialPort probe = + CriarPortaCandidata(porta.PortName); + try { - _porta.ReadTimeout = 500; - _porta.WriteTimeout = 500; - _porta.Handshake = Handshake.None; - _porta.DtrEnable = false; - _porta.RtsEnable = false; + probe.ReadTimeout = 250; + probe.WriteTimeout = 500; + probe.Open(); - _porta.Open(); - await Task.Delay(100); - var nmea = $"gngga com3 1\r\n"; - _porta.Write(Encoding.ASCII.GetBytes(nmea), 0, nmea.Length); - AtualizarConsole($"{_porta.PortName} - Procurando dispositivo GPS"); - await Task.Delay(1000); + try { probe.DiscardInBuffer(); } + catch { } - string Recebido = ""; + const string command = + "gngga com3 1\r\n"; - if (string.IsNullOrEmpty(Recebido)) - { - //Recebido = Porta.ReadLine(); - byte[] buffer = LerDadosDaPortaSerial(_porta, 15); - Recebido = Encoding.ASCII.GetString(buffer); - } + byte[] commandBytes = + Encoding.ASCII.GetBytes(command); - if ( - (Recebido.Contains("$GPTXT") || Recebido.Contains("$GPRMC") || Recebido.Contains("$GPGGA") || Recebido.Contains("$GPGLL")) || - (Recebido.Contains("$GNGGA") || Recebido.Contains("$GPVTG") || Recebido.Contains("$GPGSV") || Recebido.Contains("$GNTHS")) - ) - { - GPSService.AtualizarPortaCOM(_porta); - AtualizarConsole("Dispositivo GPS encontrado"); - return true; - } + probe.Write( + commandBytes, + 0, + commandBytes.Length + ); + + AtualizarConsole( + probe.PortName + + " - Procurando dispositivo GPS" + ); + + byte[] received = + await LerAteCondicaoAsync( + probe, + maxBytes: 4096, + timeout: 1600, + predicate: IsGpsPayload, + cancellationToken: + cancellationToken + ).ConfigureAwait(false); + + if (!IsGpsPayload(received)) + return false; + + /* + * O novo GPSService copia a configuração da porta, + * cria sua própria instância e gerencia o lifecycle. + */ + GPSService.AtualizarPortaCOM(probe); + + AtualizarConsole( + "Dispositivo GPS encontrado" + ); + + return true; + } + catch (UnauthorizedAccessException) + { + Interlocked.Increment(ref _portsBusy); return false; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { + RecordError( + "[SerialService.ProbeGPS] " + + porta.PortName + ": " + ex.Message + ); + return false; } finally { - _porta.Close(); + CloseAndDispose(probe); } } private static void ProcurarDispositivosEthernet() { - var Lvx = HealthWorkerService.ModulosSaude.FirstOrDefault(x => x.modulo == T_Code.Lvx); - if (Lvx != null) - { - var added = DispositivosMapeados.FirstOrDefault(x => x.Dispositivo == Lvx.modulo); - bool connected = Lvx.status != StatusModulo.Desconectado; + var livox = + HealthWorkerService.ModulosSaude + .FirstOrDefault( + x => x.modulo == T_Code.Lvx + ); - if (added == null && connected) - { - DispositivosMapeados.Add(new DispositivoDetalhesModel() + if (livox == null) + return; + + DispositivoDetalhesModel mapped = + GetDispositivosMapeadosSnapshot() + .FirstOrDefault( + x => x.Dispositivo == livox.modulo + ); + + bool connected = + livox.status != + StatusModulo.Desconectado; + + if (mapped == null && connected) + { + AdicionarOuAtualizarDispositivoMapeado( + new DispositivoDetalhesModel { - Dispositivo = Lvx.modulo, - Endereco = LivoxManagerProcess.DadosLeitura.lidar_ip, - Versao = LivoxManagerProcess.DadosLeitura.firmware_version, - Mod_ID = LivoxManagerProcess.DadosLeitura.dev_type, - }); - } - else if (added != null && !connected) - { - DispositivosMapeados.Remove(added); - } + Dispositivo = livox.modulo, + Endereco = + LivoxManagerProcess + .DadosLeitura + .lidar_ip, + Versao = + LivoxManagerProcess + .DadosLeitura + .firmware_version, + Mod_ID = + LivoxManagerProcess + .DadosLeitura + .dev_type + } + ); + } + else if (mapped != null && !connected) + { + RemoverDispositivoMapeado(mapped); } } - public static byte[] LerDadosDaPortaSerial(SerialPort porta, int tamanhoEsperado, int Timeout = 500) + // ============================================================ + // REMOÇÃO E ATUALIZAÇÃO DE MODELOS + // ============================================================ + + private static async Task RemoverDispositivosAusentesAsync( + string[] currentPorts, + CancellationToken cancellationToken) { - DateTime inicio = DateTime.Now; - List buffer = new List(); - while (buffer.Count < tamanhoEsperado && inicio.AddMilliseconds(Timeout) > DateTime.Now) + var current = + new HashSet( + currentPorts ?? new string[0], + StringComparer.OrdinalIgnoreCase + ); + + List mappedCom = + GetDispositivosMapeadosSnapshot() + .Where(x => + x != null && + x.Tipo == TipoConexao.PortaCOM && + !string.IsNullOrWhiteSpace(x.Endereco)) + .ToList(); + + foreach (DispositivoDetalhesModel present in mappedCom) { - if (porta == null) + if (current.Contains(present.Endereco)) { - break; - } - int bytesDisponiveis = porta.BytesToRead; - if (bytesDisponiveis > 0) - { - byte[] tempBuffer = new byte[bytesDisponiveis]; - int bytesLidos = porta.Read(tempBuffer, 0, bytesDisponiveis); - buffer.AddRange(tempBuffer.Take(bytesLidos)); + int ignored; + _missingPortConfirmations.TryRemove( + BuildMissingPortKey(present), + out ignored + ); } } - return buffer.ToArray(); - } - public static async Task LerDadosDaPortaSerialAsync(SerialPort porta, int tamanhoEsperado, int Timeout = 500) - { - DateTime inicio = DateTime.Now; - List buffer = new List(); - while (buffer.Count < tamanhoEsperado && inicio.AddMilliseconds(Timeout) > DateTime.Now) + foreach (DispositivoDetalhesModel device in mappedCom) { - await Task.Delay(50); - if (porta == null || !porta.IsOpen) - { - break; - } - int bytesDisponiveis = porta.BytesToRead; - if (bytesDisponiveis > 0) - { - byte[] tempBuffer = new byte[bytesDisponiveis]; - int bytesLidos = porta.Read(tempBuffer, 0, bytesDisponiveis); - buffer.AddRange(tempBuffer.Take(bytesLidos)); - } - } - return buffer.ToArray(); - } + cancellationToken.ThrowIfCancellationRequested(); - private static void AtualizarConsole(string msg) - { - Console.WriteLine(msg); - if (!Variaveis.IsAgroMonitor) - { - if (frmInstancial.frmPrincipal.IsHandleCreated) - { - frmInstancial.frmPrincipal.Invoke((MethodInvoker)(() => - { - frmInstancial.frmPrincipal.lblStatusConexao.Text = msg; + if (current.Contains(device.Endereco)) + continue; - if (frmInstancial.frmIHM != null && frmInstancial.frmIHM.IsHandleCreated) + string missingKey = + BuildMissingPortKey(device); + + int confirmations = + _missingPortConfirmations.AddOrUpdate( + missingKey, + 1, + (_, previous) => previous + 1 + ); + + if (confirmations < + Math.Max(1, ConfirmacoesPortaAusente)) + { + continue; + } + + int ignored; + _missingPortConfirmations.TryRemove( + missingKey, + out ignored + ); + + if (!RemoverDispositivoMapeado(device)) + continue; + + Interlocked.Increment(ref _devicesRemoved); + + switch (device.Dispositivo) + { + case T_Code.Gps: + try { - frmInstancial.frmIHM.lblStatus.Text = msg; + await GPSService + .EncerrarAsync() + .ConfigureAwait(false); } - })); - } - else - { - // Opcional: armazenar a mensagem e aplicar depois - Console.WriteLine("frmPrincipal ainda não está pronto para receber Invoke."); + catch (Exception ex) + { + RecordError( + "[SerialService.RemoveGPS] " + + ex.Message + ); + } + break; + + case T_Code.Lra: + try + { + CloseAndDispose( + LoRaBaseService._PortaLoRa + ); + } + catch { } + + LoRaBaseService._PortaLoRa = null; + break; } } } - public static string MostrarComandoResposta(T_Code tipo, byte[] comando, byte[] resposta) + public static void AtualizarDispositivos() { - bool showCommands = true; - string msg = tipo.ToString() + " - TX: " + string.Join(" ", comando.Select(x => x.ToString("x"))) + ", RX: " + string.Join(" ", (resposta != null ? resposta : new byte[] { }).Select(x => x.ToString("x"))); - if (showCommands) + AtualizarDispositivosAsync( + CancellationToken.None + ).GetAwaiter().GetResult(); + } + + private static async Task AtualizarDispositivosAsync( + CancellationToken cancellationToken) + { + await _updateDevicesGate + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + try { - Console.WriteLine(msg); + bool firstExecution; + + lock (_connectedDevicesLock) + { + firstExecution = + !Variaveis.DispositivosConectados.Any(); + + foreach (T_Code code in + DispositivosConexaoInicial) + { + IDispositivosService service = + Variaveis.DispositivosConectados + .FirstOrDefault( + x => x.Dispositivo == code + ); + + if (service != null) + continue; + + List moduleIds = + GetDispositivosMapeadosSnapshot() + .Where( + x => x.Dispositivo == code + ) + .Select(x => x.Mod_ID) + .Where( + x => !string.IsNullOrWhiteSpace(x) + ) + .ToList(); + + service = + DispositivosServiceFactory + .CreateDispositivoService( + code, + "Nome: " + + Enum.GetName( + typeof(T_Code), + code + ), + "Descrição: " + + string.Join( + ", ", + moduleIds.ToArray() + ) + ); + + service.CarregarParametrosModulos(); + + Variaveis.DispositivosConectados + .Add(service); + + service.Dados.IniciarRegistrador(); + } + } + + if (firstExecution) + DefinirModoInicialNaUi(); + } + finally + { + _updateDevicesGate.Release(); + } + } + + private static void DefinirModoInicialNaUi() + { + if (frmInstancial.frmPrincipal == null) + return; + + ComboBox combo = + frmInstancial.frmPrincipal + .cmbModoOperacao; + + if (combo == null || + combo.IsDisposed || + combo.Disposing) + { + return; } + Action action = () => + { + if (!combo.IsDisposed && + !combo.Disposing) + { + combo.SelectedIndex = + (int)ModoOperacao.MapaGPS; + } + }; + + try + { + if (combo.InvokeRequired) + combo.BeginInvoke(action); + else + action(); + } + catch + { + // A interface pode estar fechando. + } + } + + // ============================================================ + // LEITURA SERIAL COM TIMEOUT + // ============================================================ + + public static string ReadLineWithTimeout( + SerialPort porta, + int timeout) + { + if (porta == null) + return string.Empty; + + SemaphoreSlim gate = GetPortIoLock(porta); + bool acquired = false; + + try + { + acquired = gate.Wait( + Math.Max(1, timeout) + ); + + if (!acquired || !porta.IsOpen) + return string.Empty; + + int oldTimeout = porta.ReadTimeout; + + try + { + porta.ReadTimeout = + Math.Max(1, timeout); + + return porta.ReadLine(); + } + catch (TimeoutException) + { + return string.Empty; + } + finally + { + try { porta.ReadTimeout = oldTimeout; } + catch { } + } + } + catch (IOException ex) + { + RecordError( + "[SerialService.ReadLine] " + + SafePortName(porta) + ": " + ex.Message + ); + + return string.Empty; + } + catch (Exception ex) + { + RecordError( + "[SerialService.ReadLine] " + + SafePortName(porta) + ": " + ex.Message + ); + + return string.Empty; + } + finally + { + if (acquired) + gate.Release(); + } + } + + public static byte[] LerDadosDaPortaSerial( + SerialPort porta, + int tamanhoEsperado, + int Timeout = 500) + { + return LerDadosDaPortaSerialAsync( + porta, + tamanhoEsperado, + Timeout, + CancellationToken.None + ).GetAwaiter().GetResult(); + } + + public static Task LerDadosDaPortaSerialAsync( + SerialPort porta, + int tamanhoEsperado, + int Timeout = 500) + { + return LerDadosDaPortaSerialAsync( + porta, + tamanhoEsperado, + Timeout, + CancellationToken.None + ); + } + + public static async Task LerDadosDaPortaSerialAsync( + SerialPort porta, + int tamanhoEsperado, + int Timeout, + CancellationToken cancellationToken) + { + if (porta == null || + tamanhoEsperado <= 0 || + Timeout <= 0) + { + return new byte[0]; + } + + return await LerAteCondicaoAsync( + porta, + maxBytes: Math.Max( + tamanhoEsperado, + 1 + ), + timeout: Timeout, + predicate: bytes => + bytes != null && + bytes.Length >= tamanhoEsperado, + cancellationToken: + cancellationToken + ).ConfigureAwait(false); + } + + private static async Task LerAteCondicaoAsync( + SerialPort porta, + int maxBytes, + int timeout, + Func predicate, + CancellationToken cancellationToken) + { + if (porta == null || + maxBytes <= 0 || + timeout <= 0) + { + return new byte[0]; + } + + SemaphoreSlim gate = GetPortIoLock(porta); + var buffer = new List( + Math.Min(maxBytes, 4096) + ); + + using (var timeoutCts = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken)) + { + timeoutCts.CancelAfter(timeout); + + bool acquired = false; + + try + { + await gate.WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + + acquired = true; + + if (!porta.IsOpen) + return buffer.ToArray(); + + while (!timeoutCts.IsCancellationRequested && + buffer.Count < maxBytes) + { + int available; + + try + { + available = porta.BytesToRead; + } + catch + { + break; + } + + if (available <= 0) + { + await Task.Delay( + 5, + timeoutCts.Token + ).ConfigureAwait(false); + + continue; + } + + int readSize = Math.Min( + available, + maxBytes - buffer.Count + ); + + byte[] temp = new byte[readSize]; + + int read = porta.Read( + temp, + 0, + temp.Length + ); + + if (read > 0) + buffer.AddRange(temp.Take(read)); + + byte[] current = buffer.ToArray(); + + if (predicate != null && + predicate(current)) + { + return current; + } + } + } + catch (OperationCanceledException) + { + // Retorna os bytes parciais já recebidos. + } + catch (Exception ex) + { + RecordError( + "[SerialService.ReadBytes] " + + SafePortName(porta) + ": " + ex.Message + ); + } + finally + { + if (acquired) + gate.Release(); + } + } + + return buffer.ToArray(); + } + + // ============================================================ + // MANUTENÇÃO SEM SOBREPOSIÇÃO + // ============================================================ + + private static void TriggerMaintenance() + { + TriggerVersioningIfDue(); + TriggerSyncIfDue(); + } + + private static void TriggerVersioningIfDue() + { + long now = Stopwatch.GetTimestamp(); + + if (!IsDue( + Interlocked.Read( + ref _lastVersioningStartMono + ), + IntervaloVersionamentoMs, + now)) + { + return; + } + + if (!_versioningGate.Wait(0)) + return; + + Interlocked.Exchange( + ref _lastVersioningStartMono, + now + ); + + _ = RunVersioningAsync(); + } + + private static async Task RunVersioningAsync() + { + Interlocked.Increment(ref _versioningRuns); + + try + { + await FuncoesGlobais.SafeExecuteAsync( + async () => + { + await VersionamentoService + .AtualizarArquivoVersionamento( + false + ); + } + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Interlocked.Increment( + ref _versioningErrors + ); + + RecordError( + "[SerialService.Versioning] " + + ex.Message + ); + } + finally + { + _versioningGate.Release(); + } + } + + private static void TriggerSyncIfDue() + { + long now = Stopwatch.GetTimestamp(); + + if (!IsDue( + Interlocked.Read( + ref _lastSyncStartMono + ), + IntervaloSincronizacaoMs, + now)) + { + return; + } + + if (!_syncDataGate.Wait(0)) + return; + + Interlocked.Exchange( + ref _lastSyncStartMono, + now + ); + + _ = RunSyncAsync(); + } + + private static async Task RunSyncAsync() + { + Interlocked.Increment(ref _syncRuns); + + try + { + await FuncoesGlobais.SafeExecuteAsync( + async () => + { + await SyncDataService + .SincronizarArquivosComServidor(); + } + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Interlocked.Increment(ref _syncErrors); + + RecordError( + "[SerialService.Sync] " + ex.Message + ); + } + finally + { + _syncDataGate.Release(); + } + } + + // ============================================================ + // LISTA DE DISPOSITIVOS MAPEADOS + // ============================================================ + + public static List + GetDispositivosMapeadosSnapshot() + { + lock (_mappedDevicesLock) + { + return DispositivosMapeados + .Where(x => x != null) + .ToList(); + } + } + + public static void AdicionarOuAtualizarDispositivoMapeado( + DispositivoDetalhesModel device) + { + if (device == null) + return; + + lock (_mappedDevicesLock) + { + DispositivoDetalhesModel existing = + DispositivosMapeados.FirstOrDefault( + x => + x != null && + x.Dispositivo == device.Dispositivo && + string.Equals( + x.Endereco ?? string.Empty, + device.Endereco ?? string.Empty, + StringComparison.OrdinalIgnoreCase + ) && + string.Equals( + x.Mod_ID ?? string.Empty, + device.Mod_ID ?? string.Empty, + StringComparison.OrdinalIgnoreCase + ) + ); + + if (existing == null) + { + DispositivosMapeados.Add(device); + return; + } + + existing.Versao = device.Versao; + } + } + + public static bool RemoverDispositivoMapeado( + DispositivoDetalhesModel device) + { + if (device == null) + return false; + + lock (_mappedDevicesLock) + return DispositivosMapeados.Remove(device); + } + + // ============================================================ + // UI E DIAGNÓSTICO + // ============================================================ + + private static void AtualizarConsole(string message) + { + Variaveis.MostrarLog(message); + + if (Variaveis.IsAgroMonitor) + return; + + _pendingUiMessage = message; + + if (Interlocked.Exchange( + ref _uiUpdateScheduled, + 1) == 1) + { + return; + } + + Form principal = frmInstancial.frmPrincipal; + + if (principal == null || + principal.IsDisposed || + principal.Disposing || + !principal.IsHandleCreated) + { + Interlocked.Exchange( + ref _uiUpdateScheduled, + 0 + ); + + return; + } + + try + { + principal.BeginInvoke( + (MethodInvoker)(() => + { + try + { + string pending = + _pendingUiMessage; + + if (frmInstancial.frmPrincipal != null && + !frmInstancial.frmPrincipal.IsDisposed) + { + frmInstancial.frmPrincipal + .lblStatusConexao + .Text = pending; + } + + if (frmInstancial.frmIHM != null && + frmInstancial.frmIHM.IsHandleCreated && + !frmInstancial.frmIHM.IsDisposed) + { + frmInstancial.frmIHM + .lblStatus + .Text = pending; + } + } + finally + { + Interlocked.Exchange( + ref _uiUpdateScheduled, + 0 + ); + + /* + * Se outra mensagem chegou durante o callback, + * agenda somente a mais recente. + */ + if (!string.Equals( + _pendingUiMessage, + message, + StringComparison.Ordinal)) + { + AtualizarConsole( + _pendingUiMessage + ); + } + } + }) + ); + } + catch + { + Interlocked.Exchange( + ref _uiUpdateScheduled, + 0 + ); + } + } + + public static string MostrarComandoResposta( + T_Code tipo, + byte[] comando, + byte[] resposta) + { + string msg = + tipo + + " - TX: " + + string.Join( + " ", + (comando ?? new byte[0]) + .Select(x => x.ToString("X2")) + ) + + ", RX: " + + string.Join( + " ", + (resposta ?? new byte[0]) + .Select(x => x.ToString("X2")) + ); + + Variaveis.MostrarLog(msg); return msg; } + // ============================================================ + // SHUTDOWN + // ============================================================ + public static async Task EncerrarAsync() + { + if (Interlocked.Exchange( + ref _isClosing, + 1) == 1) + { + return; + } + + CancellationTokenSource lifetime; + CancellationTokenSource scan; + + lock (_lifecycleLock) + { + lifetime = _lifetimeCts; + scan = _currentScanCts; + } + + try { scan?.Cancel(); } + catch { } + + try { lifetime?.Cancel(); } + catch { } + + /* + * Aguarda a varredura liberar o gate. + */ + await _scanGate.WaitAsync() + .ConfigureAwait(false); + + _scanGate.Release(); + + lock (_lifecycleLock) + { + if (ReferenceEquals( + _lifetimeCts, + lifetime)) + { + _lifetimeCts = null; + } + + _currentScanCts = null; + } + + if (lifetime != null) + lifetime.Dispose(); + + Interlocked.Exchange(ref _isScanning, 0); + } + + // ============================================================ + // MÉTRICAS + // ============================================================ + + public static SerialServiceMetrics GetMetrics() + { + string error; + double lastDuration; + double maxDuration; + + lock (_metricsTextLock) + { + error = _lastError; + lastDuration = _lastScanDurationMs; + maxDuration = _maxScanDurationMs; + } + + return new SerialServiceMetrics + { + IsScanning = + Volatile.Read(ref _isScanning) == 1, + + IsClosing = + Volatile.Read(ref _isClosing) == 1, + + ScanGeneration = + Interlocked.Read(ref _scanGeneration), + + ScanStarted = + Interlocked.Read(ref _scanStarted), + + ScanCompleted = + Interlocked.Read(ref _scanCompleted), + + ScanSkipped = + Interlocked.Read(ref _scanSkipped), + + ScanCanceled = + Interlocked.Read(ref _scanCanceled), + + ScanErrors = + Interlocked.Read(ref _scanErrors), + + LastScanStartAgeMs = + AgeMs( + Interlocked.Read( + ref _lastScanStartMono + ) + ), + + LastScanEndAgeMs = + AgeMs( + Interlocked.Read( + ref _lastScanEndMono + ) + ), + + LastScanDurationMs = lastDuration, + MaxScanDurationMs = maxDuration, + + PortsEnumerated = + Interlocked.Read( + ref _portsEnumerated + ), + + PortsProbed = + Interlocked.Read(ref _portsProbed), + + PortsBusy = + Interlocked.Read(ref _portsBusy), + + PortsProbeErrors = + Interlocked.Read( + ref _portsProbeErrors + ), + + GpsFound = + Interlocked.Read(ref _gpsFound), + + LoraFound = + Interlocked.Read(ref _loraFound), + + CanAdapterFound = + Interlocked.Read( + ref _canAdapterFound + ), + + DevicesRemoved = + Interlocked.Read( + ref _devicesRemoved + ), + + SerialWriteAttempts = + Interlocked.Read( + ref _serialWriteAttempts + ), + + SerialWriteSuccesses = + Interlocked.Read( + ref _serialWriteSuccesses + ), + + SerialWriteTimeouts = + Interlocked.Read( + ref _serialWriteTimeouts + ), + + SerialWriteErrors = + Interlocked.Read( + ref _serialWriteErrors + ), + + VersioningRuns = + Interlocked.Read( + ref _versioningRuns + ), + + VersioningErrors = + Interlocked.Read( + ref _versioningErrors + ), + + SyncRuns = + Interlocked.Read(ref _syncRuns), + + SyncErrors = + Interlocked.Read(ref _syncErrors), + + MappedDevices = + GetDispositivosMapeadosSnapshot() + .Count, + + LastError = error + }; + } + + // ============================================================ + // HELPERS + // ============================================================ + + private static List + CriarListaInicialDispositivos() + { + string ip = string.Empty; + + try + { + ip = EthernetService.ObterIpAtual( + VariaveisEquipamento + .Parametros + .comunicacao_interface + ); + } + catch + { + ip = string.Empty; + } + + return new List + { + new DispositivoDetalhesModel + { + Dispositivo = T_Code.Npc, + Endereco = ip, + Versao = Variaveis.Versao, + Mod_ID = string.Empty + } + }; + } + + private static SerialPort CriarPortaCandidata( + string portName) + { + return new SerialPort + { + PortName = portName, + BaudRate = 115200, + ReadTimeout = 500, + WriteTimeout = 500, + Handshake = Handshake.None, + DtrEnable = false, + RtsEnable = false, + Encoding = Encoding.ASCII + }; + } + + private static SemaphoreSlim GetPortIoLock( + SerialPort port) + { + string key = SafePortName(port); + + if (string.IsNullOrWhiteSpace(key)) + { + key = + "instance:" + + RuntimeHelpersCompat.GetIdentityHashCode( + port + ); + } + + return _portIoLocks.GetOrAdd( + key, + _ => new SemaphoreSlim(1, 1) + ); + } + + private static bool ValidarFaixaBuffer( + byte[] buffer, + int offset, + int count) + { + return buffer != null && + offset >= 0 && + count >= 0 && + offset <= buffer.Length && + count <= buffer.Length - offset; + } + + private static bool IsGpsPayload(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + return false; + + string text = + Encoding.ASCII.GetString(bytes); + + string[] markers = + { + "$GPTXT", + "$GPRMC", + "$GNRMC", + "$GPGGA", + "$GNGGA", + "$GLGGA", + "$GPGLL", + "$GPVTG", + "$GNVTG", + "$GPGSV", + "$GLGSV", + "$GNTHS", + "$GPTHS", + "$command," + }; + + return markers.Any( + x => text.IndexOf( + x, + StringComparison.OrdinalIgnoreCase + ) >= 0 + ); + } + + private static void EnsureLifetimeAvailable() + { + lock (_lifecycleLock) + { + if (_lifetimeCts == null || + _lifetimeCts + .IsCancellationRequested) + { + _lifetimeCts?.Dispose(); + _lifetimeCts = + new CancellationTokenSource(); + + Interlocked.Exchange( + ref _isClosing, + 0 + ); + } + } + } + + private static bool IsDue( + long previousTimestamp, + int intervalMs, + long now) + { + return previousTimestamp <= 0 || + ElapsedMs( + previousTimestamp, + now + ) >= Math.Max(1000, intervalMs); + } + + private static double AgeMs(long timestamp) + { + if (timestamp <= 0) + return double.PositiveInfinity; + + return ElapsedMs( + timestamp, + Stopwatch.GetTimestamp() + ); + } + + private static double ElapsedMs( + long start, + long end) + { + if (start <= 0 || end <= start) + return 0; + + return (end - start) * + 1000.0 / + Stopwatch.Frequency; + } + + private static string BuildMissingPortKey( + DispositivoDetalhesModel device) + { + if (device == null) + return string.Empty; + + return device.Dispositivo + "|" + + (device.Endereco ?? string.Empty) + "|" + + (device.Mod_ID ?? string.Empty); + } + + private static string SafePortName( + SerialPort port) + { + if (port == null) + return string.Empty; + + try { return port.PortName ?? string.Empty; } + catch { return string.Empty; } + } + + private static int PortSortKey(string port) + { + if (string.IsNullOrWhiteSpace(port)) + return int.MaxValue; + + string digits = + new string( + port.Where(char.IsDigit).ToArray() + ); + + int number; + + return int.TryParse(digits, out number) + ? number + : int.MaxValue - 1; + } + + private static void ClosePortOnly( + SerialPort port) + { + if (port == null) + return; + + try + { + if (port.IsOpen) + port.Close(); + } + catch { } + } + + private static void CloseAndDispose( + SerialPort port) + { + if (port == null) + return; + + ClosePortOnly(port); + + try { port.Dispose(); } + catch { } + } + + private static void RecordError(string error) + { + lock (_metricsTextLock) + _lastError = error; + + Variaveis.MostrarLog(error); + } + + /// + /// Evita depender de RuntimeHelpers em targets antigos. + /// + private static class RuntimeHelpersCompat + { + public static int GetIdentityHashCode(object value) + { + return value == null + ? 0 + : System.Runtime.CompilerServices + .RuntimeHelpers + .GetHashCode(value); + } + } + } + + public sealed class SerialServiceMetrics + { + public bool IsScanning { get; set; } + public bool IsClosing { get; set; } + public long ScanGeneration { get; set; } + + public long ScanStarted { get; set; } + public long ScanCompleted { get; set; } + public long ScanSkipped { get; set; } + public long ScanCanceled { get; set; } + public long ScanErrors { get; set; } + + public double LastScanStartAgeMs { get; set; } + public double LastScanEndAgeMs { get; set; } + public double LastScanDurationMs { get; set; } + public double MaxScanDurationMs { get; set; } + + public long PortsEnumerated { get; set; } + public long PortsProbed { get; set; } + public long PortsBusy { get; set; } + public long PortsProbeErrors { get; set; } + + public long GpsFound { get; set; } + public long LoraFound { get; set; } + public long CanAdapterFound { get; set; } + public long DevicesRemoved { get; set; } + + public long SerialWriteAttempts { get; set; } + public long SerialWriteSuccesses { get; set; } + public long SerialWriteTimeouts { get; set; } + public long SerialWriteErrors { get; set; } + + public long VersioningRuns { get; set; } + public long VersioningErrors { get; set; } + public long SyncRuns { get; set; } + public long SyncErrors { get; set; } + + public int MappedDevices { get; set; } + public string LastError { get; set; } } } diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/tcp_streamer.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/tcp_streamer.py index 10ab74e4b..8dd16ce8f 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/tcp_streamer.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/tcp_streamer.py @@ -1,71 +1,249 @@ +import math import socket import struct import threading import time +import zlib +from typing import Any, Dict, Optional, Tuple import cv2 +from shared.contexto_global_redis import ContextoGlobalRedis from shared.enums import T_Code from shared.utils import resize_frame -from shared.contexto_global_redis import ContextoGlobalRedis class CameraTcpStreamer: - def __init__(self, porta: int, mx_id: str, mostrar_log=None): - self.mx_id = mx_id + """ + Streamer TCP de vídeo orientado a campo. + + Objetivos desta versão: + 1. Vídeo nunca deve criar uma fila ilimitada: existe somente um frame pendente. + Se chegar outro, o antigo é descartado e o vídeo permanece atual. + 2. O envio ocorre em uma thread própria. A thread da câmera/inferência nunca + fica presa esperando o TCP liberar buffer. + 3. Existe um teto real de banda por câmera, aplicado por token bucket. + 4. A política usa o Health Worker do IPB, mas também observa backpressure + local pelo tempo de send e pelos timeouts do próprio socket. + 5. MQTT/heartbeat degradado não significa que a rede caiu, porém faz o vídeo + ceder banda agressivamente para proteger RTCM, controle e telemetria. + 6. Em enlace crítico/queda confirmada, o vídeo é pausado e o socket fechado. + 7. Reconexão usa backoff exponencial com jitter estável, evitando que duas + câmeras reconectem em sincronia. + 8. O protocolo existente é preservado: uint32 big-endian + JPEG. + + Parâmetros novos são opcionais para preservar chamadas existentes: + CameraTcpStreamer(porta, mx_id, mostrar_log) + + max_stream_kbps: + Teto absoluto desta câmera. Com duas câmeras e o padrão de 1600 kbps, + o vídeo total não passa de aproximadamente 3,2 Mbps. + + safe_link_fraction: + Fração da banda segura estimada pelo IPB que uma câmera pode ocupar. + O padrão de 0,30 reserva espaço para a outra câmera e para tráfego crítico. + """ + + MODE_EMERGENCY = -1 + MODE_MIN = -1 + MODE_MAX = 3 + + POLICY_NORMAL = "NORMAL" + POLICY_CONSERVATIVE = "CONSERVATIVE" + POLICY_PROTECTING = "PROTECTING" + POLICY_PAUSED = "PAUSED" + + def __init__( + self, + porta: int, + mx_id: str, + mostrar_log=None, + max_stream_kbps: float = 1600.0, + safe_link_fraction: float = 0.30, + copiar_frame: bool = True, + ): + self.mx_id = str(mx_id) self.mostrar_log = mostrar_log or (lambda msg: None) - # --------------------------------------------------------- + # ------------------------------------------------------------------ + # CONFIGURAÇÃO DE BANDA + # ------------------------------------------------------------------ + self._max_stream_kbps_default = self._clamp_float( + max_stream_kbps, + 200.0, + 10000.0, + 1600.0, + ) + self._max_stream_kbps = self._max_stream_kbps_default + self._safe_link_fraction_default = self._clamp_float( + safe_link_fraction, + 0.05, + 0.80, + 0.30, + ) + self._safe_link_fraction = self._safe_link_fraction_default + self._stream_priority = 1.0 + self._copiar_frame = bool(copiar_frame) + + # ------------------------------------------------------------------ # SOCKET TCP - # --------------------------------------------------------- - - self.sock_port = porta - - self._sock = None + # ------------------------------------------------------------------ + self.sock_port = int(porta) + self._sock: Optional[socket.socket] = None self._sock_conectado = False + self._sock_base_ip: Optional[str] = None self._sock_lock = threading.RLock() - self._sock_ultima_tentativa_conexao = 0.0 - self._sock_intervalo_reconexao = 1.0 - self._sock_timeout_conexao_s = 1.0 - self._sock_timeout_envio_s = 0.75 + # Vídeo é descartável. Um send bloqueado por muito tempo deve falhar + # rápido, fechar a fila TCP e abrir espaço para tráfego essencial. + self._sock_timeout_envio_s = 0.35 + self._sock_send_buffer_bytes = 64 * 1024 - # --------------------------------------------------------- + self._reconnect_backoff_min_s = 1.0 + self._reconnect_backoff_max_s = 20.0 + self._reconnect_backoff_s = self._reconnect_backoff_min_s + self._next_connect_mono = 0.0 + self._connect_failures = 0 + self._last_transport_error: Optional[str] = None + + # Fase estável por câmera para que duas instâncias não reconectem nem + # aumentem qualidade exatamente no mesmo instante. + self._instance_phase = ( + zlib.crc32(self.mx_id.encode("utf-8")) % 1000 + ) / 1000.0 + + # ------------------------------------------------------------------ # CONTROLE ADAPTATIVO - # --------------------------------------------------------- - - agora_mono = time.monotonic() - + # ------------------------------------------------------------------ + now = time.monotonic() self._op_mode = 1 self._op_fps = 2.0 + self._last_mode_up_mono = now + self._last_mode_down_mono = 0.0 + self._mode_up_interval_s = 14.0 + (self._instance_phase * 4.0) + self._mode_down_interval_s = 0.8 + self._stable_low_pressure_since_mono = 0.0 + self._emergency_hold_until_mono = 0.0 - # Recupera qualidade lentamente. - self._last_mode_up_ts = agora_mono - self._mode_up_interval_s = 6.0 + # Política derivada do IPB. + self._policy_lock = threading.RLock() + self._policy_refresh_interval_s = 0.50 + self._last_policy_refresh_mono = 0.0 + self._policy: Dict[str, Any] = self._default_policy() - # Reduz qualidade rapidamente. - self._last_mode_down_ts = 0.0 - self._mode_down_interval_s = 1.0 + # ------------------------------------------------------------------ + # TOKEN BUCKET / TETO DE BANDA + # ------------------------------------------------------------------ + self._budget_kbps = min(900.0, self._max_stream_kbps) + self._budget_target_kbps = self._budget_kbps + self._budget_tokens_bytes = 0.0 + self._budget_last_refill_mono = now + self._budget_initialized = False - # --------------------------------------------------------- - # MÉTRICAS DO STREAMING - # --------------------------------------------------------- + # ------------------------------------------------------------------ + # FILA DE ÚLTIMO FRAME / THREAD DE ENVIO + # ------------------------------------------------------------------ + self._frame_condition = threading.Condition(threading.RLock()) + self._pending_frame = None + self._pending_frame_seq = 0 + self._last_processed_frame_seq = 0 + self._next_enqueue_mono = 0.0 + self._stop_event = threading.Event() - self._last_frame_sent_mono = 0.0 - self._stream_kbps_ema = 0.0 self._streaming_ativo = False + self._stream_paused = False + self._stream_pause_reason: Optional[str] = None - # --------------------------------------------------------- - # CONTROLE DE LOG - # --------------------------------------------------------- + self._worker = threading.Thread( + target=self._sender_loop, + daemon=True, + name=f"camera-stream-{self.mx_id}", + ) + # ------------------------------------------------------------------ + # MÉTRICAS + # ------------------------------------------------------------------ + self._metrics_lock = threading.RLock() + self._last_frame_sent_mono = 0.0 + self._last_frame_sent_wall = 0.0 + self._stream_kbps_ema = 0.0 + self._send_ms_ema = 0.0 + self._encode_ms_ema = 0.0 + self._local_backpressure_pct = 0.0 + self._last_send_error_mono = 0.0 + self._consecutive_send_errors = 0 + self._consecutive_send_successes = 0 + + self._frames_received = 0 + self._frames_sent = 0 + self._frames_dropped_pending = 0 + self._frames_dropped_budget = 0 + self._frames_dropped_policy = 0 + self._frames_dropped_invalid = 0 + self._frames_dropped_encode = 0 + + # ------------------------------------------------------------------ + # PUBLICAÇÃO / LOG + # ------------------------------------------------------------------ self._log_intervalo_s = 5.0 - self._last_log_ts = {} + self._last_log_ts: Dict[str, float] = {} + self._publish_interval_s = 1.0 + self._last_publish_mono = 0.0 + self._last_published_signature = None - # ============================================================= - # LOG - # ============================================================= + # Inicia somente depois de todo o estado da instância estar pronto. + self._worker.start() + + # ====================================================================== + # UTILITÁRIOS + # ====================================================================== + + @staticmethod + def _clamp_float(value, minimum, maximum, default): + try: + result = float(value) + except (TypeError, ValueError): + result = float(default) + + if not math.isfinite(result): + result = float(default) + + return max(float(minimum), min(float(maximum), result)) + + @staticmethod + def _as_bool(value, default=False): + if value is None: + return bool(default) + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "sim", "yes", "on", "ativo"}: + return True + if normalized in {"0", "false", "nao", "não", "no", "off", "inativo"}: + return False + return bool(value) + + @staticmethod + def _safe_float(value, default=0.0): + try: + result = float(value) + except (TypeError, ValueError): + return float(default) + return result if math.isfinite(result) else float(default) + + @staticmethod + def _ema(previous: float, current: float, alpha: float) -> float: + if previous <= 0.0: + return float(current) + return (previous * (1.0 - alpha)) + (float(current) * alpha) + + # ====================================================================== + # LOG / REDIS + # ====================================================================== def _log(self, msg: str): try: @@ -73,404 +251,739 @@ class CameraTcpStreamer: except Exception: pass - def _log_throttled(self, chave: str, msg: str): - """ - Evita inundar os logs quando uma falha persiste. - """ - agora = time.monotonic() - ultimo = self._last_log_ts.get(chave, 0.0) - - if (agora - ultimo) >= self._log_intervalo_s: - self._last_log_ts[chave] = agora + def _log_throttled(self, key: str, msg: str): + now = time.monotonic() + last = self._last_log_ts.get(key, 0.0) + if (now - last) >= self._log_intervalo_s: + self._last_log_ts[key] = now self._log(msg) - # ============================================================= - # CONTEXTO REDIS - # ============================================================= + def _publicar_estado(self, force=False, **fields): + now = time.monotonic() + signature = tuple(sorted((key, repr(value)) for key, value in fields.items())) + + if not force: + if ( + (now - self._last_publish_mono) < self._publish_interval_s + and signature == self._last_published_signature + ): + return - def _publicar_estado(self, **campos): - """ - Publicação de telemetria não pode derrubar o streaming caso - ocorra alguma falha temporária no contexto Redis. - """ try: ContextoGlobalRedis.atualizar_ctx_dict( ContextoGlobalRedis.CamKey(self.mx_id), - **campos, + **fields, ) - except Exception as e: + self._last_publish_mono = now + self._last_published_signature = signature + except Exception as exc: self._log_throttled( "redis_publish", - ( - f"[{self.mx_id}] Falha ao publicar estado " - f"do streaming: {e}" - ), + f"[{self.mx_id}] Falha ao publicar estado do streaming: {exc}", ) - # ============================================================= - # SOCKET - # ============================================================= + def _camera_context(self) -> Dict[str, Any]: + try: + return ContextoGlobalRedis.get_camera(self.mx_id) or {} + except Exception as exc: + self._log_throttled( + "camera_context_error", + f"[{self.mx_id}] Falha ao obter contexto da câmera: {exc}", + ) + return {} - def _fechar_socket(self): - """ - Fecha somente o socket, sem publicar estado. - Pode ser usado durante reconexões internas. - """ + def _apply_camera_overrides(self, camera: Dict[str, Any]): + # Permite ajuste por câmera via contexto sem alterar o construtor. + max_kbps = camera.get("stream_max_kbps") + fraction = camera.get("stream_safe_link_fraction") + priority = camera.get("stream_priority") + + self._max_stream_kbps = self._clamp_float( + max_kbps, + 200.0, + 10000.0, + self._max_stream_kbps_default, + ) if max_kbps is not None else self._max_stream_kbps_default + + self._safe_link_fraction = self._clamp_float( + fraction, + 0.05, + 0.80, + self._safe_link_fraction_default, + ) if fraction is not None else self._safe_link_fraction_default + + self._stream_priority = self._clamp_float( + priority, + 0.25, + 2.0, + 1.0, + ) if priority is not None else 1.0 + + # ====================================================================== + # SOCKET + # ====================================================================== + + def _configure_socket(self, sock: socket.socket): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt( + socket.SOL_SOCKET, + socket.SO_SNDBUF, + int(self._sock_send_buffer_bytes), + ) + + # CS1 / Scavenger. É best-effort: alguns sistemas e bridges ignoram, + # mas, quando respeitado, ajuda o vídeo a perder prioridade para RTCM. + try: + sock.setsockopt(socket.IPPROTO_IP, socket.IP_TOS, 0x20) + except Exception: + pass + + # Keepalive mais curto, também best-effort e multiplataforma. + try: + if hasattr(socket, "TCP_KEEPIDLE"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10) + if hasattr(socket, "TCP_KEEPINTVL"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3) + if hasattr(socket, "TCP_KEEPCNT"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) + except Exception: + pass + + try: + if hasattr(socket, "SIO_KEEPALIVE_VALS"): + sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 10000, 3000)) + except Exception: + pass + + def _fechar_socket(self, reason: Optional[str] = None): with self._sock_lock: sock = self._sock - self._sock = None self._sock_conectado = False + self._sock_base_ip = None if sock is not None: try: sock.shutdown(socket.SHUT_RDWR) except Exception: pass - try: sock.close() except Exception: pass - def fechar(self): - """ - Encerramento público do streamer. - """ - self._fechar_socket() + if reason: + self._log_throttled( + f"socket_close_{reason}", + f"[{self.mx_id}] Socket de vídeo fechado: {reason}", + ) - self._stream_kbps_ema = 0.0 - self._last_frame_sent_mono = 0.0 - self._streaming_ativo = False + def _schedule_reconnect_failure(self): + now = time.monotonic() + self._connect_failures += 1 - self._publicar_estado( - stream_active=False, - stream_connected=False, - stream_kbps=0.0, - stream_fps=0.0, + jitter = 0.85 + (self._instance_phase * 0.30) + delay = self._reconnect_backoff_s * jitter + self._next_connect_mono = now + delay + self._reconnect_backoff_s = min( + self._reconnect_backoff_max_s, + max( + self._reconnect_backoff_min_s, + self._reconnect_backoff_s * 1.8, + ), ) - def _sock_tentar_conectar(self): - try: - cfg = ContextoGlobalRedis.get_equipamento() or {} - ip_base = cfg.get("base_ip") + def _reset_reconnect_backoff(self): + self._connect_failures = 0 + self._reconnect_backoff_s = self._reconnect_backoff_min_s + self._next_connect_mono = 0.0 - except Exception as e: + def _get_base_ip(self) -> Optional[str]: + try: + config = ContextoGlobalRedis.get_equipamento() or {} + base_ip = config.get("base_ip") + return str(base_ip).strip() if base_ip else None + except Exception as exc: self._log_throttled( "base_config_error", - f"[{self.mx_id}] Falha ao obter IP da base: {e}", + f"[{self.mx_id}] Falha ao obter IP da base: {exc}", ) - return + return None - if not ip_base or not self.sock_port: - return + def _sock_tentar_conectar(self) -> bool: + base_ip = self._get_base_ip() + if not base_ip or not self.sock_port: + return False - agora = time.monotonic() + now = time.monotonic() + if now < self._next_connect_mono: + return False - if ( - agora - self._sock_ultima_tentativa_conexao - ) < self._sock_intervalo_reconexao: - return + with self._sock_lock: + if ( + self._sock_conectado + and self._sock is not None + and self._sock_base_ip == base_ip + ): + return True - self._sock_ultima_tentativa_conexao = agora - - # Remove qualquer socket anterior antes de reconectar. self._fechar_socket() - - novo_socket = None + new_socket = None try: - novo_socket = socket.socket( - socket.AF_INET, - socket.SOCK_STREAM, - ) - - novo_socket.setsockopt( - socket.IPPROTO_TCP, - socket.TCP_NODELAY, - 1, - ) - - novo_socket.setsockopt( - socket.SOL_SOCKET, - socket.SO_KEEPALIVE, - 1, - ) - - # Timeout usado somente durante a conexão. - novo_socket.settimeout( - self._sock_timeout_conexao_s - ) - - novo_socket.connect( - (ip_base, self.sock_port) - ) - - # Timeout mantido para impedir sendall bloqueado - # indefinidamente em caso de enlace congestionado. - novo_socket.settimeout( - self._sock_timeout_envio_s - ) + new_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._configure_socket(new_socket) + new_socket.settimeout(self._sock_timeout_conexao_s) + new_socket.connect((base_ip, self.sock_port)) + new_socket.settimeout(self._sock_timeout_envio_s) with self._sock_lock: - self._sock = novo_socket + self._sock = new_socket self._sock_conectado = True + self._sock_base_ip = base_ip + self._reset_reconnect_backoff() + self._last_transport_error = None self._publicar_estado( + force=True, stream_connected=True, stream_last_error=None, ) + self._log(f"[{self.mx_id}] Socket de vídeo conectado em {base_ip}:{self.sock_port}.") + return True - self._log( - f"[{self.mx_id}] Socket de vídeo conectado." - ) - - except Exception as e: - if novo_socket is not None: + except Exception as exc: + if new_socket is not None: try: - novo_socket.close() + new_socket.close() except Exception: pass with self._sock_lock: self._sock = None self._sock_conectado = False + self._sock_base_ip = None + self._schedule_reconnect_failure() + self._last_transport_error = str(exc) self._publicar_estado( stream_connected=False, - stream_kbps=0.0, - stream_fps=0.0, - stream_last_error=str(e), + stream_last_error=str(exc), ) - self._log_throttled( "connect_error", - ( - f"[{self.mx_id}] Falha ao conectar " - f"socket de vídeo: {e}" - ), + f"[{self.mx_id}] Falha ao conectar socket de vídeo: {exc}", ) + return False - # ============================================================= - # PRESSÃO DO ENLACE - # ============================================================= + def _socket_snapshot(self) -> Tuple[Optional[socket.socket], bool]: + with self._sock_lock: + return self._sock, bool(self._sock_conectado and self._sock is not None) - def _get_ipb_bw_pressure(self): - """ - Retorna a pressão atual do enlace entre 0 e 100. + # ====================================================================== + # POLÍTICA DO ENLACE + # ====================================================================== - None significa que ainda não existem dados confiáveis - suficientes para alterar o modo atual. - """ - try: - ipb = ( - ContextoGlobalRedis.get_modulo(T_Code.Ipb) - or {} - ) - - saude = ipb.get("saude") or {} - detalhes = saude.get("detalhes") or {} - - except Exception: - return None - - # IPB ainda não publicou saúde. - if not saude: - return None - - conectado = bool( - saude.get("conectado", False) - ) - - valor = detalhes.get("bw_pressure_pct") - - if valor is None: - valor = detalhes.get("bw_util_pct") - - # O IPB publicou estado, mas está desconectado. - # Força comportamento conservador. - if valor is None: - return 90.0 if not conectado else None - - try: - pressure = float(valor) - - except (TypeError, ValueError): - return 90.0 if not conectado else None - - pressure = max( - 0.0, - min(100.0, pressure), - ) - - if not conectado: - pressure = max(pressure, 90.0) - - return pressure - - # ============================================================= - # MODOS DE STREAMING - # ============================================================= - - def _mode_params(self, mode: int): - """ - Os limites são máximos. A resolução real pode ser menor, - pois resize_frame preserva a proporção original. - """ - tabela = { - 3: { - "fps": 5.0, - "q": 55, - "w": 640, - "h": 360, - }, - 2: { - "fps": 3.0, - "q": 45, - "w": 640, - "h": 360, - }, - 1: { - "fps": 2.0, - "q": 40, - "w": 480, - "h": 270, - }, - 0: { - "fps": 1.0, - "q": 35, - "w": 320, - "h": 180, - }, + def _default_policy(self) -> Dict[str, Any]: + return { + "available": False, + "pause": False, + "pause_reason": None, + "level": self.POLICY_CONSERVATIVE, + "pressure_pct": 45.0, + "budget_target_kbps": min(900.0, self._max_stream_kbps_default), + "max_mode": 1, + "link_state": "UNKNOWN", + "network_health": 75.0, + "assessment_confidence_pct": 0.0, + "mqtt_connected": None, + "heartbeat_ok": None, + "heartbeat_age_s": -1.0, + "bandwidth_corroborated": False, + "bw_safe_mbps": 6.0, + "bw_total_mbps": 0.0, + "route_ok": None, + "down_confirmed": False, + "connected": None, } - return tabela.get( - int(mode), - tabela[1], + def _read_ipb_snapshot(self) -> Dict[str, Any]: + try: + module = ContextoGlobalRedis.get_modulo(T_Code.Ipb) or {} + health = module.get("saude") or {} + details = health.get("detalhes") or {} + except Exception as exc: + self._log_throttled( + "ipb_context_error", + f"[{self.mx_id}] Falha ao ler saúde do IPB: {exc}", + ) + return {} + + if not health: + return {} + + return { + "health": health, + "details": details, + } + + def _derive_policy(self, snapshot: Dict[str, Any]) -> Dict[str, Any]: + if not snapshot: + policy = self._default_policy() + policy["budget_target_kbps"] = min( + self._max_stream_kbps, + 900.0, + ) + return policy + + health = snapshot.get("health") or {} + details = snapshot.get("details") or {} + + connected = self._as_bool(health.get("conectado"), True) + link_state = str(details.get("link_state") or "UNKNOWN").upper() + route_ok = self._as_bool(details.get("route_ok"), connected) + down_confirmed = self._as_bool(details.get("down_confirmed"), False) + should_stop = self._as_bool(details.get("deve_parar_por_rede"), False) + + pressure = self._safe_float( + details.get("bw_pressure_pct", details.get("bw_util_pct", 45.0)), + 45.0, + ) + pressure = max(0.0, min(100.0, pressure)) + + confidence = self._safe_float( + details.get("assessment_confidence_pct"), + 0.0, + ) + network_health = self._safe_float( + details.get("network_health", health.get("saude", 75.0)), + 75.0, + ) + bw_safe_mbps = max( + 0.20, + self._safe_float(details.get("bw_safe_mbps"), 6.0), + ) + bw_total_mbps = max( + 0.0, + self._safe_float(details.get("bw_total_mbps"), 0.0), ) - def _maybe_update_mode(self): - pressure = self._get_ipb_bw_pressure() - agora = time.monotonic() + mqtt_raw = details.get("mqtt_connected") + hb_raw = details.get("heartbeat_ok") + mqtt_connected = None if mqtt_raw is None else self._as_bool(mqtt_raw) + heartbeat_ok = None if hb_raw is None else self._as_bool(hb_raw) + heartbeat_in_grace = self._as_bool( + details.get("heartbeat_in_grace"), + False, + ) + heartbeat_age_s = self._safe_float( + details.get("atraso_hb"), + -1.0, + ) + bandwidth_corroborated = self._as_bool( + details.get("bandwidth_corroborated"), + False, + ) - if pressure is None: - return None + # Envelope total de vídeo: 60% da banda considerada segura. Cada + # câmera possui ainda seu próprio share, por padrão 30%. + safe_kbps = bw_safe_mbps * 1000.0 + base_share_kbps = ( + safe_kbps + * self._safe_link_fraction + * self._stream_priority + ) + base_share_kbps = min(self._max_stream_kbps, base_share_kbps) - # --------------------------------------------------------- - # EMERGÊNCIA - # Pressão superior a 85 pode reduzir até dois níveis. - # --------------------------------------------------------- + # Estima quanto sobra do envelope de vídeo, descontando todo tráfego + # observado exceto o desta própria instância. Isso faz cada câmera ver + # a outra câmera como concorrente, mesmo sem um coordenador global. + with self._metrics_lock: + own_stream_kbps = max(0.0, self._stream_kbps_ema) - if pressure > 85.0: - if ( - self._op_mode > 0 + total_kbps = bw_total_mbps * 1000.0 + other_observed_kbps = max(0.0, total_kbps - own_stream_kbps) + video_envelope_kbps = safe_kbps * 0.60 + available_for_this_stream = max( + 180.0, + video_envelope_kbps - other_observed_kbps, + ) + + if confidence >= 40.0: + budget = min(base_share_kbps, available_for_this_stream) + else: + budget = min(base_share_kbps, 900.0) + + budget = max(180.0, budget) + max_mode = self.MODE_MAX + level = self.POLICY_NORMAL + pause = False + pause_reason = None + + hard_down = ( + down_confirmed + or (not route_ok and confidence >= 30.0) + or (not connected and confidence >= 50.0) + ) + hard_critical = ( + link_state == "CRITICAL" + or should_stop + ) + + if hard_down: + pause = True + pause_reason = "queda_confirmada_do_enlace" + level = self.POLICY_PAUSED + max_mode = self.MODE_EMERGENCY + budget = 0.0 + elif hard_critical: + pause = True + pause_reason = "enlace_critico" + level = self.POLICY_PAUSED + max_mode = self.MODE_EMERGENCY + budget = 0.0 + else: + service_protection = ( + not heartbeat_in_grace and ( - agora - self._last_mode_down_ts - ) >= self._mode_down_interval_s - ): - self._op_mode = max( - 0, - self._op_mode - 2, + mqtt_connected is False + or heartbeat_ok is False + or heartbeat_age_s >= 7.0 ) + ) - self._last_mode_down_ts = agora + if link_state == "DEGRADED": + level = self.POLICY_PROTECTING + budget *= 0.45 + max_mode = min(max_mode, 0) - return pressure + if network_health < 60.0: + level = self.POLICY_PROTECTING + budget *= 0.50 + max_mode = min(max_mode, 0) + elif network_health < 78.0: + if level == self.POLICY_NORMAL: + level = self.POLICY_CONSERVATIVE + budget *= 0.75 + max_mode = min(max_mode, 1) - # --------------------------------------------------------- - # PRESSÃO ALTA - # Reduz um nível rapidamente. - # --------------------------------------------------------- + if pressure >= 88.0: + level = self.POLICY_PROTECTING + budget *= 0.22 + max_mode = min(max_mode, self.MODE_EMERGENCY) + elif pressure >= 75.0: + level = self.POLICY_PROTECTING + budget *= 0.42 + max_mode = min(max_mode, 0) + elif pressure >= 58.0: + level = self.POLICY_CONSERVATIVE + budget *= 0.70 + max_mode = min(max_mode, 1) - if pressure > 75.0: + if bandwidth_corroborated: + level = self.POLICY_PROTECTING + budget *= 0.70 + if pressure >= 65.0: + max_mode = min(max_mode, 0) + + # O MQTT não sentencia a rede, mas o vídeo cede espaço porque RTCM + # é mais importante. Mantemos imagem mínima para diagnóstico. + if service_protection: + level = self.POLICY_PROTECTING + budget *= 0.30 + max_mode = min(max_mode, self.MODE_EMERGENCY) + + if confidence < 30.0: + level = self.POLICY_CONSERVATIVE + budget = min(budget, 900.0) + max_mode = min(max_mode, 1) + + budget = max(180.0, min(self._max_stream_kbps, budget)) + + return { + "available": True, + "pause": pause, + "pause_reason": pause_reason, + "level": level, + "pressure_pct": round(pressure, 1), + "budget_target_kbps": round(budget, 1), + "max_mode": int(max_mode), + "link_state": link_state, + "network_health": round(network_health, 1), + "assessment_confidence_pct": round(confidence, 1), + "mqtt_connected": mqtt_connected, + "heartbeat_ok": heartbeat_ok, + "heartbeat_age_s": round(heartbeat_age_s, 2), + "bandwidth_corroborated": bandwidth_corroborated, + "bw_safe_mbps": round(bw_safe_mbps, 3), + "bw_total_mbps": round(bw_total_mbps, 3), + "route_ok": route_ok, + "down_confirmed": down_confirmed, + "connected": connected, + } + + def _refresh_policy_if_needed(self, force=False) -> Dict[str, Any]: + now = time.monotonic() + with self._policy_lock: if ( - self._op_mode > 0 - and ( - agora - self._last_mode_down_ts - ) >= self._mode_down_interval_s + not force + and (now - self._last_policy_refresh_mono) + < self._policy_refresh_interval_s ): - self._op_mode -= 1 - self._last_mode_down_ts = agora + return dict(self._policy) - return pressure + snapshot = self._read_ipb_snapshot() + policy = self._derive_policy(snapshot) - # --------------------------------------------------------- - # ZONA NEUTRA - # Entre 50 e 75 não aumenta nem reduz. - # --------------------------------------------------------- + with self._policy_lock: + self._policy = policy + self._last_policy_refresh_mono = now - if pressure >= 50.0: - return pressure + self._update_budget_target(policy["budget_target_kbps"], policy) + return dict(policy) - # --------------------------------------------------------- - # ENLACE FOLGADO - # Recupera somente um nível por vez e lentamente. - # --------------------------------------------------------- + # ====================================================================== + # BUDGET / TOKEN BUCKET + # ====================================================================== + + def _update_budget_target(self, target_kbps: float, policy: Dict[str, Any]): + now = time.monotonic() + target = max(0.0, min(self._max_stream_kbps, float(target_kbps))) + dt = max(0.0, now - self._budget_last_refill_mono) + + self._refill_budget_tokens(now) + self._budget_target_kbps = target + + if policy.get("pause"): + self._budget_kbps = 0.0 + self._budget_tokens_bytes = 0.0 + return + + if self._budget_kbps <= 0.0: + self._budget_kbps = min(target, 400.0) + elif target < self._budget_kbps: + # Redução rápida. Quedas grandes são aplicadas imediatamente. + if target <= self._budget_kbps * 0.70: + self._budget_kbps = target + else: + self._budget_kbps = max( + target, + self._budget_kbps - max(150.0, dt * 800.0), + ) + elif target > self._budget_kbps: + # Recuperação lenta para não formar onda com as duas câmeras. + self._budget_kbps = min( + target, + self._budget_kbps + max(20.0, dt * 90.0), + ) + + self._budget_kbps = max(180.0, min(self._max_stream_kbps, self._budget_kbps)) + self._clamp_budget_tokens() + + def _budget_capacity_bytes(self) -> float: + if self._budget_kbps <= 0.0: + return 0.0 + bytes_per_second = (self._budget_kbps * 1000.0) / 8.0 + # Permite um JPEG isolado mesmo no modo mínimo, sem permitir rajadas + # longas que voltem a encher a fila do rádio. + return max(64.0 * 1024.0, bytes_per_second * 1.25) + + def _clamp_budget_tokens(self): + capacity = self._budget_capacity_bytes() + self._budget_tokens_bytes = max( + 0.0, + min(capacity, self._budget_tokens_bytes), + ) + + def _refill_budget_tokens(self, now=None): + if now is None: + now = time.monotonic() + + dt = max(0.0, now - self._budget_last_refill_mono) + self._budget_last_refill_mono = now + + capacity = self._budget_capacity_bytes() + if capacity <= 0.0: + self._budget_tokens_bytes = 0.0 + return + + if not self._budget_initialized: + self._budget_tokens_bytes = capacity + self._budget_initialized = True + return + + bytes_per_second = (self._budget_kbps * 1000.0) / 8.0 + self._budget_tokens_bytes = min( + capacity, + self._budget_tokens_bytes + (bytes_per_second * dt), + ) + + def _consume_budget(self, payload_bytes: int) -> bool: + now = time.monotonic() + self._refill_budget_tokens(now) + + if self._budget_kbps <= 0.0: + return False + + if float(payload_bytes) > self._budget_tokens_bytes: + return False + + self._budget_tokens_bytes -= float(payload_bytes) + return True + + # ====================================================================== + # MODOS / BACKPRESSURE + # ====================================================================== + + def _mode_params(self, mode: int) -> Dict[str, Any]: + table = { + 3: {"fps": 5.0, "q": 55, "w": 640, "h": 360}, + 2: {"fps": 3.0, "q": 45, "w": 640, "h": 360}, + 1: {"fps": 2.0, "q": 40, "w": 480, "h": 270}, + 0: {"fps": 1.0, "q": 35, "w": 320, "h": 180}, + -1: {"fps": 0.5, "q": 30, "w": 256, "h": 144}, + } + return table.get(int(mode), table[1]) + + @staticmethod + def _budget_mode_ceiling(budget_kbps: float) -> int: + if budget_kbps < 320.0: + return -1 + if budget_kbps < 620.0: + return 0 + if budget_kbps < 1000.0: + return 1 + if budget_kbps < 1400.0: + return 2 + return 3 + + def _calculate_local_backpressure(self, send_ms: float, target_fps: float) -> float: + period_ms = 1000.0 / max(0.1, target_fps) + ratio = send_ms / max(1.0, period_ms) + + if send_ms <= 40.0: + absolute_pressure = 0.0 + elif send_ms <= 100.0: + absolute_pressure = 20.0 + elif send_ms <= 180.0: + absolute_pressure = 45.0 + elif send_ms <= 280.0: + absolute_pressure = 70.0 + else: + absolute_pressure = 92.0 + + ratio_pressure = max(0.0, min(100.0, ratio * 110.0)) + return max(absolute_pressure, ratio_pressure) + + def _maybe_update_mode(self, policy: Dict[str, Any]): + now = time.monotonic() + + with self._metrics_lock: + local_pressure = self._local_backpressure_pct + stream_kbps = self._stream_kbps_ema + last_error_age = ( + now - self._last_send_error_mono + if self._last_send_error_mono > 0.0 + else math.inf + ) + + effective_pressure = max( + float(policy.get("pressure_pct", 45.0)), + local_pressure, + ) + + max_mode = min( + int(policy.get("max_mode", 1)), + self._budget_mode_ceiling(self._budget_kbps), + ) + + if now < self._emergency_hold_until_mono: + max_mode = min(max_mode, self.MODE_EMERGENCY) + effective_pressure = max(effective_pressure, 90.0) + + if self._op_mode > max_mode: + self._op_mode = max_mode + self._last_mode_down_mono = now + + over_budget = ( + self._budget_kbps > 0.0 + and stream_kbps > (self._budget_kbps * 1.08) + ) + + should_drop_two = effective_pressure >= 88.0 + should_drop_one = effective_pressure >= 72.0 or over_budget if ( - self._op_mode < 3 - and ( - agora - self._last_mode_up_ts - ) >= self._mode_up_interval_s + should_drop_two + and self._op_mode > self.MODE_EMERGENCY + and (now - self._last_mode_down_mono) >= self._mode_down_interval_s ): - self._op_mode += 1 - self._last_mode_up_ts = agora + self._op_mode = max( + self.MODE_EMERGENCY, + self._op_mode - 2, + ) + self._last_mode_down_mono = now + self._stable_low_pressure_since_mono = 0.0 - return pressure + elif ( + should_drop_one + and self._op_mode > self.MODE_EMERGENCY + and (now - self._last_mode_down_mono) >= self._mode_down_interval_s + ): + self._op_mode -= 1 + self._last_mode_down_mono = now + self._stable_low_pressure_since_mono = 0.0 - # ============================================================= - # ESTADO INATIVO - # ============================================================= + elif effective_pressure < 40.0 and last_error_age >= 10.0: + if self._stable_low_pressure_since_mono <= 0.0: + self._stable_low_pressure_since_mono = now - def _set_streaming_inativo(self): - """ - Executa a limpeza apenas na transição ligado -> desligado. - """ - if not self._streaming_ativo: - return + stable_for = now - self._stable_low_pressure_since_mono + if ( + stable_for >= self._mode_up_interval_s + and self._op_mode < max_mode + and (now - self._last_mode_up_mono) >= self._mode_up_interval_s + ): + self._op_mode += 1 + self._last_mode_up_mono = now + self._stable_low_pressure_since_mono = now + else: + self._stable_low_pressure_since_mono = 0.0 - self._streaming_ativo = False - - self._fechar_socket() - - self._stream_kbps_ema = 0.0 - self._last_frame_sent_mono = 0.0 - - self._publicar_estado( - stream_active=False, - stream_connected=False, - stream_kbps=0.0, - stream_fps=0.0, + self._op_mode = max( + self.MODE_EMERGENCY, + min(max_mode, self._op_mode), ) + self._op_fps = float(self._mode_params(self._op_mode)["fps"]) - # ============================================================= - # ENVIO - # ============================================================= + # ====================================================================== + # FILA / CICLO PÚBLICO + # ====================================================================== + + @staticmethod + def _frame_is_valid(frame) -> bool: + return bool( + frame is not None + and hasattr(frame, "shape") + and hasattr(frame, "size") + and frame.size > 0 + and len(frame.shape) >= 2 + ) def enviar_frame_tcp(self, frame_bgr): - # --------------------------------------------------------- - # CONFIGURAÇÃO DA CÂMERA - # --------------------------------------------------------- + """ + Entrada não bloqueante chamada pela câmera. - try: - camera = ( - ContextoGlobalRedis.get_camera(self.mx_id) - or {} - ) - - except Exception as e: - self._log_throttled( - "camera_context_error", - ( - f"[{self.mx_id}] Falha ao obter " - f"contexto da câmera: {e}" - ), - ) + O método apenas valida, aplica o limitador de captura e substitui o + frame pendente. Resize, JPEG e send TCP ficam na thread do streamer. + """ + if self._stop_event.is_set(): return - stream_on = bool( - camera.get("streaming", False) - ) + camera = self._camera_context() + self._apply_camera_overrides(camera) + stream_on = self._as_bool(camera.get("streaming"), False) if not stream_on: self._set_streaming_inativo() @@ -478,359 +991,589 @@ class CameraTcpStreamer: if not self._streaming_ativo: self._streaming_ativo = True - + self._stream_paused = False + self._stream_pause_reason = None + self._op_mode = min(1, self.MODE_MAX) + self._op_fps = self._mode_params(self._op_mode)["fps"] + self._next_enqueue_mono = 0.0 self._publicar_estado( - stream_active=True + force=True, + stream_active=True, + stream_paused=False, ) - # --------------------------------------------------------- - # MODO ADAPTATIVO - # --------------------------------------------------------- - - pressure = self._maybe_update_mode() - - params = self._mode_params( - self._op_mode - ) - - self._op_fps = float( - params["fps"] - ) - - # --------------------------------------------------------- - # LIMITADOR DE FPS - # --------------------------------------------------------- - - agora_mono = time.monotonic() - min_dt = 1.0 / max( - 0.1, - self._op_fps, - ) - - if ( - self._last_frame_sent_mono > 0.0 - and ( - agora_mono - - self._last_frame_sent_mono - ) < min_dt - ): - return - - # --------------------------------------------------------- - # CONEXÃO - # --------------------------------------------------------- - - with self._sock_lock: - conectado = ( - self._sock_conectado - and self._sock is not None - ) - - if not conectado: - self._sock_tentar_conectar() - - with self._sock_lock: - conectado = ( - self._sock_conectado - and self._sock is not None - ) - - if not conectado: - return - - # --------------------------------------------------------- - # VALIDAÇÃO DO FRAME - # --------------------------------------------------------- - - frame_valido = ( - frame_bgr is not None - and hasattr(frame_bgr, "shape") - and hasattr(frame_bgr, "size") - and frame_bgr.size > 0 - and len(frame_bgr.shape) >= 2 - ) - - if not frame_valido: + if not self._frame_is_valid(frame_bgr): + with self._metrics_lock: + self._frames_dropped_invalid += 1 self._publicar_estado( stream_frame_valido=False, - stream_last_error=( - "Frame ausente ou inválido." - ), + stream_last_error="Frame ausente ou inválido.", ) return - # --------------------------------------------------------- - # REDIMENSIONAMENTO - # --------------------------------------------------------- + policy = self._refresh_policy_if_needed() + self._maybe_update_mode(policy) + + if policy.get("pause"): + with self._metrics_lock: + self._frames_dropped_policy += 1 + self._apply_policy_pause(policy) + self._publish_metrics(policy) + return + + now = time.monotonic() + fps = max(0.1, float(self._op_fps)) + if now < self._next_enqueue_mono: + return + self._next_enqueue_mono = now + (1.0 / fps) try: - frame_redimensionado = resize_frame( + frame_to_queue = frame_bgr.copy() if self._copiar_frame else frame_bgr + except Exception as exc: + with self._metrics_lock: + self._frames_dropped_invalid += 1 + self._log_throttled( + "frame_copy_error", + f"[{self.mx_id}] Falha ao copiar frame: {exc}", + ) + return + + with self._metrics_lock: + self._frames_received += 1 + + with self._frame_condition: + if self._pending_frame is not None: + with self._metrics_lock: + self._frames_dropped_pending += 1 + + self._pending_frame = frame_to_queue + self._pending_frame_seq += 1 + self._frame_condition.notify() + + def _set_streaming_inativo(self): + if not self._streaming_ativo: + return + + self._streaming_ativo = False + self._stream_paused = False + self._stream_pause_reason = None + + with self._frame_condition: + self._pending_frame = None + self._frame_condition.notify_all() + + self._fechar_socket("stream_desativado") + + with self._metrics_lock: + self._stream_kbps_ema = 0.0 + self._send_ms_ema = 0.0 + self._local_backpressure_pct = 0.0 + self._last_frame_sent_mono = 0.0 + self._last_frame_sent_wall = 0.0 + + self._budget_initialized = False + self._budget_tokens_bytes = 0.0 + self._next_enqueue_mono = 0.0 + + self._publicar_estado( + force=True, + stream_active=False, + stream_connected=False, + stream_paused=False, + stream_pause_reason=None, + stream_kbps=0.0, + stream_fps=0.0, + ) + + def _apply_policy_pause(self, policy: Dict[str, Any]): + reason = policy.get("pause_reason") or "politica_de_rede" + transitioned = ( + not self._stream_paused + or self._stream_pause_reason != reason + ) + + self._stream_paused = True + self._stream_pause_reason = reason + self._fechar_socket(reason) + + with self._frame_condition: + self._pending_frame = None + + if transitioned: + self._publicar_estado( + force=True, + stream_active=True, + stream_connected=False, + stream_paused=True, + stream_pause_reason=reason, + stream_policy=policy.get("level"), + stream_kbps=0.0, + stream_fps=0.0, + ) + + def _clear_policy_pause(self): + if not self._stream_paused: + return + + self._stream_paused = False + self._stream_pause_reason = None + self._next_connect_mono = 0.0 + self._publicar_estado( + force=True, + stream_active=True, + stream_paused=False, + stream_pause_reason=None, + ) + + # ====================================================================== + # THREAD DE ENVIO + # ====================================================================== + + def _sender_loop(self): + while not self._stop_event.is_set(): + frame = None + frame_seq = 0 + + with self._frame_condition: + if self._pending_frame is None: + self._frame_condition.wait(timeout=0.25) + + if self._pending_frame is not None: + frame = self._pending_frame + frame_seq = self._pending_frame_seq + self._pending_frame = None + + if self._stop_event.is_set(): + break + + if not self._streaming_ativo: + continue + + policy = self._refresh_policy_if_needed() + self._maybe_update_mode(policy) + + if policy.get("pause"): + self._apply_policy_pause(policy) + self._publish_metrics(policy) + continue + + self._clear_policy_pause() + + if frame is None: + self._publish_metrics(policy) + continue + + self._last_processed_frame_seq = frame_seq + self._process_and_send(frame, policy) + + self._fechar_socket("encerramento") + + def _process_and_send(self, frame_bgr, policy: Dict[str, Any]): + params = self._mode_params(self._op_mode) + + try: + resize_start = time.monotonic() + resized = resize_frame( frame_bgr, max_width=int(params["w"]), max_height=int(params["h"]), ) - - except Exception as e: - self._publicar_estado( - stream_frame_valido=False, - stream_last_error=( - f"Falha ao redimensionar frame: {e}" - ), - ) - + _ = resize_start # Mantido para facilitar instrumentação futura. + except Exception as exc: + with self._metrics_lock: + self._frames_dropped_invalid += 1 self._log_throttled( "resize_error", - ( - f"[{self.mx_id}] Falha ao " - f"redimensionar frame: {e}" - ), + f"[{self.mx_id}] Falha ao redimensionar frame: {exc}", ) - return - - frame_redimensionado_valido = ( - frame_redimensionado is not None - and hasattr( - frame_redimensionado, - "shape", - ) - and hasattr( - frame_redimensionado, - "size", - ) - and frame_redimensionado.size > 0 - and len( - frame_redimensionado.shape - ) >= 2 - ) - - if not frame_redimensionado_valido: self._publicar_estado( stream_frame_valido=False, - stream_last_error=( - "Frame inválido após redimensionamento." - ), + stream_last_error=f"Falha ao redimensionar frame: {exc}", ) return - stream_h_real, stream_w_real = ( - frame_redimensionado.shape[:2] - ) + if not self._frame_is_valid(resized): + with self._metrics_lock: + self._frames_dropped_invalid += 1 + self._publicar_estado( + stream_frame_valido=False, + stream_last_error="Frame inválido após redimensionamento.", + ) + return - # --------------------------------------------------------- - # JPEG - # --------------------------------------------------------- - - encode_inicio = time.monotonic() - encode_erro = None + height, width = resized.shape[:2] + encode_start = time.monotonic() try: - encode_param = [ - int(cv2.IMWRITE_JPEG_QUALITY), - int(params["q"]), - ] - - ok, buffer_jpeg = cv2.imencode( + ok, jpeg_buffer = cv2.imencode( ".jpg", - frame_redimensionado, - encode_param, + resized, + [int(cv2.IMWRITE_JPEG_QUALITY), int(params["q"])], ) - - except Exception as e: + except Exception as exc: ok = False - buffer_jpeg = None - encode_erro = e + jpeg_buffer = None + encode_error = str(exc) + else: + encode_error = "cv2.imencode retornou falha." - encode_ms = ( - time.monotonic() - encode_inicio - ) * 1000.0 - - if not ok or buffer_jpeg is None: - erro_txt = ( - str(encode_erro) - if encode_erro is not None - else "cv2.imencode retornou falha." - ) - - self._publicar_estado( - stream_frame_valido=False, - stream_last_error=( - f"Falha ao codificar JPEG: {erro_txt}" - ), - stream_encode_ms=round( - encode_ms, - 1, - ), + encode_ms = (time.monotonic() - encode_start) * 1000.0 + with self._metrics_lock: + self._encode_ms_ema = self._ema( + self._encode_ms_ema, + encode_ms, + 0.20, ) + if not ok or jpeg_buffer is None: + with self._metrics_lock: + self._frames_dropped_encode += 1 self._log_throttled( "encode_error", - ( - f"[{self.mx_id}] Falha ao " - f"codificar JPEG: {erro_txt}" - ), + f"[{self.mx_id}] Falha ao codificar JPEG: {encode_error}", + ) + self._publicar_estado( + stream_frame_valido=False, + stream_encode_ms=round(encode_ms, 1), + stream_last_error=f"Falha ao codificar JPEG: {encode_error}", ) return - data = buffer_jpeg.tobytes() - size = len(data) + data = jpeg_buffer.tobytes() + jpeg_size = len(data) + wire_size = jpeg_size + 4 - # Cabeçalho de quatro bytes em big-endian. - header = struct.pack( - "!I", - size, - ) + if not self._consume_budget(wire_size): + with self._metrics_lock: + self._frames_dropped_budget += 1 + self._publish_metrics( + policy, + frame_info={ + "w": width, + "h": height, + "q": params["q"], + "jpeg_bytes": jpeg_size, + "encode_ms": encode_ms, + }, + ) + return - # --------------------------------------------------------- - # ENVIO TCP - # --------------------------------------------------------- + if not self._sock_tentar_conectar(): + self._publish_metrics(policy) + return - send_inicio = time.monotonic() + sock, connected = self._socket_snapshot() + if not connected or sock is None: + return + + header = struct.pack("!I", jpeg_size) + send_start = time.monotonic() try: - with self._sock_lock: - if ( - not self._sock_conectado - or self._sock is None - ): - return - - self._sock.sendall( - header + data - ) - - except ( - BrokenPipeError, - ConnectionResetError, - socket.timeout, - OSError, - ) as e: - send_ms = ( - time.monotonic() - - send_inicio - ) * 1000.0 - - self._fechar_socket() - self._stream_kbps_ema = 0.0 - - # O frame era válido. O que falhou foi o transporte. - self._publicar_estado( - stream_connected=False, - stream_kbps=0.0, - stream_fps=0.0, - stream_frame_valido=True, - stream_send_ms=round( - send_ms, - 1, - ), - stream_last_error=str(e), - ) - - self._log_throttled( - "send_error", - ( - f"[{self.mx_id}] Falha ao " - f"enviar frame TCP: {e}" - ), - ) + # Duas chamadas evitam copiar header + JPEG para outro buffer grande. + # Há apenas uma thread de envio por socket, portanto não há mistura. + sock.sendall(header) + sock.sendall(data) + except (BrokenPipeError, ConnectionResetError, socket.timeout, OSError) as exc: + send_ms = (time.monotonic() - send_start) * 1000.0 + self._handle_send_error(exc, send_ms, policy) return - # --------------------------------------------------------- - # ENVIO CONCLUÍDO - # --------------------------------------------------------- + sent_mono = time.monotonic() + send_ms = (sent_mono - send_start) * 1000.0 + self._handle_send_success( + sent_mono, + send_ms, + encode_ms, + wire_size, + jpeg_size, + width, + height, + params, + policy, + ) - enviado_mono = time.monotonic() + def _handle_send_error(self, exc: Exception, send_ms: float, policy: Dict[str, Any]): + now = time.monotonic() + self._fechar_socket("falha_de_envio") + self._schedule_reconnect_failure() + self._last_transport_error = str(exc) + self._emergency_hold_until_mono = max( + self._emergency_hold_until_mono, + now + 10.0, + ) + self._op_mode = self.MODE_EMERGENCY + self._op_fps = self._mode_params(self._op_mode)["fps"] - send_ms = ( - enviado_mono - send_inicio - ) * 1000.0 - - if self._last_frame_sent_mono > 0.0: - dt_real = max( - 0.001, - enviado_mono - - self._last_frame_sent_mono, - ) - else: - # Estimativa inicial para o primeiro frame. - dt_real = 1.0 / max( - 0.1, - self._op_fps, - ) - - self._last_frame_sent_mono = enviado_mono - - fps_real = 1.0 / dt_real - - kbps_inst = ( - size * 8.0 - ) / dt_real / 1000.0 - - # EMA para evitar métrica tremendo a cada JPEG. - if self._stream_kbps_ema <= 0.0: - self._stream_kbps_ema = kbps_inst - else: - self._stream_kbps_ema = ( - self._stream_kbps_ema * 0.8 - + kbps_inst * 0.2 - ) - - # --------------------------------------------------------- - # PUBLICAÇÃO DAS MÉTRICAS - # --------------------------------------------------------- + with self._metrics_lock: + self._last_send_error_mono = now + self._consecutive_send_errors += 1 + self._consecutive_send_successes = 0 + self._send_ms_ema = self._ema(self._send_ms_ema, send_ms, 0.35) + self._local_backpressure_pct = 100.0 + self._stream_kbps_ema *= 0.65 self._publicar_estado( - stream_active=True, - stream_connected=True, - stream_frame_valido=True, + force=True, + stream_connected=False, + stream_send_ms=round(send_ms, 1), + stream_last_error=str(exc), + stream_mode=int(self._op_mode), + stream_policy=policy.get("level"), + ) + self._log_throttled( + "send_error", + f"[{self.mx_id}] Falha ao enviar frame TCP: {exc}", + ) - # Timestamp de parede somente após envio confirmado. - stream_last_frame=time.time(), + def _handle_send_success( + self, + sent_mono: float, + send_ms: float, + encode_ms: float, + wire_size: int, + jpeg_size: int, + width: int, + height: int, + params: Dict[str, Any], + policy: Dict[str, Any], + ): + with self._metrics_lock: + if self._last_frame_sent_mono > 0.0: + dt_real = max(0.001, sent_mono - self._last_frame_sent_mono) + else: + dt_real = 1.0 / max(0.1, self._op_fps) - stream_kbps=round( + fps_real = 1.0 / dt_real + kbps_inst = (wire_size * 8.0) / dt_real / 1000.0 + + self._last_frame_sent_mono = sent_mono + self._last_frame_sent_wall = time.time() + self._frames_sent += 1 + self._consecutive_send_successes += 1 + self._consecutive_send_errors = 0 + + self._stream_kbps_ema = self._ema( self._stream_kbps_ema, - 1, - ), - stream_fps=round( - fps_real, - 1, - ), - stream_target_fps=round( - self._op_fps, - 1, - ), - - # Dimensões realmente transmitidas. - stream_w=int( - stream_w_real - ), - stream_h=int( - stream_h_real - ), - - stream_mode=int( - self._op_mode - ), - stream_quality=int( - params["q"] - ), - - stream_pressure_pct=( - round(pressure, 1) - if pressure is not None - else None - ), - - stream_jpeg_bytes=int( - size - ), - stream_encode_ms=round( - encode_ms, - 1, - ), - stream_send_ms=round( + kbps_inst, + 0.20, + ) + self._send_ms_ema = self._ema( + self._send_ms_ema, send_ms, + 0.25, + ) + self._encode_ms_ema = self._ema( + self._encode_ms_ema, + encode_ms, + 0.20, + ) + + instant_backpressure = self._calculate_local_backpressure( + send_ms, + self._op_fps, + ) + # Sobe rápido e cai devagar. + alpha = 0.45 if instant_backpressure > self._local_backpressure_pct else 0.12 + self._local_backpressure_pct = self._ema( + self._local_backpressure_pct, + instant_backpressure, + alpha, + ) + + snapshot = { + "fps_real": fps_real, + "stream_kbps": self._stream_kbps_ema, + "send_ms_ema": self._send_ms_ema, + "encode_ms_ema": self._encode_ms_ema, + "local_backpressure": self._local_backpressure_pct, + } + + self._reset_reconnect_backoff() + self._last_transport_error = None + self._publish_metrics( + policy, + frame_info={ + "w": width, + "h": height, + "q": params["q"], + "jpeg_bytes": jpeg_size, + "encode_ms": encode_ms, + "send_ms": send_ms, + **snapshot, + }, + ) + + # ====================================================================== + # MÉTRICAS + # ====================================================================== + + def _publish_metrics( + self, + policy: Optional[Dict[str, Any]] = None, + frame_info: Optional[Dict[str, Any]] = None, + force=False, + ): + now = time.monotonic() + if not force and (now - self._last_publish_mono) < self._publish_interval_s: + return + + if policy is None: + policy = self._refresh_policy_if_needed() + frame_info = frame_info or {} + + _, connected = self._socket_snapshot() + with self._metrics_lock: + last_frame_age = ( + now - self._last_frame_sent_mono + if self._last_frame_sent_mono > 0.0 + else -1.0 + ) + metrics = { + "stream_kbps": round(self._stream_kbps_ema, 1), + "stream_send_ms_ema": round(self._send_ms_ema, 1), + "stream_encode_ms_ema": round(self._encode_ms_ema, 1), + "stream_local_backpressure_pct": round( + self._local_backpressure_pct, + 1, + ), + "stream_frames_received": int(self._frames_received), + "stream_frames_sent": int(self._frames_sent), + "stream_drop_pending": int(self._frames_dropped_pending), + "stream_drop_budget": int(self._frames_dropped_budget), + "stream_drop_policy": int(self._frames_dropped_policy), + "stream_drop_invalid": int(self._frames_dropped_invalid), + "stream_drop_encode": int(self._frames_dropped_encode), + "stream_consecutive_send_errors": int( + self._consecutive_send_errors + ), + "stream_consecutive_send_successes": int( + self._consecutive_send_successes + ), + "stream_last_frame_age_s": round(last_frame_age, 2), + "stream_last_frame": ( + self._last_frame_sent_wall + if self._last_frame_sent_wall > 0.0 + else None + ), + } + + with self._frame_condition: + queue_depth = 1 if self._pending_frame is not None else 0 + + fields = { + "stream_active": bool(self._streaming_ativo), + "stream_connected": bool(connected), + "stream_paused": bool(self._stream_paused), + "stream_pause_reason": self._stream_pause_reason, + "stream_frame_valido": True, + "stream_target_fps": round(self._op_fps, 1), + "stream_fps": round( + self._safe_float(frame_info.get("fps_real"), 0.0), 1, ), + "stream_mode": int(self._op_mode), + "stream_quality": int( + frame_info.get( + "q", + self._mode_params(self._op_mode)["q"], + ) + ), + "stream_w": int(frame_info.get("w", 0)), + "stream_h": int(frame_info.get("h", 0)), + "stream_jpeg_bytes": int(frame_info.get("jpeg_bytes", 0)), + "stream_encode_ms": round( + self._safe_float(frame_info.get("encode_ms"), 0.0), + 1, + ), + "stream_send_ms": round( + self._safe_float(frame_info.get("send_ms"), 0.0), + 1, + ), + "stream_pressure_pct": round( + self._safe_float(policy.get("pressure_pct"), 45.0), + 1, + ), + "stream_policy": policy.get("level"), + "stream_budget_kbps": round(self._budget_kbps, 1), + "stream_budget_target_kbps": round( + self._budget_target_kbps, + 1, + ), + "stream_max_kbps": round(self._max_stream_kbps, 1), + "stream_safe_link_fraction": round( + self._safe_link_fraction, + 3, + ), + "stream_priority": round(self._stream_priority, 2), + "stream_queue_depth": queue_depth, + "stream_reconnect_backoff_s": round( + self._reconnect_backoff_s, + 2, + ), + "stream_ipb_link_state": policy.get("link_state"), + "stream_ipb_network_health": policy.get("network_health"), + "stream_ipb_confidence_pct": policy.get( + "assessment_confidence_pct" + ), + "stream_ipb_mqtt_connected": policy.get("mqtt_connected"), + "stream_ipb_heartbeat_ok": policy.get("heartbeat_ok"), + "stream_ipb_heartbeat_age_s": policy.get("heartbeat_age_s"), + "stream_ipb_bandwidth_corroborated": policy.get( + "bandwidth_corroborated" + ), + **metrics, + "stream_last_error": ( + None + if connected + else (self._stream_pause_reason or self._last_transport_error) + ), + } - stream_last_error=None, - ) \ No newline at end of file + self._publicar_estado(force=force, **fields) + + # ====================================================================== + # ENCERRAMENTO + # ====================================================================== + + def fechar(self): + """Encerramento definitivo da instância.""" + if self._stop_event.is_set(): + return + + self._streaming_ativo = False + self._stop_event.set() + + with self._frame_condition: + self._pending_frame = None + self._frame_condition.notify_all() + + self._fechar_socket("encerramento") + + if ( + self._worker is not None + and self._worker.is_alive() + and threading.current_thread() is not self._worker + ): + self._worker.join(timeout=1.0) + + with self._metrics_lock: + self._stream_kbps_ema = 0.0 + self._last_frame_sent_mono = 0.0 + self._last_frame_sent_wall = 0.0 + + self._publicar_estado( + force=True, + stream_active=False, + stream_connected=False, + stream_paused=False, + stream_pause_reason=None, + stream_kbps=0.0, + stream_fps=0.0, + ) + + def __del__(self): + try: + self.fechar() + except Exception: + pass diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py index 354e47d62..ea81db374 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py @@ -1,744 +1,1422 @@ import math +import os import re -import time -import threading -import statistics import socket -import numpy as np -import psutil +import statistics import subprocess -from concurrent.futures import ThreadPoolExecutor +import threading +import time from collections import deque +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + import paho.mqtt.client as mqtt +import psutil + +from health_worker.modulos.base import ModuloDiagnosticoBase from shared.contexto_global_redis import ContextoGlobalRedis from shared.enums import StatusModulo, T_Code -from health_worker.modulos.base import ModuloDiagnosticoBase + class ModuloIPBribge(ModuloDiagnosticoBase): - def __init__(self, window_fast: int = 8, window_slow: int = 24): + """ + Diagnóstico da ponte IP / Wi-Fi HaLow. + + Princípios desta versão: + 1. MQTT e heartbeat são serviços sobre o enlace, não o enlace inteiro. + 2. Uma falha isolada de ICMP não derruba a saúde. + 3. Persistência é medida por tempo monotônico e por novas amostras, + nunca pela frequência do loop do Health Worker. + 4. Falha crítica exige persistência ou confirmação por mais de uma evidência. + 5. Banda estimada é indício de pressão, não prova isolada de falha. + 6. O payload antigo é preservado na medida do possível para não quebrar + consumidores existentes. + """ + + LINK_OK = "OK" + LINK_DEGRADED = "DEGRADED" + LINK_CRITICAL = "CRITICAL" + + def __init__(self, window_fast: int = 12, window_slow: int = 60): self.t_code = T_Code.Ipb - self.nome = "IP_Brigde" + self.nome = "IP_Brigde" # preservado por compatibilidade self.timeout = 5 + # Evita iniciar duas atualizações de saúde simultâneas. + self._run_lock = threading.Lock() self._thread_saude = None - self._running = False - self.nic_name = self.descobrir_nic_para_base() - self.window_fast = window_fast - self.window_slow = window_slow + # Configuração da rota/NIC. + self.nic_name: Optional[str] = None + self._last_base_ip: Optional[str] = None + self._route_refresh_interval_s = 3.0 + self._last_route_refresh_mono = 0.0 - # FAST (reação) - self.rtts_f = deque(maxlen=window_fast) - self.timeouts_f = deque(maxlen=window_fast) - self.loses_f = deque(maxlen=window_fast) - self.nic_error_rates_f = deque(maxlen=window_fast) - self.bw_rx_mbps_f = deque(maxlen=window_fast) - self.bw_tx_mbps_f = deque(maxlen=window_fast) + self.window_fast = max(6, int(window_fast)) + self.window_slow = max(self.window_fast + 6, int(window_slow)) - # SLOW (estabilidade) - self.rtts_s = deque(maxlen=window_slow) - self.timeouts_s = deque(maxlen=window_slow) - self.loses_s = deque(maxlen=window_slow) - self.nic_error_rates_s = deque(maxlen=window_slow) - self.bw_rx_mbps_s = deque(maxlen=window_slow) - self.bw_tx_mbps_s = deque(maxlen=window_slow) - - self.last_nic_counters = None - self.last_bw_counters = None - self.last_bw_ts = None - - self.last_heartbeat_ts = time.time() - - self.rover_id = None - - self._mqtt = None - self._mqtt_lock = threading.Lock() - self._mqtt_conectado = False - self._mqtt_connecting = False - self._heartbeat_ok = False - self._mqtt_last_attempt_ts = 0.0 - self._mqtt_backoff_s = 1.0 - self._mqtt_backoff_max_s = 30.0 - self._mqtt_connected_ts = 0.0 - self.last_heartbeat_ts = 0.0 - self._heartbeat_timeout_s = 7.0 - self._heartbeat_grace_after_connect_s = 10.0 - self.sub = False - self._mqtt_future = None - self._mqtt_executor = ThreadPoolExecutor(max_workers=1) - self._mqtt_client_seq = 0 - self._mqtt_debug = False + # ------------------------------------------------------------------ + # SONDAGEM ICMP + # ------------------------------------------------------------------ + # Um ping por amostra. A estatística vem da janela, não de uma única + # chamada com vários pacotes. Assim o loop continua leve e assíncrono. + self.ping_ok_f = deque(maxlen=self.window_fast) + self.ping_ok_s = deque(maxlen=self.window_slow) + self.rtts_f = deque(maxlen=self.window_fast) + self.rtts_s = deque(maxlen=self.window_slow) self._ping_executor = ThreadPoolExecutor(max_workers=1) self._ping_future = None self._ping_lock = threading.Lock() - - # snapshot do último ping concluído - self._last_ping_ts = 0.0 + self._ping_timeout_ms = 1000 + self._ping_count = 1 + self._ping_min_interval_s = 1.0 + self._last_ping_started_mono = 0.0 + self._last_ping_completed_mono = 0.0 self._last_ping_ok = False - self._last_ping_rtt_ms = None + self._last_ping_rtt_ms: Optional[float] = None self._last_ping_loss_pct = 100.0 + self._ping_sample_seq = 0 + self._last_applied_ping_seq = 0 + self._last_state_ping_seq = 0 + self._last_ping_success_mono = 0.0 + self._consecutive_ping_failures = 0 + self._consecutive_ping_successes = 0 - # parâmetros do ping - self._ping_timeout_ms = 500 # recomendado: 300–500 - self._ping_count = 1 # recomendado: 1 (janela já suaviza) - self._ping_min_interval = 0.5 # não precisa pingar a cada 100ms - self._hb_last_rx_monotonic = 0.0 - self._last_ping_sample_applied_ts = 0.0 + # ------------------------------------------------------------------ + # NIC / BANDA + # ------------------------------------------------------------------ + self.nic_error_rates_f = deque(maxlen=self.window_fast) + self.nic_error_rates_s = deque(maxlen=self.window_slow) + self.bw_rx_mbps_f = deque(maxlen=self.window_fast) + self.bw_tx_mbps_f = deque(maxlen=self.window_fast) + self.bw_rx_mbps_s = deque(maxlen=self.window_slow) + self.bw_tx_mbps_s = deque(maxlen=self.window_slow) + self._last_nic_counters = None + self._last_nic_sample_mono = 0.0 + self._last_nic_activity_mono = 0.0 + self._nic_sample_interval_s = 1.0 + + # Valores nominais. São estimativas para pressão de banda e não prova + # direta da capacidade instantânea do rádio. self._bw_nominal_max_mbps = 10.0 self._bw_nominal_safe_mbps = 6.0 self._bw_nominal_hard_mbps = 7.8 - self.bw_max_mbps = self._bw_nominal_max_mbps self.bw_safe_mbps = self._bw_nominal_safe_mbps self.bw_hard_mbps = self._bw_nominal_hard_mbps - self.link_state = "OK" # OK | DEGRADED | CRITICAL + # ------------------------------------------------------------------ + # SOCKETS ATIVOS PARA A BASE + # ------------------------------------------------------------------ + self._socket_probe_interval_s = 1.0 + self._last_socket_probe_mono = 0.0 + self._tcp_established_total = 0 + self._tcp_established_non_mqtt = 0 + self._udp_remote_total = 0 + self._socket_probe_supported = True + self._last_remote_socket_seen_mono = 0.0 + + # ------------------------------------------------------------------ + # MQTT / HEARTBEAT + # ------------------------------------------------------------------ + self.rover_id = None + self._mqtt = None + self._mqtt_base_ip: Optional[str] = None + self._mqtt_lock = threading.RLock() + self._mqtt_conectado = False + self._mqtt_connecting = False + self._mqtt_connected_mono = 0.0 + self._mqtt_created_mono = 0.0 + self._mqtt_disconnected_mono = 0.0 + self._mqtt_client_seq = 0 + self._mqtt_debug = False + self.sub = False + + self._heartbeat_ok = False + self._hb_last_rx_monotonic = 0.0 + self._hb_last_rx_wall = 0.0 + self._heartbeat_grace_after_connect_s = 8.0 + self._heartbeat_warn_s = 4.0 + self._heartbeat_degraded_s = 7.0 + self._heartbeat_critical_s = 15.0 + + # ------------------------------------------------------------------ + # ESTADO DO ENLACE + # ------------------------------------------------------------------ + self.link_state = self.LINK_OK + self._degraded_since_mono = 0.0 + self._critical_since_mono = 0.0 + self._healthy_since_mono = 0.0 + self._down_since_mono = 0.0 + + # Histerese por tempo real. + self._enter_degraded_after_s = 4.0 + self._enter_critical_after_s = 6.0 + self._recover_after_s = 8.0 + self._confirm_down_after_s = 6.0 + + # Contadores preservados no payload, mas agora atualizados apenas + # quando chega uma nova amostra de ping. self._cycles_degraded = 0 self._cycles_critical = 0 self._cycles_recovered = 0 + + # Marcadores de distância. self._distancia_inicio_degradado = None self._distancia_inicio_critico = None self._max_distancia_ok = 0.0 self._max_distancia_desde_degradado = 0.0 + self._refresh_route(force=True) - - def _start_ping_async_if_needed(self): - """Dispara ping em background se não houver um em andamento.""" - now = time.time() - - # respeita intervalo mínimo (evita spam de ping) - if (now - self._last_ping_ts) < self._ping_min_interval: - return - - # já tem um ping rodando? - if self._ping_future is not None and not self._ping_future.done(): - return - - base_ip = self.get_base_ip() - if not base_ip: - # sem rota ainda -> considera "sem dados" (não bloqueia) - with self._ping_lock: - self._last_ping_ts = now - self._last_ping_ok = False - self._last_ping_rtt_ms = None - self._last_ping_loss_pct = 100.0 - return - - # dispara o ping em background - self._ping_future = self._ping_executor.submit( - self._ping_once, base_ip, self._ping_count, self._ping_timeout_ms - ) - - def _consume_ping_result_if_ready(self): - """Se o ping terminou, atualiza o snapshot sem bloquear o loop.""" - if self._ping_future is None or not self._ping_future.done(): - return - - try: - # _ping_once deve retornar: (ok: bool, avg_rtt_ms: Optional[float], loss_pct: float) - ok, avg_rtt_ms, loss_pct = self._ping_future.result(timeout=0) - except Exception: - ok, avg_rtt_ms, loss_pct = False, None, 100.0 - - with self._ping_lock: - self._last_ping_ts = time.time() - self._last_ping_ok = ok - self._last_ping_rtt_ms = avg_rtt_ms - self._last_ping_loss_pct = loss_pct - - # limpa - self._ping_future = None - - def _get_ping_snapshot(self): - """Lê o último resultado do ping (snapshot thread-safe).""" - with self._ping_lock: - return (self._last_ping_ok, self._last_ping_rtt_ms, self._last_ping_loss_pct, self._last_ping_ts) - - - + # ====================================================================== + # CICLO DE EXECUÇÃO + # ====================================================================== def atualizar_saude(self): - if self._running: + if not self._run_lock.acquire(blocking=False): return - self._running = True - self._thread_saude = threading.Thread(target=self._job_saude, daemon=True) + + self._thread_saude = threading.Thread( + target=self._job_saude, + daemon=True, + name="health-ip-bridge", + ) self._thread_saude.start() def _job_saude(self): try: self.atualizar_saude_interno() - except Exception as e: - print(f"Erro ao atualizar saude: {e}") + except Exception as exc: + print(f"Erro ao atualizar saúde da ponte IP: {exc}") finally: - self._running = False + self._run_lock.release() + def encerrar(self): + """Libera recursos se o módulo for encerrado explicitamente.""" + client = None + with self._mqtt_lock: + client = self._mqtt + self._mqtt = None + self._mqtt_conectado = False + self._mqtt_connecting = False + self.sub = False + + self._mqtt_close_client(client) + + try: + self._ping_executor.shutdown(wait=False, cancel_futures=True) + except TypeError: + self._ping_executor.shutdown(wait=False) + except Exception: + pass + + # ====================================================================== + # ROTA / NIC + # ====================================================================== + + def get_base_ip(self): + equipamento = ContextoGlobalRedis.get_equipamento() or {} + base_ip = equipamento.get("base_ip") + if base_ip is None: + return None + base_ip = str(base_ip).strip() + return base_ip or None + + def descobrir_nic_para_base(self) -> Optional[str]: + base_ip = self.get_base_ip() + if not base_ip: + return None + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + # Não envia dados. Apenas força o SO a escolher a rota local. + sock.connect((base_ip, 80)) + local_ip = sock.getsockname()[0] + except Exception as exc: + self._mqtt_mostra_log(f"falha ao descobrir rota local: {exc}") + return None + + try: + for nic_name, addr_list in psutil.net_if_addrs().items(): + for addr in addr_list: + if addr.family == socket.AF_INET and addr.address == local_ip: + return nic_name + except Exception: + return None + + return None + + def _refresh_route(self, force: bool = False): + now = time.monotonic() + base_ip = self.get_base_ip() + base_changed = base_ip != self._last_base_ip + + if not force and not base_changed: + if (now - self._last_route_refresh_mono) < self._route_refresh_interval_s: + return + + old_nic = self.nic_name + self._last_route_refresh_mono = now + self._last_base_ip = base_ip + + discovered_nic = self.descobrir_nic_para_base() if base_ip else None + # Uma falha transitória na descoberta da rota não deve apagar uma NIC + # que continua fisicamente ativa. + if discovered_nic is None and old_nic and base_ip: + try: + old_stats = psutil.net_if_stats().get(old_nic) + if old_stats and old_stats.isup: + discovered_nic = old_nic + except Exception: + pass + + self.nic_name = discovered_nic + + if base_changed or self.nic_name != old_nic: + self._reset_measurements() + + if base_changed: + self._replace_mqtt_client_for_new_base(base_ip) + + def _nic_is_up(self) -> bool: + if not self.nic_name: + return False + try: + stats = psutil.net_if_stats().get(self.nic_name) + return bool(stats and stats.isup) + except Exception: + return False + + def _reset_measurements(self): + for window in ( + self.ping_ok_f, + self.ping_ok_s, + self.rtts_f, + self.rtts_s, + self.nic_error_rates_f, + self.nic_error_rates_s, + self.bw_rx_mbps_f, + self.bw_tx_mbps_f, + self.bw_rx_mbps_s, + self.bw_tx_mbps_s, + ): + window.clear() + + self._last_nic_counters = None + self._last_nic_sample_mono = 0.0 + self._last_nic_activity_mono = 0.0 + self._last_remote_socket_seen_mono = 0.0 + self._consecutive_ping_failures = 0 + self._consecutive_ping_successes = 0 + self._last_ping_success_mono = 0.0 + self._cycles_degraded = 0 + self._cycles_critical = 0 + self._cycles_recovered = 0 + + # ====================================================================== + # MQTT / HEARTBEAT + # ====================================================================== - # MQTT HEARTBEAT def _mqtt_mostra_log(self, msg): if self._mqtt_debug: print(f"[HEARTBEAT] {msg}") - def _check_mqtt_heartbeat(self): - now = time.time() - client_to_close = None + @staticmethod + def _mqtt_reason_code_to_int(reason_code) -> int: + try: + return int(reason_code) + except Exception: + try: + return int(reason_code.value) + except Exception: + return -1 + + def _new_mqtt_client(self): + # Compatível com Paho 1.x e 2.x. + try: + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api is not None: + client = mqtt.Client(callback_api_version=callback_api.VERSION1) + else: + client = mqtt.Client() + except Exception: + client = mqtt.Client() with self._mqtt_lock: - if not self._mqtt_conectado: - return + self._mqtt_client_seq += 1 + cid = self._mqtt_client_seq - connected_ts = self._mqtt_connected_ts - last_hb = self.last_heartbeat_ts + client._agro_client_local_id = cid + client.on_connect = self._on_connect + client.on_message = self._on_message + client.on_disconnect = self._on_disconnect - # janela de graça após reconnect - if (now - connected_ts) < self._heartbeat_grace_after_connect_s: - return - - atraso_s = now - last_hb - - if atraso_s >= self._heartbeat_timeout_s: - self._mqtt_mostra_log(f"atraso de {atraso_s:.1f}s detectado") - - with self._mqtt_lock: - self._heartbeat_ok = False - client_to_close = self._mqtt_detach_client_locked("heartbeat atrasado") - - self._mqtt_close_client(client_to_close) - - def _is_active_client_locked(self, client): - return self._mqtt is client - - def _mqtt_detach_client_locked(self, reason=""): - client = self._mqtt - self._mqtt = None - self._mqtt_conectado = False - self._mqtt_connecting = False - self._heartbeat_ok = False - self.sub = False - self._mqtt_last_attempt_ts = time.time() - self._mqtt_backoff_s = max(self._mqtt_backoff_s, 5.0) - - if reason: - self._mqtt_mostra_log(f"Resetando client MQTT: {reason}") + try: + client.reconnect_delay_set(min_delay=1, max_delay=30) + except Exception: + pass return client - - def _mqtt_close_client(self, client): - if client is not None: - cid = getattr(client, "_agro_client_local_id", "?") - self._mqtt_mostra_log(f"[client={cid}] fechando client antigo") - try: - client.loop_stop() - except Exception: - pass - try: - client.disconnect() - except Exception: - pass + def _replace_mqtt_client_for_new_base(self, base_ip: Optional[str]): + old = None + with self._mqtt_lock: + if self._mqtt_base_ip == base_ip: + return - def _start_mqtt_heartbeat_async(self): - now = time.time() + old = self._mqtt + self._mqtt = None + self._mqtt_base_ip = None + self._mqtt_conectado = False + self._mqtt_connecting = False + self._heartbeat_ok = False + self.sub = False + self._hb_last_rx_monotonic = 0.0 + self._hb_last_rx_wall = 0.0 + + self._mqtt_close_client(old) + + def _ensure_mqtt_client(self): + base_ip = self.get_base_ip() + if not base_ip: + return + + now = time.monotonic() + stale_client = None with self._mqtt_lock: - if self._mqtt_conectado: - return + if self._mqtt is not None and self._mqtt_base_ip == base_ip: + if self._mqtt_conectado: + return - if self._mqtt_connecting: - return + # O Paho normalmente reconecta sozinho. Este watchdog só + # recria o cliente se ele permanecer sem qualquer recuperação + # por um período muito longo, evitando um zumbi silencioso. + reference = max( + self._mqtt_created_mono, + self._mqtt_disconnected_mono, + ) + if reference > 0 and (now - reference) < 60.0: + return - if self._mqtt_future is not None and not self._mqtt_future.done(): - return - - if (now - self._mqtt_last_attempt_ts) < self._mqtt_backoff_s: - return - - base_ip = self.get_base_ip() - if not base_ip: - self._mqtt_mostra_log("mqtt heartbeat sem ip base definido") - return - - self._mqtt_last_attempt_ts = now - self._mqtt_connecting = True - - self._mqtt_mostra_log("tentando conectar mqtt heartbeat") - self._mqtt_future = self._mqtt_executor.submit(self._mqtt_connect_worker, base_ip) - - def _mqtt_connect_worker(self, base_ip: str): - client = None - try: - client = mqtt.Client() - with self._mqtt_lock: - self._mqtt_client_seq += 1 - cid = self._mqtt_client_seq - - client._agro_client_local_id = cid - client.on_connect = self._on_connect - client.on_message = self._on_message - client.on_disconnect = self._on_disconnect - - client.connect(base_ip, 1883, 15) - client.loop_start() - - with self._mqtt_lock: - # só assume como ativo depois de iniciar sem exception - old = self._mqtt - self._mqtt = client - - if old is not None and old is not client: - try: - old.loop_stop() - except Exception: - pass - try: - old.disconnect() - except Exception: - pass - - self._mqtt_mostra_log(f"mqtt heartbeat conectado [client={cid}]") - - except Exception as e: - with self._mqtt_lock: + stale_client = self._mqtt + self._mqtt = None + self._mqtt_base_ip = None self._mqtt_connecting = False self._mqtt_conectado = False - self._heartbeat_ok = False self.sub = False - self._mqtt_last_attempt_ts = time.time() - self._mqtt_backoff_s = min(max(self._mqtt_backoff_s * 2.0, 5.0), self._mqtt_backoff_max_s) - self._mqtt_mostra_log(f"mqtt heartbeat erro ao conectar [client={getattr(client, '_agro_client_local_id', '?')}]: {e}") - - def _on_connect(self, client, userdata, flags, rc): + self._mqtt_close_client(stale_client) + + client = self._new_mqtt_client() + + with self._mqtt_lock: + # Registra como ativo ANTES do loop para eliminar a corrida em + # que on_connect chegava antes de self._mqtt receber o cliente. + if self._mqtt is not None: + return + self._mqtt = client + self._mqtt_base_ip = base_ip + self._mqtt_connecting = True + self._mqtt_created_mono = now + self._mqtt_disconnected_mono = 0.0 + + try: + client.connect_async(base_ip, 1883, keepalive=15) + client.loop_start() + self._mqtt_mostra_log( + f"cliente MQTT iniciado [client={client._agro_client_local_id}]" + ) + except Exception as exc: + with self._mqtt_lock: + if self._mqtt is client: + self._mqtt = None + self._mqtt_base_ip = None + self._mqtt_connecting = False + self._mqtt_conectado = False + self.sub = False + self._mqtt_close_client(client) + self._mqtt_mostra_log(f"erro ao iniciar MQTT: {exc}") + + def _mqtt_close_client(self, client): + if client is None: + return + + try: + client.loop_stop() + except Exception: + pass + try: + client.disconnect() + except Exception: + pass + + def _is_active_client_locked(self, client) -> bool: + return self._mqtt is client + + def _on_connect(self, client, userdata, flags, reason_code, *extra): + rc = self._mqtt_reason_code_to_int(reason_code) cid = getattr(client, "_agro_client_local_id", "?") - - self._mqtt_mostra_log(f"[client={cid}] on_connect rc={rc} | t={time.time():.3f}") + now = time.monotonic() with self._mqtt_lock: if not self._is_active_client_locked(client): - self._mqtt_mostra_log(f"[client={cid}] on_connect IGNORADO (client antigo)") + self._mqtt_mostra_log( + f"[client={cid}] on_connect ignorado, cliente antigo" + ) return self._mqtt_connecting = False if rc == 0: self._mqtt_conectado = True + self._mqtt_connected_mono = now + self._mqtt_disconnected_mono = 0.0 self._heartbeat_ok = False - self._mqtt_backoff_s = 1.0 - self._mqtt_connected_ts = time.time() - self.last_heartbeat_ts = self._mqtt_connected_ts - self.rover_id = ContextoGlobalRedis.get_equipamento().get("serial_number") + equipamento = ContextoGlobalRedis.get_equipamento() or {} + self.rover_id = equipamento.get("serial_number") topic = f"agrobot/v1/rover/{self.rover_id}/heartbeat" - result, mid = client.subscribe(topic) - self.sub = (result == 0) - self._mqtt_mostra_log(f"[client={cid}] Subscribado em: {topic} | result={result} mid={mid}") + try: + result, mid = client.subscribe(topic, qos=0) + self.sub = result == mqtt.MQTT_ERR_SUCCESS + self._mqtt_mostra_log( + f"[client={cid}] inscrito em {topic}; result={result}; mid={mid}" + ) + except Exception as exc: + self.sub = False + self._mqtt_mostra_log( + f"[client={cid}] falha ao assinar heartbeat: {exc}" + ) else: self._mqtt_conectado = False self._heartbeat_ok = False self.sub = False - self._mqtt_mostra_log(f"[client={cid}] Erro ao conectar MQTT: rc={rc}") + self._mqtt_mostra_log(f"[client={cid}] conexão recusada; rc={rc}") - def _on_disconnect(self, client, userdata, rc): + def _on_disconnect(self, client, userdata, reason_code, *extra): + rc = self._mqtt_reason_code_to_int(reason_code) cid = getattr(client, "_agro_client_local_id", "?") with self._mqtt_lock: if not self._is_active_client_locked(client): - self._mqtt_mostra_log(f"[client={cid}] on_disconnect IGNORADO (client antigo) rc={rc}") return + # Não removemos o cliente. O loop_start do Paho tenta reconectar + # automaticamente. Isso evita a criação de clientes em cascata. self._mqtt_conectado = False - self._mqtt_connecting = False + self._mqtt_connecting = True + self._mqtt_disconnected_mono = time.monotonic() self._heartbeat_ok = False self.sub = False - self._mqtt = None - # adiciona isso: - self._mqtt_last_attempt_ts = time.time() - self._mqtt_backoff_s = max(self._mqtt_backoff_s, 5.0) - - self._mqtt_mostra_log(f"[client={cid}] MQTT desconectado! rc={rc} | t={time.time():.3f}") + self._mqtt_mostra_log(f"[client={cid}] desconectado; rc={rc}") def _on_message(self, client, userdata, msg): - cid = getattr(client, "_agro_client_local_id", "?") - with self._mqtt_lock: if not self._is_active_client_locked(client): - self._mqtt_mostra_log(f"[client={cid}] on_message IGNORADO (client antigo)") return + rover_id = self.rover_id - if msg.topic == f"agrobot/v1/rover/{self.rover_id}/heartbeat": - now = time.time() - with self._mqtt_lock: - self.last_heartbeat_ts = now - self._hb_last_rx_monotonic = time.perf_counter() - self._heartbeat_ok = True + expected_topic = f"agrobot/v1/rover/{rover_id}/heartbeat" + if msg.topic != expected_topic: + return - self._mqtt_mostra_log(f"[client={cid}] heartbeat recebido | t={now:.3f}") + now_mono = time.monotonic() + now_wall = time.time() - - def _reset_janelas(self): - for d in ( - self.rtts_f, self.timeouts_f, self.loses_f, - self.nic_error_rates_f, self.bw_rx_mbps_f, self.bw_tx_mbps_f, - self.rtts_s, self.timeouts_s, self.loses_s, - self.nic_error_rates_s, self.bw_rx_mbps_s, self.bw_tx_mbps_s, - ): - d.clear() + with self._mqtt_lock: + self._hb_last_rx_monotonic = now_mono + self._hb_last_rx_wall = now_wall + self._heartbeat_ok = True - self.last_nic_counters = None - self.last_bw_counters = None - self.last_bw_ts = None + def _heartbeat_snapshot(self, now_mono: float): + with self._mqtt_lock: + mqtt_connected = self._mqtt_conectado + mqtt_connecting = self._mqtt_connecting + subscribed = self.sub + connected_mono = self._mqtt_connected_mono + hb_last = self._hb_last_rx_monotonic + hb_last_wall = self._hb_last_rx_wall - def _status_por_score(self, score: float) -> StatusModulo: - if score >= 80: - return StatusModulo.OPERANTE - elif score >= 50: - return StatusModulo.ALERTA + in_grace = ( + mqtt_connected + and connected_mono > 0 + and (now_mono - connected_mono) < self._heartbeat_grace_after_connect_s + ) + + if hb_last > 0: + hb_age_s = max(0.0, now_mono - hb_last) + elif mqtt_connected and connected_mono > 0: + hb_age_s = max(0.0, now_mono - connected_mono) else: - return StatusModulo.FALHA + hb_age_s = math.inf - def get_base_ip(self): - return ContextoGlobalRedis.get_equipamento().get("base_ip") + if in_grace: + heartbeat_ok = True + else: + heartbeat_ok = mqtt_connected and hb_age_s <= self._heartbeat_degraded_s + + with self._mqtt_lock: + self._heartbeat_ok = heartbeat_ok + + return { + "mqtt_connected": mqtt_connected, + "mqtt_connecting": mqtt_connecting, + "subscribed": subscribed, + "heartbeat_ok": heartbeat_ok, + "heartbeat_in_grace": in_grace, + "heartbeat_age_s": hb_age_s, + "heartbeat_last_wall": hb_last_wall, + } + + # ====================================================================== + # PING ASSÍNCRONO + # ====================================================================== + + def _start_ping_async_if_needed(self): + now = time.monotonic() + + if (now - self._last_ping_started_mono) < self._ping_min_interval_s: + return + + if self._ping_future is not None and not self._ping_future.done(): + return + + base_ip = self.get_base_ip() + if not base_ip: + return + + self._last_ping_started_mono = now + self._ping_future = self._ping_executor.submit( + self._ping_once, + base_ip, + self._ping_count, + self._ping_timeout_ms, + ) + + def _consume_ping_result_if_ready(self): + if self._ping_future is None or not self._ping_future.done(): + return - def descobrir_nic_para_base(self) -> str | None: try: - base_ip = self.get_base_ip() - if base_ip is None: - return None + ok, avg_rtt_ms, loss_pct = self._ping_future.result(timeout=0) + except Exception: + ok, avg_rtt_ms, loss_pct = False, None, 100.0 - # Abre socket UDP só para descobrir o IP local usado na rota - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect((base_ip, 80)) # porta não importa - local_ip = s.getsockname()[0] - s.close() - except Exception as e: - print("[Ponte IP] Erro ao descobrir IP local da rota:", e) - return None + now = time.monotonic() + with self._ping_lock: + self._last_ping_completed_mono = now + self._last_ping_ok = bool(ok) + self._last_ping_rtt_ms = avg_rtt_ms + self._last_ping_loss_pct = float(loss_pct) + self._ping_sample_seq += 1 - # Agora, com o local_ip, encontrar qual NIC possui esse IP - addrs = psutil.net_if_addrs() + self._ping_future = None - for nic_name, addr_list in addrs.items(): - for addr in addr_list: - if addr.family == socket.AF_INET and addr.address == local_ip: - return nic_name + def _get_ping_snapshot(self): + with self._ping_lock: + return { + "ok": self._last_ping_ok, + "rtt_ms": self._last_ping_rtt_ms, + "loss_pct": self._last_ping_loss_pct, + "completed_mono": self._last_ping_completed_mono, + "seq": self._ping_sample_seq, + } - return None + def _apply_new_ping_sample(self, snapshot) -> bool: + seq = snapshot["seq"] + if seq <= self._last_applied_ping_seq: + return False - def _update_nic_bandwidth(self): - if self.nic_name is None: - self.nic_name = self.descobrir_nic_para_base() - if self.nic_name is None: - return + self._last_applied_ping_seq = seq + ok = bool(snapshot["ok"]) + rtt_ms = snapshot["rtt_ms"] - counters = psutil.net_io_counters(pernic=True).get(self.nic_name) - if not counters: - return + self.ping_ok_f.append(ok) + self.ping_ok_s.append(ok) - now = time.time() - if self.last_bw_counters is not None and self.last_bw_ts is not None: - dt = now - self.last_bw_ts - if dt > 0: - delta_rx = counters.bytes_recv - self.last_bw_counters.bytes_recv - delta_tx = counters.bytes_sent - self.last_bw_counters.bytes_sent + if ok: + self._consecutive_ping_successes += 1 + self._consecutive_ping_failures = 0 + self._last_ping_success_mono = snapshot["completed_mono"] - # bytes → bits → Mbit/s - rx_mbps = (delta_rx * 8.0) / (dt * 1_000_000.0) - tx_mbps = (delta_tx * 8.0) / (dt * 1_000_000.0) + if rtt_ms is not None and math.isfinite(float(rtt_ms)): + self.rtts_f.append(float(rtt_ms)) + self.rtts_s.append(float(rtt_ms)) + else: + self._consecutive_ping_failures += 1 + self._consecutive_ping_successes = 0 - self.bw_rx_mbps_f.append(rx_mbps) - self.bw_tx_mbps_f.append(tx_mbps) - self.bw_rx_mbps_s.append(rx_mbps) - self.bw_tx_mbps_s.append(tx_mbps) - - self.last_bw_counters = counters - self.last_bw_ts = now + return True def _ping_once(self, base_ip: str, pings: int, timeout_ms: int): - """ - Retorna (ok: bool, avg_rtt_ms: float|None, loss_pct: float) - Compatível com o ping async. - """ try: - cmd = ["ping", "-n", str(pings), "-w", str(timeout_ms), base_ip] - proc = subprocess.run(cmd, capture_output=True, text=True) - out = (proc.stdout or "") + "\n" + (proc.stderr or "") - - # Reply confiável: TTL= - replies = len(re.findall(r"TTL=", out, flags=re.IGNORECASE)) - ok = replies > 0 - - # Tempo: time=12ms / time<1ms / tempo=12ms - times = [] - for m in re.findall(r"(?:time|tempo)[=<]\s*(\d+)\s*ms", out, flags=re.IGNORECASE): - try: - times.append(float(m)) - except: - pass - avg_rtt_ms = (sum(times) / len(times)) if times else None - - # Loss: "Lost = X" ou "Perdidos = X" - lost = None - m = re.search(r"(?:Lost|Perdidos)\s*=\s*(\d+)", out, flags=re.IGNORECASE) - if m: - lost = int(m.group(1)) + if os.name == "nt": + cmd = ["ping", "-n", str(pings), "-w", str(timeout_ms), base_ip] + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) else: - # fallback pelo número de replies - lost = max(0, int(pings) - replies) + timeout_s = max(1, int(math.ceil(timeout_ms / 1000.0))) + cmd = ["ping", "-c", str(pings), "-W", str(timeout_s), base_ip] + creationflags = 0 + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=max(2.0, (timeout_ms / 1000.0) * pings + 1.5), + creationflags=creationflags, + ) + output = (proc.stdout or "") + "\n" + (proc.stderr or "") + + replies = len(re.findall(r"TTL=", output, flags=re.IGNORECASE)) + if os.name != "nt" and replies == 0: + replies = len(re.findall(r"bytes from", output, flags=re.IGNORECASE)) + + times = [] + pattern = r"(?:time|tempo)[=<]\s*(\d+(?:[\.,]\d+)?)\s*ms" + for raw in re.findall(pattern, output, flags=re.IGNORECASE): + try: + times.append(float(raw.replace(",", "."))) + except Exception: + pass + + avg_rtt_ms = statistics.mean(times) if times else None + lost = max(0, int(pings) - replies) loss_pct = (lost / max(1, int(pings))) * 100.0 - - # (opcional) debug leve em caso de falha - # if not ok: - # print("PING FAIL:", base_ip, "rc:", proc.returncode) - # print(out) + ok = replies > 0 return ok, avg_rtt_ms, loss_pct - except Exception as e: - # (opcional) print(e) + except Exception: return False, None, 100.0 - def _update_nic_errors(self): - if self.nic_name is None: - self.nic_name = self.descobrir_nic_para_base() - if self.nic_name is None: + # ====================================================================== + # NIC, BANDA E SOCKETS + # ====================================================================== + + def _update_nic_metrics_if_due(self): + now = time.monotonic() + if (now - self._last_nic_sample_mono) < self._nic_sample_interval_s: + return + self._last_nic_sample_mono = now + + if not self.nic_name: return - counters = psutil.net_io_counters(pernic=True).get(self.nic_name) - if not counters: + try: + counters = psutil.net_io_counters(pernic=True).get(self.nic_name) + except Exception: + counters = None + + if counters is None: return - if self.last_nic_counters is not None: - delta_err = (counters.errin - self.last_nic_counters.errin) + \ - (counters.errout - self.last_nic_counters.errout) - delta_pkts = (counters.packets_sent - self.last_nic_counters.packets_sent) + \ - (counters.packets_recv - self.last_nic_counters.packets_recv) + if self._last_nic_counters is not None: + dt = max(0.001, now - getattr(self, "_last_nic_counter_mono", now)) + + delta_rx = max(0, counters.bytes_recv - self._last_nic_counters.bytes_recv) + delta_tx = max(0, counters.bytes_sent - self._last_nic_counters.bytes_sent) + delta_bytes = delta_rx + delta_tx + + rx_mbps = (delta_rx * 8.0) / (dt * 1_000_000.0) + tx_mbps = (delta_tx * 8.0) / (dt * 1_000_000.0) + + self.bw_rx_mbps_f.append(rx_mbps) + self.bw_tx_mbps_f.append(tx_mbps) + self.bw_rx_mbps_s.append(rx_mbps) + self.bw_tx_mbps_s.append(tx_mbps) + + if delta_bytes > 0: + self._last_nic_activity_mono = now + + delta_err = ( + max(0, counters.errin - self._last_nic_counters.errin) + + max(0, counters.errout - self._last_nic_counters.errout) + + max(0, counters.dropin - self._last_nic_counters.dropin) + + max(0, counters.dropout - self._last_nic_counters.dropout) + ) + delta_pkts = ( + max(0, counters.packets_sent - self._last_nic_counters.packets_sent) + + max(0, counters.packets_recv - self._last_nic_counters.packets_recv) + ) if delta_pkts > 0: - # erros por mil pacotes - rate = (delta_err / delta_pkts) * 1000.0 - self.nic_error_rates_f.append(rate) - self.nic_error_rates_s.append(rate) + rate_per_thousand = (delta_err / delta_pkts) * 1000.0 + self.nic_error_rates_f.append(rate_per_thousand) + self.nic_error_rates_s.append(rate_per_thousand) - self.last_nic_counters = counters + self._last_nic_counters = counters + self._last_nic_counter_mono = now - def _metric_scores_from(self, rtts, timeouts, loses, nic_error_rates): - avg_rtt = None - loss_pct = 100.0 - jitter = 0.0 - err_rate = 0.0 - atraso = time.time() - self.last_heartbeat_ts + @staticmethod + def _raddr_ip_port(connection): + raddr = getattr(connection, "raddr", None) + if not raddr: + return None, None - # LATÊNCIA - if rtts: - avg_rtt = statistics.mean(rtts) - if avg_rtt <= 40: latency_score = 100 - elif avg_rtt <= 70: latency_score = 85 - elif avg_rtt <= 120: latency_score = 65 - elif avg_rtt <= 200: latency_score = 35 - else: latency_score = 10 - else: - latency_score = 0 + try: + return raddr.ip, raddr.port + except Exception: + try: + return raddr[0], raddr[1] + except Exception: + return None, None - # TIMEOUT - if timeouts: - timeout_pct = (sum(timeouts) / len(timeouts)) * 100.0 - else: - timeout_pct = 100.0 + def _update_socket_snapshot_if_due(self): + now = time.monotonic() + if (now - self._last_socket_probe_mono) < self._socket_probe_interval_s: + return - if timeout_pct <= 2: timeout_score = 100 - elif timeout_pct <= 5: timeout_score = 85 - elif timeout_pct <= 10: timeout_score = 60 - elif timeout_pct <= 20: timeout_score = 30 - else: timeout_score = 0 + self._last_socket_probe_mono = now + base_ip = self.get_base_ip() + if not base_ip: + self._tcp_established_total = 0 + self._tcp_established_non_mqtt = 0 + self._udp_remote_total = 0 + return - # LOSS - if loses: - loss_pct = (sum(loses) / len(loses)) - else: - loss_pct = 100.0 + tcp_total = 0 + tcp_non_mqtt = 0 + udp_total = 0 - if loss_pct <= 1: loss_score = 100 - elif loss_pct <= 3: loss_score = 90 - elif loss_pct <= 5: loss_score = 75 - elif loss_pct <= 8: loss_score = 55 - elif loss_pct <= 12: loss_score = 30 - else: loss_score = 0 + try: + connections = psutil.net_connections(kind="inet") + self._socket_probe_supported = True + except Exception: + self._socket_probe_supported = False + return - # JITTER - if len(rtts) > 2: - jitter = statistics.pstdev(rtts) - if jitter <= 12: jitter_score = 100 - elif jitter <= 25: jitter_score = 85 - elif jitter <= 40: jitter_score = 65 - elif jitter <= 70: jitter_score = 40 - else: jitter_score = 15 - else: - jitter = 0.0 - jitter_score = 85 + for conn in connections: + remote_ip, remote_port = self._raddr_ip_port(conn) + if remote_ip != base_ip: + continue - # NIC ERRORS - if nic_error_rates: - err_rate = statistics.mean(nic_error_rates) - else: - err_rate = 0.0 + sock_type = getattr(conn, "type", None) + status = getattr(conn, "status", "") - if err_rate <= 0.05: nic_score = 100 - elif err_rate <= 0.2: nic_score = 90 - elif err_rate <= 1.0: nic_score = 70 - elif err_rate <= 3.0: nic_score = 45 - else: nic_score = 20 + if sock_type == socket.SOCK_STREAM: + if status == psutil.CONN_ESTABLISHED: + tcp_total += 1 + if remote_port != 1883: + tcp_non_mqtt += 1 + elif sock_type == socket.SOCK_DGRAM: + udp_total += 1 - # HEARTBEAT (igual) - if atraso <= 1.5: hb_score = 100 - elif atraso <= 3.0: hb_score = 80 - elif atraso <= 5.0: hb_score = 50 - elif atraso <= 8.0: hb_score = 20 - else: hb_score = 0 + self._tcp_established_total = tcp_total + self._tcp_established_non_mqtt = tcp_non_mqtt + self._udp_remote_total = udp_total - return ( - latency_score, timeout_score, loss_score, jitter_score, nic_score, hb_score, - avg_rtt, timeout_pct, loss_pct, jitter, err_rate, atraso - ) + if tcp_total > 0 or udp_total > 0: + self._last_remote_socket_seen_mono = now + # ====================================================================== + # CÁLCULO DAS MÉTRICAS + # ====================================================================== + + @staticmethod + def _mean(values, default=0.0): + return statistics.mean(values) if values else default + + @staticmethod + def _loss_pct(window) -> Optional[float]: + if not window: + return None + failures = sum(1 for ok in window if not ok) + return (failures / len(window)) * 100.0 + + @staticmethod + def _score_latency(avg_rtt_ms: Optional[float]) -> float: + if avg_rtt_ms is None: + return 100.0 + if avg_rtt_ms <= 50: + return 100.0 + if avg_rtt_ms <= 100: + return 90.0 + if avg_rtt_ms <= 180: + return 75.0 + if avg_rtt_ms <= 300: + return 50.0 + if avg_rtt_ms <= 500: + return 25.0 + return 5.0 + + @staticmethod + def _score_loss( + loss_pct: Optional[float], + failures: int, + sample_count: int, + ) -> float: + if loss_pct is None or failures <= 0: + return 100.0 + + # Uma falha isolada continua visível na porcentagem bruta, mas não + # recebe uma punição desproporcional durante o aquecimento da janela. + if failures == 1: + return 95.0 if sample_count < 20 else 90.0 + + if loss_pct <= 2: + return 100.0 + if loss_pct <= 5: + return 90.0 + if loss_pct <= 10: + return 75.0 + if loss_pct <= 20: + return 50.0 + if loss_pct <= 35: + return 20.0 + return 0.0 + + @staticmethod + def _score_jitter(jitter_ms: float, rtt_sample_count: int) -> float: + if rtt_sample_count < 4: + return 100.0 + if jitter_ms <= 20: + return 100.0 + if jitter_ms <= 50: + return 85.0 + if jitter_ms <= 100: + return 60.0 + if jitter_ms <= 180: + return 30.0 + return 10.0 + + @staticmethod + def _score_nic_errors(error_rate_per_thousand: float) -> float: + if error_rate_per_thousand <= 0.05: + return 100.0 + if error_rate_per_thousand <= 0.2: + return 90.0 + if error_rate_per_thousand <= 1.0: + return 70.0 + if error_rate_per_thousand <= 3.0: + return 45.0 + return 20.0 + + def _score_heartbeat(self, snapshot) -> float: + if snapshot["heartbeat_in_grace"]: + return 100.0 + + age = snapshot["heartbeat_age_s"] + if not math.isfinite(age): + # MQTT desconectado sem heartbeat. É falha do serviço, não prova + # isolada de queda do enlace. + return 20.0 if not snapshot["mqtt_connected"] else 40.0 + if age <= 2.0: + return 100.0 + if age <= self._heartbeat_warn_s: + return 90.0 + if age <= self._heartbeat_degraded_s: + return 65.0 + if age <= self._heartbeat_critical_s: + return 35.0 + return 10.0 def _bandwidth_score(self, bw_total_mbps: float): - """ - Retorna (bw_score 0..100, bw_util_pct 0..100, bw_state str) - Curva pensada p/ rádio: até SAFE = ok, depois cai, e perto do HARD cai forte. - """ bw = max(0.0, float(bw_total_mbps or 0.0)) bw_max = max(0.1, float(self.bw_max_mbps)) bw_safe = max(0.1, float(self.bw_safe_mbps)) - bw_hard = max(bw_safe, float(self.bw_hard_mbps)) + bw_hard = max(bw_safe + 0.01, float(self.bw_hard_mbps)) - util = min(1.5, bw / bw_max) # pode passar de 100% (indicador) - util_pct = util * 100.0 + util_pct = min(150.0, (bw / bw_max) * 100.0) if bw <= bw_safe: return 100.0, util_pct, "OK" - if bw >= bw_hard: - # acima do hard: score vai rapidamente pra 0 - # 0 no hard*1.1, por exemplo - span = max(0.05, bw_hard * 0.10) + span = max(0.05, bw_hard * 0.20) x = min(1.0, (bw - bw_hard) / span) - score = max(0.0, 20.0 * (1.0 - x)) # 20 → 0 - return score, util_pct, "CRITICO" + return max(0.0, 30.0 * (1.0 - x)), util_pct, "CRITICO" - # entre safe e hard: cai de 100 → 20 - x = (bw - bw_safe) / max(0.05, (bw_hard - bw_safe)) # 0..1 - score = 100.0 - (80.0 * x) # 100 → 20 + x = (bw - bw_safe) / max(0.05, bw_hard - bw_safe) + score = 100.0 - (70.0 * x) state = "ALTO" if x >= 0.5 else "MEDIO" return max(0.0, min(100.0, score)), util_pct, state - def _bandwidth_pressure_pct(self, bw_util_pct: float, ls: float, tmout: float, los: float, js: float, ns: float, hbs: float): - """ - Pressão real do link para guiar vídeo adaptativo. - 0 = folgado - 100 = estrangulado - """ + def _heartbeat_age_for_display(self, snapshot) -> float: + age = snapshot["heartbeat_age_s"] + return age if math.isfinite(age) else -1.0 - bw_util_pct = max(0.0, min(100.0, float(bw_util_pct or 0.0))) + def _calculate_metrics(self, heartbeat_snapshot): + loss_f = self._loss_pct(self.ping_ok_f) + loss_s = self._loss_pct(self.ping_ok_s) - p_lat = 100.0 - max(0.0, min(100.0, float(ls or 0.0))) - p_tmout = 100.0 - max(0.0, min(100.0, float(tmout or 0.0))) - p_loss = 100.0 - max(0.0, min(100.0, float(los or 0.0))) - p_jit = 100.0 - max(0.0, min(100.0, float(js or 0.0))) - p_nic = 100.0 - max(0.0, min(100.0, float(ns or 0.0))) - p_hb = 100.0 - max(0.0, min(100.0, float(hbs or 0.0))) + avg_rtt_f = self._mean(self.rtts_f, default=None) + avg_rtt_s = self._mean(self.rtts_s, default=None) - pressure_rel = ( - 0.30 * p_tmout + - 0.25 * p_loss + - 0.15 * p_hb + - 0.15 * p_jit + - 0.10 * p_lat + - 0.05 * p_nic + jitter_f = statistics.pstdev(self.rtts_f) if len(self.rtts_f) >= 4 else 0.0 + jitter_s = statistics.pstdev(self.rtts_s) if len(self.rtts_s) >= 4 else 0.0 + + err_rate_f = self._mean(self.nic_error_rates_f, 0.0) + err_rate_s = self._mean(self.nic_error_rates_s, 0.0) + + rx_f = self._mean(self.bw_rx_mbps_f, 0.0) + tx_f = self._mean(self.bw_tx_mbps_f, 0.0) + rx_s = self._mean(self.bw_rx_mbps_s, 0.0) + tx_s = self._mean(self.bw_tx_mbps_s, 0.0) + + bw_total_f = rx_f + tx_f + bw_total_s = rx_s + tx_s + bw_score_f, bw_util_f, bw_state_f = self._bandwidth_score(bw_total_f) + bw_score_s, bw_util_s, bw_state_s = self._bandwidth_score(bw_total_s) + + ls_f = self._score_latency(avg_rtt_f) + ls_s = self._score_latency(avg_rtt_s) + failures_f = sum(1 for ok in self.ping_ok_f if not ok) + failures_s = sum(1 for ok in self.ping_ok_s if not ok) + los_f = self._score_loss(loss_f, failures_f, len(self.ping_ok_f)) + los_s = self._score_loss(loss_s, failures_s, len(self.ping_ok_s)) + js_f = self._score_jitter(jitter_f, len(self.rtts_f)) + js_s = self._score_jitter(jitter_s, len(self.rtts_s)) + ns_f = self._score_nic_errors(err_rate_f) + ns_s = self._score_nic_errors(err_rate_s) + hbs = self._score_heartbeat(heartbeat_snapshot) + + # "timeout" é mantido como alias de falha de sondagem para preservar + # o contrato antigo, mas NÃO entra novamente no peso da saúde. + tmout_f = los_f + tmout_s = los_s + timeout_pct_f = loss_f if loss_f is not None else 0.0 + timeout_pct_s = loss_s if loss_s is not None else 0.0 + + # Mistura com predominância da tendência lenta. + def mix(slow, fast, w_s=0.75): + return (w_s * slow) + ((1.0 - w_s) * fast) + + ls = mix(ls_s, ls_f) + los = mix(los_s, los_f) + tmout = los + js = mix(js_s, js_f) + ns = mix(ns_s, ns_f) + bw_score_raw = mix(bw_score_s, bw_score_f) + + return { + "loss_f": loss_f if loss_f is not None else 0.0, + "loss_s": loss_s if loss_s is not None else 0.0, + "failures_f": failures_f, + "failures_s": failures_s, + "timeout_pct_f": timeout_pct_f, + "timeout_pct_s": timeout_pct_s, + "avg_rtt_f": avg_rtt_f, + "avg_rtt_s": avg_rtt_s, + "jitter_f": jitter_f, + "jitter_s": jitter_s, + "err_rate_f": err_rate_f, + "err_rate_s": err_rate_s, + "rx_f": rx_f, + "tx_f": tx_f, + "rx_s": rx_s, + "tx_s": tx_s, + "bw_total_f": bw_total_f, + "bw_total_s": bw_total_s, + "bw_score_f": bw_score_f, + "bw_score_s": bw_score_s, + "bw_score_raw": bw_score_raw, + "bw_util_f": bw_util_f, + "bw_util_s": bw_util_s, + "bw_state_f": bw_state_f, + "bw_state_s": bw_state_s, + "ls_f": ls_f, + "ls_s": ls_s, + "los_f": los_f, + "los_s": los_s, + "tmout_f": tmout_f, + "tmout_s": tmout_s, + "js_f": js_f, + "js_s": js_s, + "ns_f": ns_f, + "ns_s": ns_s, + "hbs": hbs, + "ls": ls, + "los": los, + "tmout": tmout, + "js": js, + "ns": ns, + "probe_sample_count": len(self.ping_ok_s), + "probe_ready": len(self.ping_ok_s) >= 6, + } + + # ====================================================================== + # EVIDÊNCIAS E ESTADO + # ====================================================================== + + def _reachability_evidence(self, now_mono: float, heartbeat_snapshot): + nic_up = self._nic_is_up() + route_ok = bool(self.get_base_ip() and self.nic_name and nic_up) + + ping_success_age = ( + now_mono - self._last_ping_success_mono + if self._last_ping_success_mono > 0 + else math.inf + ) + socket_seen_age = ( + now_mono - self._last_remote_socket_seen_mono + if self._last_remote_socket_seen_mono > 0 + else math.inf + ) + nic_activity_age = ( + now_mono - self._last_nic_activity_mono + if self._last_nic_activity_mono > 0 + else math.inf ) - # gatilhos de proteção - if tmout < 50: - pressure_rel = max(pressure_rel, 85.0) - if los < 50: - pressure_rel = max(pressure_rel, 85.0) - if hbs < 50: - pressure_rel = max(pressure_rel, 80.0) - if js < 30: - pressure_rel = max(pressure_rel, 75.0) + ping_recent = ping_success_age <= 4.0 + active_remote_socket = ( + self._tcp_established_total > 0 or self._udp_remote_total > 0 + ) + non_mqtt_socket = self._tcp_established_non_mqtt > 0 + socket_recent = active_remote_socket or socket_seen_age <= 4.0 + nic_activity_recent = nic_activity_age <= 3.0 + mqtt_connected = heartbeat_snapshot["mqtt_connected"] - pressure = max(bw_util_pct, pressure_rel) - return round(max(0.0, min(100.0, pressure)), 1) + positive_count = sum( + bool(value) + for value in ( + ping_recent, + socket_recent, + mqtt_connected, + nic_activity_recent, + ) + ) + + strong_positive = ping_recent or non_mqtt_socket + any_positive = positive_count > 0 + + all_remote_evidence_absent = ( + not ping_recent + and not socket_recent + and not mqtt_connected + and not nic_activity_recent + ) + + return { + "nic_up": nic_up, + "route_ok": route_ok, + "ping_success_age_s": ping_success_age, + "ping_recent": ping_recent, + "socket_seen_age_s": socket_seen_age, + "socket_recent": socket_recent, + "non_mqtt_socket": non_mqtt_socket, + "nic_activity_age_s": nic_activity_age, + "nic_activity_recent": nic_activity_recent, + "positive_count": positive_count, + "strong_positive": strong_positive, + "any_positive": any_positive, + "all_remote_evidence_absent": all_remote_evidence_absent, + } + + def _evaluate_instant_conditions(self, metrics, evidence, heartbeat_snapshot): + loss_f = metrics["loss_f"] + loss_s = metrics["loss_s"] + rtt_s = metrics["avg_rtt_s"] or 0.0 + jitter_s = metrics["jitter_s"] + hb_age = heartbeat_snapshot["heartbeat_age_s"] + probe_ready = metrics["probe_ready"] + + heartbeat_delayed = ( + math.isfinite(hb_age) and hb_age >= self._heartbeat_degraded_s + ) + heartbeat_very_delayed = ( + math.isfinite(hb_age) and hb_age >= self._heartbeat_critical_s + ) + + # Perda percentual só vira evidência após uma janela mínima e pelo + # menos duas falhas reais. Isso impede que 1 falha isolada durante o + # aquecimento apareça como 16,7% e dispare degradação. + loss_degraded = ( + metrics["probe_sample_count"] >= 12 + and metrics["failures_s"] >= 2 + and loss_s >= 10.0 + ) + + qos_degraded = probe_ready and ( + loss_degraded + or (rtt_s >= 180.0 and jitter_s >= 50.0) + or jitter_s >= 100.0 + or self._consecutive_ping_failures >= 2 + ) + + corroborated_service_delay = heartbeat_delayed and ( + loss_s >= 5.0 + or rtt_s >= 150.0 + or jitter_s >= 60.0 + or self._consecutive_ping_failures >= 2 + ) + + bandwidth_corroborated = ( + metrics["bw_util_s"] >= 85.0 + and ( + loss_s >= 5.0 + or rtt_s >= 120.0 + or jitter_s >= 50.0 + or heartbeat_delayed + ) + ) + + degraded_now = bool( + qos_degraded + or corroborated_service_delay + or bandwidth_corroborated + ) + + critical_now = bool( + probe_ready + and ( + ( + metrics["probe_sample_count"] >= 12 + and metrics["failures_s"] >= 4 + and loss_s >= 35.0 + and loss_f >= 35.0 + ) + or (loss_s >= 20.0 and rtt_s >= 300.0) + or ( + self._consecutive_ping_failures >= 5 + and not evidence["strong_positive"] + ) + or ( + heartbeat_very_delayed + and loss_s >= 15.0 + and not evidence["non_mqtt_socket"] + ) + ) + ) + + # Confirmação de queda total: nunca depende apenas de MQTT. + down_candidate = bool( + not evidence["route_ok"] + or ( + metrics["probe_ready"] + and self._consecutive_ping_failures >= 4 + and evidence["all_remote_evidence_absent"] + ) + ) + + return { + "degraded_now": degraded_now, + "critical_now": critical_now, + "down_candidate": down_candidate, + "heartbeat_delayed": heartbeat_delayed, + "heartbeat_very_delayed": heartbeat_very_delayed, + "bandwidth_corroborated": bandwidth_corroborated, + "qos_degraded": qos_degraded, + } + + @staticmethod + def _elapsed_since(now_mono: float, started_mono: float) -> float: + return max(0.0, now_mono - started_mono) if started_mono > 0 else 0.0 + + def _update_persistence_counters(self, new_ping_sample: bool, instant): + if not new_ping_sample: + return + + if instant["critical_now"] or instant["down_candidate"]: + self._cycles_critical = min(1000, self._cycles_critical + 1) + self._cycles_degraded = min(1000, self._cycles_degraded + 1) + self._cycles_recovered = 0 + elif instant["degraded_now"]: + self._cycles_degraded = min(1000, self._cycles_degraded + 1) + self._cycles_critical = max(0, self._cycles_critical - 1) + self._cycles_recovered = 0 + else: + self._cycles_recovered = min(1000, self._cycles_recovered + 1) + self._cycles_degraded = max(0, self._cycles_degraded - 1) + self._cycles_critical = max(0, self._cycles_critical - 1) + + def _update_link_state(self, now_mono: float, instant): + previous = self.link_state + + if instant["down_candidate"]: + if self._down_since_mono <= 0: + self._down_since_mono = now_mono + else: + self._down_since_mono = 0.0 + + down_confirmed = ( + instant["down_candidate"] + and self._elapsed_since(now_mono, self._down_since_mono) + >= self._confirm_down_after_s + ) + + # Queda total já passou pela própria persistência de confirmação. + # Portanto, quando confirmada, não aguardamos uma segunda janela para + # bloquear a operação. + if down_confirmed: + self.link_state = self.LINK_CRITICAL + self._critical_since_mono = now_mono + self._degraded_since_mono = now_mono + self._healthy_since_mono = 0.0 + return previous, down_confirmed + + effective_critical = instant["critical_now"] + + if effective_critical: + if self._critical_since_mono <= 0: + self._critical_since_mono = now_mono + if self._degraded_since_mono <= 0: + self._degraded_since_mono = now_mono + self._healthy_since_mono = 0.0 + + if ( + self._elapsed_since(now_mono, self._critical_since_mono) + >= self._enter_critical_after_s + ): + self.link_state = self.LINK_CRITICAL + + elif instant["degraded_now"]: + self._critical_since_mono = 0.0 + if self._degraded_since_mono <= 0: + self._degraded_since_mono = now_mono + self._healthy_since_mono = 0.0 + + if ( + self.link_state == self.LINK_OK + and self._elapsed_since(now_mono, self._degraded_since_mono) + >= self._enter_degraded_after_s + ): + self.link_state = self.LINK_DEGRADED + + else: + self._critical_since_mono = 0.0 + self._degraded_since_mono = 0.0 + if self._healthy_since_mono <= 0: + self._healthy_since_mono = now_mono + + if ( + self.link_state != self.LINK_OK + and self._elapsed_since(now_mono, self._healthy_since_mono) + >= self._recover_after_s + ): + self.link_state = self.LINK_OK + + return previous, down_confirmed + + def _calculate_health(self, metrics, evidence, heartbeat_snapshot, instant): + # Banda só recebe peso integral quando há sintoma corroborando pressão. + if instant["bandwidth_corroborated"]: + bw_effective_score = metrics["bw_score_raw"] + else: + bw_effective_score = max(85.0, metrics["bw_score_raw"]) + + # Reachability: usa várias evidências. MQTT sozinho não decide nada. + if evidence["strong_positive"]: + reachability_score = 100.0 + elif evidence["positive_count"] >= 2: + reachability_score = 95.0 + elif evidence["any_positive"]: + reachability_score = 80.0 + elif not metrics["probe_ready"] and evidence["route_ok"]: + reachability_score = 75.0 # aquecimento + elif evidence["route_ok"]: + reachability_score = 30.0 + else: + reachability_score = 0.0 + + network_health = ( + 0.35 * metrics["los"] + + 0.22 * metrics["ls"] + + 0.15 * metrics["js"] + + 0.20 * reachability_score + + 0.05 * metrics["ns"] + + 0.03 * bw_effective_score + ) + + # Serviço MQTT/heartbeat pode reduzir um pouco a saúde observada, mas + # jamais transformar sozinho um enlace comprovadamente ativo em falha. + service_penalty = 0.0 + if not heartbeat_snapshot["mqtt_connected"]: + service_penalty += 4.0 + if metrics["hbs"] < 65: + service_penalty += 4.0 + + health = max(0.0, min(100.0, network_health - service_penalty)) + + if self.link_state == self.LINK_CRITICAL: + health = min(health, 45.0) + elif self.link_state == self.LINK_DEGRADED: + health = min(health, 75.0) + + return { + "health": round(health, 1), + "network_health": round(network_health, 1), + "reachability_score": round(reachability_score, 1), + "bw_effective_score": round(bw_effective_score, 1), + "service_penalty": round(service_penalty, 1), + } + + def _bandwidth_pressure_pct(self, metrics, heartbeat_snapshot, instant): + quality_pressure = ( + 0.45 * (100.0 - metrics["los"]) + + 0.25 * (100.0 - metrics["ls"]) + + 0.20 * (100.0 - metrics["js"]) + + 0.10 * (100.0 - metrics["hbs"]) + ) + + # Uso alto sem sintomas não deve parecer estrangulamento. Com sintomas, + # o uso estimado passa a ser evidência forte de fila cheia/bufferbloat. + if instant["bandwidth_corroborated"]: + bandwidth_pressure = metrics["bw_util_s"] + else: + bandwidth_pressure = metrics["bw_util_s"] * 0.55 + + return round( + max(0.0, min(100.0, max(quality_pressure, bandwidth_pressure))), + 1, + ) + + # ====================================================================== + # DISTÂNCIA / PERFIL DE BANDA + # ====================================================================== def _interp_log(self, x, x0, x1, y0, y1): x = max(x0, min(x, x1)) - t = (math.log10(x) - math.log10(x0)) / (math.log10(x1) - math.log10(x0)) + t = (math.log10(x) - math.log10(x0)) / ( + math.log10(x1) - math.log10(x0) + ) return y0 + t * (y1 - y0) - def _ajustar_limites_banda_por_distancia(self, distancia_m: float | None): - """ - Ajusta os limites de banda usando a distância da base. + def _aplicar_limites_nominais_banda(self): + self.bw_max_mbps = self._bw_nominal_max_mbps + self.bw_safe_mbps = self._bw_nominal_safe_mbps + self.bw_hard_mbps = self._bw_nominal_hard_mbps - Quando não há posição confiável, aplica o perfil nominal do enlace, - sem penalização baseada em distância. + def _ajustar_limites_banda_por_distancia(self, distancia_m: Optional[float]): """ - if ( - distancia_m is None - or not math.isfinite(float(distancia_m)) - or distancia_m < 0 - ): + A distância continua gerando um perfil estimado, mas esse perfil agora + é apenas informativo e precisa ser corroborado por perda/latência/jitter + antes de afetar fortemente a saúde. + """ + if distancia_m is None or not math.isfinite(distancia_m) or distancia_m < 0: self._aplicar_limites_nominais_banda() return d = max(10.0, min(float(distancia_m), 1200.0)) + estimated_max = self._interp_log(d, 10.0, 1200.0, 10.0, 0.4) + estimated_max = max(0.35, min(10.0, estimated_max)) - bw_max = self._interp_log( - d, - 10.0, - 1200.0, - 10.0, - 0.4 + self.bw_max_mbps = round(estimated_max, 3) + self.bw_safe_mbps = round(max(0.20, estimated_max * 0.60), 3) + self.bw_hard_mbps = round( + max(self.bw_safe_mbps + 0.05, estimated_max * 0.78), + 3, ) - bw_max = max(0.35, min(bw_max, 10.0)) - - bw_safe = max(0.20, bw_max * 0.60) - bw_hard = max(bw_safe + 0.05, bw_max * 0.78) - - self.bw_max_mbps = round(bw_max, 3) - self.bw_safe_mbps = round(bw_safe, 3) - self.bw_hard_mbps = round(bw_hard, 3) - - - def _normalizar_status_modulo(self, status): + @staticmethod + def _normalizar_status_modulo(status): if status is None: return None - - # Caso tenha vindo como enum return getattr(status, "value", status) def _obter_status_gnss(self): - """ - Retorna o status atual do módulo GNSS. - - Ajuste T_Code.Gps somente se o membro do enum tiver outro nome - no seu projeto, por exemplo T_Code.Gnss. - """ modulo_gnss = ContextoGlobalRedis.get_modulo(T_Code.Gps) or {} saude_gnss = modulo_gnss.get("saude") or {} - - status = saude_gnss.get( - "status", - modulo_gnss.get("status") - ) - + status = saude_gnss.get("status", modulo_gnss.get("status")) return self._normalizar_status_modulo(status) - def _normalizar_distancia(self, valor): + @staticmethod + def _normalizar_distancia(valor): try: distancia = float(valor) except (TypeError, ValueError): @@ -746,680 +1424,646 @@ class ModuloIPBribge(ModuloDiagnosticoBase): if not math.isfinite(distancia) or distancia < 0: return None - return distancia def _posicao_confiavel(self, distancia_base): status_gnss = self._obter_status_gnss() + validos = {StatusModulo.OPERANTE.value, StatusModulo.ALERTA.value} + return status_gnss in validos and distancia_base is not None, status_gnss - status_validos = { - StatusModulo.OPERANTE.value, - StatusModulo.ALERTA.value, - } + def _update_distance_markers( + self, + previous_state, + regra_distancia_ativa, + distancia_base, + ): + if not regra_distancia_ativa: + self._distancia_inicio_degradado = None + self._distancia_inicio_critico = None + self._max_distancia_desde_degradado = 0.0 + return - confiavel = ( - status_gnss in status_validos - and distancia_base is not None - ) - - return confiavel, status_gnss - - def _aplicar_limites_nominais_banda(self): - self.bw_max_mbps = self._bw_nominal_max_mbps - self.bw_safe_mbps = self._bw_nominal_safe_mbps - self.bw_hard_mbps = self._bw_nominal_hard_mbps - - - - def atualizar_saude_interno(self): - #print("atualizando saude IPB...") - try: - m = ContextoGlobalRedis.get_modulo(self.t_code) or {} - - distancia_base_bruta = m.get("distancia_base") - distancia_base = self._normalizar_distancia(distancia_base_bruta) - - posicao_confiavel, status_gnss = self._posicao_confiavel( - distancia_base + if self.link_state == self.LINK_DEGRADED: + if self._distancia_inicio_degradado is None: + self._distancia_inicio_degradado = distancia_base + self._max_distancia_desde_degradado = distancia_base + self._max_distancia_desde_degradado = max( + self._max_distancia_desde_degradado, + distancia_base, ) - regra_distancia_ativa = ( - posicao_confiavel - and distancia_base is not None - ) + if self.link_state == self.LINK_CRITICAL: + if self._distancia_inicio_critico is None: + self._distancia_inicio_critico = distancia_base - self._ajustar_limites_banda_por_distancia( - distancia_m=distancia_base if regra_distancia_ativa else None - ) - - self._check_mqtt_heartbeat() - self._start_mqtt_heartbeat_async() - - SAUDE_MIN_ALERTA = 80 - - motivos = [] - condicoes = [] - saude_individual = [] - #conectado = self.get_base_ip() is not None and self.nic_name is not None and self._mqtt_conectado - conectado = (self.get_base_ip() is not None) and self._mqtt_conectado - - # 1) consome resultado pronto (não bloqueia) - self._consume_ping_result_if_ready() - # 2) dispara novo ping se precisar (não bloqueia) - self._start_ping_async_if_needed() - # 3) usa snapshot pra alimentar suas janelas rtts/loses/timeouts - ok, rtt, loss, ping_ts = self._get_ping_snapshot() - #print(f"ok: {ok}, rtt: {rtt}, loss: {loss}, ping_ts: {ping_ts}") - if ping_ts > self._last_ping_sample_applied_ts: - self._last_ping_sample_applied_ts = ping_ts - - self.timeouts_f.append(loss == 100) - self.timeouts_s.append(loss == 100) - - if ok and rtt is not None: - self.rtts_f.append(rtt) - self.rtts_s.append(rtt) - - if loss is not None: - self.loses_f.append(loss) - self.loses_s.append(loss) - - self._update_nic_errors() - self._update_nic_bandwidth() - - fast = self._metric_scores_from(self.rtts_f, self.timeouts_f, self.loses_f, self.nic_error_rates_f) - slow = self._metric_scores_from(self.rtts_s, self.timeouts_s, self.loses_s, self.nic_error_rates_s) - - (ls_f, tmout_f, los_f, js_f, ns_f, hbs_f, avg_rtt_f, tmout_pct_f, loss_pct_f, jitter_f, err_rate_f, atraso_f) = fast - (ls_s, tmout_s, los_s, js_s, ns_s, hbs_s, avg_rtt_s, tmout_pct_s, loss_pct_s, jitter_s, err_rate_s, atraso_s) = slow - rx_f = statistics.mean(self.bw_rx_mbps_f) if self.bw_rx_mbps_f else 0.0 - tx_f = statistics.mean(self.bw_tx_mbps_f) if self.bw_tx_mbps_f else 0.0 - bw_total_f = rx_f + tx_f - bw_score_f, bw_util_pct_f, bw_state_f = self._bandwidth_score(bw_total_f) - - rx_s = statistics.mean(self.bw_rx_mbps_s) if self.bw_rx_mbps_s else 0.0 - tx_s = statistics.mean(self.bw_tx_mbps_s) if self.bw_tx_mbps_s else 0.0 - bw_total_s = rx_s + tx_s - bw_score_s, bw_util_pct_s, bw_state_s = self._bandwidth_score(bw_total_s) - - health_f = ( - 0.15 * ls_f + - 0.18 * tmout_f + - 0.25 * los_f + - 0.08 * js_f + - 0.05 * ns_f + - 0.22 * hbs_f + - 0.07 * bw_score_f - ) - - health_s = ( - 0.15 * ls_s + - 0.18 * tmout_s + - 0.25 * los_s + - 0.08 * js_s + - 0.05 * ns_s + - 0.22 * hbs_s + - 0.07 * bw_score_s - ) - - health = 0.80 * health_s + 0.20 * health_f - - # Se o FAST gritar "caos", derruba a saúde na hora (não espera a slow) - if loss_pct_f >= 12: - health = min(health, 55) - elif loss_pct_f >= 8 and loss_pct_s >= 5: - health = min(health, 65) - - # Timeout - if tmout_pct_f >= 20: - health = min(health, 45) - elif tmout_pct_f >= 10 and tmout_pct_s >= 5: - health = min(health, 60) - - # Se saturou banda no FAST, corta também (comandos atrasam) - if bw_util_pct_f >= 98 and (loss_pct_f >= 5 or tmout_pct_f >= 5): - health = min(health, 65) - - # Se heartbeat atrasou muito, isso é sério - if atraso_f >= 8.0: - health = min(health, 30) - elif atraso_f >= 5.0: - health = min(health, 45) - elif atraso_f >= 3.0 and atraso_s >= 2.0: - health = min(health, 60) - - saude = round(health, 1) - - # -------- SAÚDE INDIVIDUAL / CONDIÇÕES / MOTIVOS -------- - - def _mix(a_s, a_f, w_s=0.70, w_f=0.30): - return (w_s * a_s) + (w_f * a_f) - - ls = _mix(ls_s, ls_f) - tmout = _mix(tmout_s, tmout_f) - los = _mix(los_s, los_f) - js = _mix(js_s, js_f) - ns = _mix(ns_s, ns_f) - hbs = _mix(hbs_s, hbs_f) - bw_score = _mix(bw_score_s, bw_score_f) - - # Para valores brutos, eu sugiro mostrar SLOW como "tendência" e FAST como "agora". - avg_rtt = avg_rtt_s - tmout_pct = tmout_pct_s - loss_pct = loss_pct_s - jitter = jitter_s - err_rate = err_rate_s - atraso = atraso_s - - rx_mbps = rx_s - tx_mbps = tx_s - bw_total = bw_total_s - bw_util_pct = bw_util_pct_s - bw_state = bw_state_s - - bw_pressure_pct = self._bandwidth_pressure_pct( - bw_util_pct=bw_util_pct, - ls=ls, - tmout=tmout, - los=los, - js=js, - ns=ns, - hbs=hbs, - ) - - # 1) Latência - cond_lat = [] - if ls_f < SAUDE_MIN_ALERTA: - severidade = min(89, int(max(0, 100 - ls_f))) - c = { - "label": "Latência ICMP", - "valor": avg_rtt_f if avg_rtt_f is not None else -1, - "severidade": severidade, - "descricao": f"Latência alta (rápida). Tendência: {avg_rtt_s:.0f} ms" if avg_rtt_s is not None else "Latência alta (rápida).", - "acoes": [ - "Reduzir taxa de envio de telemetria", - "Verificar alinhamento das antenas 900 MHz", - "Verificar interferência ou obstáculos entre base e rover" - ] - } - condicoes.append(c) - cond_lat.append(c) - - if avg_rtt_f is not None: - motivos.append(f"Latência ICMP alta (fast {avg_rtt_f:.0f} ms, score {ls_f:.0f}; slow {avg_rtt_s:.0f} ms, score {ls_s:.0f}).") - else: - motivos.append(f"Latência ICMP comprometida (fast score {ls_f:.0f}; slow score {ls_s:.0f}).") - - saude_individual.append({ - "id": "ip_latency", - "label": "Latência ICMP", - "status": self._status_por_score(ls).value, # <- MIX - "saude": round(ls, 1), # <- MIX - "motivos": [m for m in motivos if "Latência ICMP" in m], - "condicoes_operacionais": cond_lat, - "em_uso": True, - }) - - # 2) Timeouts - cond_timeout = [] - if tmout_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - tmout_f))) - c = { - "label": "Timeout", - "valor": round(tmout_pct_f, 2), - "severidade": severidade, - "descricao": f"Timeout ICMP alto (rápido). Tendência: {tmout_pct_s:.1f}%", - "acoes": [ - "Checar conectores e cabo da ponte 900 MHz", - "Verificar nível de ruído / interferência no enlace", - "Reduzir banda utilizada (streaming de vídeo, logs, etc.)" - ] - } - condicoes.append(c) - cond_timeout.append(c) - motivos.append(f"Timeout alto (fast {tmout_pct_f:.1f}%, score {tmout_f:.0f}; slow {tmout_pct_s:.1f}%, score {tmout_s:.0f}).") - - saude_individual.append({ - "id": "ip_timeout", - "label": "Timeout", - "status": self._status_por_score(tmout).value, - "saude": round(tmout, 1), - "motivos": [m for m in motivos if "Timeout alto" in m or "Timeout ICMP" in m], - "condicoes_operacionais": cond_timeout, - "em_uso": True, - }) - - # 2) Perda de pacotes - cond_loss = [] - if los_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - los_f))) - c = { - "label": "Perda de Pacotes", - "valor": round(loss_pct_f, 2), - "severidade": severidade, - "descricao": f"Perda de pacotes alta (rápida). Tendência: {loss_pct_s:.1f}%", - "acoes": [ - "Checar conectores e cabo da ponte 900 MHz", - "Verificar nível de ruído / interferência no enlace", - "Reduzir banda utilizada (streaming de vídeo, logs, etc.)" - ] - } - condicoes.append(c) - cond_loss.append(c) - motivos.append(f"Perda de pacotes alta (fast {loss_pct_f:.1f}%, score {los_f:.0f}; slow {loss_pct_s:.1f}%, score {los_s:.0f}).") - - saude_individual.append({ - "id": "ip_loss", - "label": "Perda de pacotes", - "status": self._status_por_score(los).value, - "saude": round(los, 1), - "motivos": [m for m in motivos if "Perda de pacotes alta" in m], - "condicoes_operacionais": cond_loss, - "em_uso": True, - }) - - # 3) Jitter - cond_jit = [] - if js_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - js_f))) - c = { - "label": "Jitter (variação de latência)", - "valor": round(jitter_f, 2), - "severidade": severidade, - "descricao": f"Jitter alto (rápido). Tendência: {jitter_s:.1f} ms", - "acoes": [ - "Evitar tráfego pesado na mesma rede da ponte", - "Reduzir taxa de envio de mensagens de controle", - "Verificar qualidade do enlace 900 MHz" - ] - } - condicoes.append(c) - cond_jit.append(c) - motivos.append(f"Jitter alto (fast {jitter_f:.1f} ms, score {js_f:.0f}; slow {jitter_s:.1f} ms, score {js_s:.0f}).") - - saude_individual.append({ - "id": "ip_jitter", - "label": "Jitter da conexão", - "status": self._status_por_score(js).value, - "saude": round(js, 1), - "motivos": [m for m in motivos if "Jitter alto" in m], - "condicoes_operacionais": cond_jit, - "em_uso": True, - }) - - # 4) Erros na NIC - cond_nic = [] - if ns_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - ns_f))) - c = { - "label": "Erros na interface de rede", - "valor": round(err_rate_f, 3), - "severidade": severidade, - "descricao": f"Erros na NIC acima do normal (rápido). Tendência: {err_rate_s:.3f}", - "acoes": [ - "Verificar cabo de rede e conectores", - "Checar colisões ou problemas físicos no link", - "Substituir cabo ou porta de switch se necessário" - ] - } - condicoes.append(c) - cond_nic.append(c) - motivos.append(f"Erros NIC altos (fast {err_rate_f:.3f}, score {ns_f:.0f}; slow {err_rate_s:.3f}, score {ns_s:.0f}).") - - saude_individual.append({ - "id": "ip_nic_errors", - "label": "Erros da interface de rede", - "status": self._status_por_score(ns).value, - "saude": round(ns, 1), - "motivos": [m for m in motivos if "Erros NIC altos" in m], - "condicoes_operacionais": cond_nic, - "em_uso": True, - }) - - # 5) Heartbeat - cond_hb = [] - if hbs_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - hbs_f))) - c = { - "label": "Atraso de heartbeat", - "valor": round(atraso_f, 2), - "severidade": severidade, - "descricao": f"Heartbeat atrasado (rápido). Tendência: {atraso_s:.1f}s", - "acoes": [ - "Verificar estado do serviço de telemetria no rover", - "Checar fila de mensagens MQTT/Redis", - "Garantir prioridade para mensagens de controle" - ] - } - condicoes.append(c) - cond_hb.append(c) - motivos.append(f"Heartbeat atrasado (fast {atraso_f:.1f}s, score {hbs_f:.0f}; slow {atraso_s:.1f}s, score {hbs_s:.0f}).") - - saude_individual.append({ - "id": "ip_heartbeat", - "label": "Heartbeat do rover", - "status": self._status_por_score(hbs).value, - "saude": round(hbs, 1), - "motivos": [m for m in motivos if "Heartbeat atrasado" in m], - "condicoes_operacionais": cond_hb, - "em_uso": True, - }) - - # 6) Banda / Saturação - cond_bw = [] - if bw_score_f < SAUDE_MIN_ALERTA: - severidade = severidade = min(89, int(max(0, 100 - bw_score_f))) - c = { - "label": "Uso de banda", - "valor": round(bw_util_pct_f, 1), - "severidade": severidade, - "descricao": f"Uso de banda alto (rápido: {bw_state_f}). Tendência: {bw_util_pct_s:.0f}% ({bw_state_s})", - "acoes": [ - "Reduzir FPS/qualidade/resolução do vídeo", - "Evitar envio de logs em rajada", - "Priorizar tráfego de controle" - ] - } - condicoes.append(c) - cond_bw.append(c) - motivos.append( - f"Banda alta (fast {bw_total_f:.2f} Mbps, {bw_util_pct_f:.0f}%, score {bw_score_f:.0f}; " - f"slow {bw_total_s:.2f} Mbps, {bw_util_pct_s:.0f}%, score {bw_score_s:.0f})." - ) - - saude_individual.append({ - "id": "ip_bandwidth", - "label": "Uso de banda", - "status": self._status_por_score(bw_score).value, # MIX - "saude": round(bw_score, 1), # MIX - "motivos": [m for m in motivos if "Banda alta" in m], - "condicoes_operacionais": cond_bw, - "em_uso": True, - }) - - - - # RISCO REAL - risk_score = ( - 0.32 * (100 - los) + - 0.24 * (100 - tmout) + - 0.22 * (100 - hbs) + - 0.10 * (100 - ls) + - 0.07 * (100 - js) + - 0.03 * (100 - bw_score) + - 0.02 * (100 - ns) - ) - risk_score = max(0.0, min(100.0, risk_score)) - - degradado_agora = ( - loss_pct_s >= 7 or - tmout_pct_s >= 6 or - atraso_s >= 5.0 or - (loss_pct_f >= 12 and loss_pct_s >= 4) or - (tmout_pct_f >= 15 and tmout_pct_s >= 4) or - (jitter_s >= 45 and loss_pct_s >= 4) - ) - - critico_agora = ( - loss_pct_s >= 15 or - tmout_pct_s >= 12 or - atraso_s >= 8.0 or - (loss_pct_f >= 20 and loss_pct_s >= 6) or - (tmout_pct_f >= 25 and tmout_pct_s >= 6) or - ((loss_pct_s >= 8) and (atraso_s >= 6.0)) - ) - - colapso_agora = ( - loss_pct_s >= 15 or - tmout_pct_s >= 12 or - atraso_s >= 8.0 or - not conectado - ) - - if colapso_agora: - self._cycles_critical += 2 - self._cycles_degraded += 1 - self._cycles_recovered = 0 - elif critico_agora: - self._cycles_critical += 1 - self._cycles_degraded += 1 - self._cycles_recovered = 0 - elif degradado_agora: - self._cycles_degraded += 1 - self._cycles_critical = max(0, self._cycles_critical - 1) - self._cycles_recovered = 0 - else: - self._cycles_recovered += 1 - self._cycles_degraded = max(0, self._cycles_degraded - 1) - self._cycles_critical = max(0, self._cycles_critical - 1) - - self._cycles_degraded = min(self._cycles_degraded, 30) - self._cycles_critical = min(self._cycles_critical, 30) - self._cycles_recovered = min(self._cycles_recovered, 30) - - prev_state = self.link_state - - if self._cycles_critical >= 10: - self.link_state = "CRITICAL" - elif self._cycles_degraded >= 14: - self.link_state = "DEGRADED" - elif self._cycles_recovered >= 24: - self.link_state = "OK" - - - # --------------------------------------------------------- - # MARCADORES DE DISTÂNCIA - # Só possuem validade quando o GNSS fornece posição confiável - # --------------------------------------------------------- - - if not regra_distancia_ativa: - # Sem posição confiável, não podemos associar a degradação - # do enlace a uma distância física. + if self.link_state == self.LINK_OK: + if previous_state != self.LINK_OK: self._distancia_inicio_degradado = None self._distancia_inicio_critico = None self._max_distancia_desde_degradado = 0.0 + self._max_distancia_ok = max(self._max_distancia_ok, distancia_base) - else: - # O enlace está degradado e temos uma distância válida. - if self.link_state == "DEGRADED": - # Inicializa também se o GNSS voltou enquanto o enlace - # já estava no estado DEGRADED. - if self._distancia_inicio_degradado is None: - self._distancia_inicio_degradado = distancia_base - self._max_distancia_desde_degradado = distancia_base + # ====================================================================== + # PAYLOAD / APRESENTAÇÃO + # ====================================================================== - self._max_distancia_desde_degradado = max( - self._max_distancia_desde_degradado, - distancia_base - ) + @staticmethod + def _status_por_score(score: float) -> StatusModulo: + if score >= 80: + return StatusModulo.OPERANTE + if score >= 50: + return StatusModulo.ALERTA + return StatusModulo.FALHA - # Registra onde o enlace entrou ou foi observado como crítico. - if self.link_state == "CRITICAL": - if self._distancia_inicio_critico is None: - self._distancia_inicio_critico = distancia_base + @staticmethod + def _safe_round(value, digits=1, fallback=-1.0): + if value is None: + return fallback + try: + if not math.isfinite(float(value)): + return fallback + return round(float(value), digits) + except Exception: + return fallback - # Ao recuperar o enlace, limpa os marcos da degradação anterior. - if self.link_state == "OK": - if prev_state != "OK": - self._distancia_inicio_degradado = None - self._distancia_inicio_critico = None - self._max_distancia_desde_degradado = 0.0 + def _append_individual( + self, + target, + item_id, + label, + score, + motivos, + condicoes, + em_uso=True, + ): + target.append( + { + "id": item_id, + "label": label, + "status": self._status_por_score(score).value, + "saude": round(score, 1), + "motivos": motivos, + "condicoes_operacionais": condicoes, + "em_uso": bool(em_uso), + } + ) - self._max_distancia_ok = max( - self._max_distancia_ok, - distancia_base - ) + def _build_messages( + self, + metrics, + heartbeat_snapshot, + evidence, + instant, + health_info, + down_confirmed, + regra_distancia_ativa, + distancia_base, + ): + motivos = [] + condicoes = [] + individuais = [] + # Latência + lat_motivos = [] + lat_cond = [] + if metrics["probe_ready"] and metrics["ls"] < 80: + text = ( + f"Latência ICMP elevada: fast " + f"{self._safe_round(metrics['avg_rtt_f'], 0)} ms; slow " + f"{self._safe_round(metrics['avg_rtt_s'], 0)} ms." + ) + motivos.append(text) + lat_motivos.append(text) + condition = { + "label": "Latência ICMP elevada", + "valor": self._safe_round(metrics["avg_rtt_f"], 1), + "severidade": min(89, int(100 - metrics["ls_f"])), + "descricao": ( + f"Latência rápida elevada. Tendência lenta: " + f"{self._safe_round(metrics['avg_rtt_s'], 1)} ms." + ), + "acoes": [ + "Reduzir tráfego não crítico se a tendência persistir", + "Verificar interferência, multipath e alinhamento das antenas", + "Correlacionar com o bitrate dos streams de vídeo", + ], + } + condicoes.append(condition) + lat_cond.append(condition) - # Condição 1: Link degradado persistente - if self.link_state == "DEGRADED": - if regra_distancia_ativa: - complemento_descricao = "Não aumentar distância da base." - acoes_degradacao = [ - "Bloquear avanço para longe da base", - "Reduzir vídeo/logs/telemetria não crítica", - "Permitir apenas manter posição ou retornar" - ] - else: - complemento_descricao = ( - "Posição GNSS indisponível. A degradação está sendo avaliada " - "somente pelas métricas reais do enlace." - ) - acoes_degradacao = [ - "Reduzir vídeo/logs/telemetria não crítica", - "Priorizar tráfego de controle e heartbeat", - "Verificar a estabilidade do enlace antes de continuar a operação" - ] + self._append_individual( + individuais, + "ip_latency", + "Latência ICMP", + metrics["ls"], + lat_motivos, + lat_cond, + em_uso=metrics["probe_sample_count"] > 0, + ) - condicoes.append({ + # Falhas ICMP. Mantém dois blocos por compatibilidade, mas deixa claro + # que representam a mesma família de sondagem e não têm peso duplicado. + loss_motivos = [] + loss_cond = [] + if metrics["probe_ready"] and metrics["los"] < 80: + text = ( + f"Falhas de sondagem ICMP: fast {metrics['loss_f']:.1f}%; " + f"slow {metrics['loss_s']:.1f}%." + ) + motivos.append(text) + loss_motivos.append(text) + condition = { + "label": "Falhas de sondagem ICMP", + "valor": round(metrics["loss_f"], 2), + "severidade": min(89, int(100 - metrics["los_f"])), + "descricao": ( + f"Perda rápida {metrics['loss_f']:.1f}% e tendência " + f"{metrics['loss_s']:.1f}%. Uma falha isolada não altera " + "o estado do enlace." + ), + "acoes": [ + "Correlacionar com sockets ativos e heartbeat", + "Reduzir vídeo se houver latência/jitter simultâneos", + "Verificar qualidade RF caso a perda seja persistente", + ], + } + condicoes.append(condition) + loss_cond.append(condition) + + self._append_individual( + individuais, + "ip_timeout", + "Timeout de sondagem", + metrics["tmout"], + loss_motivos, + loss_cond, + em_uso=metrics["probe_sample_count"] > 0, + ) + self._append_individual( + individuais, + "ip_loss", + "Perda de pacotes", + metrics["los"], + loss_motivos, + loss_cond, + em_uso=metrics["probe_sample_count"] > 0, + ) + + # Jitter + jitter_motivos = [] + jitter_cond = [] + if metrics["probe_ready"] and metrics["js"] < 80: + text = ( + f"Jitter elevado: fast {metrics['jitter_f']:.1f} ms; " + f"slow {metrics['jitter_s']:.1f} ms." + ) + motivos.append(text) + jitter_motivos.append(text) + condition = { + "label": "Jitter elevado", + "valor": round(metrics["jitter_f"], 2), + "severidade": min(89, int(100 - metrics["js_f"])), + "descricao": ( + f"Variação rápida {metrics['jitter_f']:.1f} ms; " + f"tendência {metrics['jitter_s']:.1f} ms." + ), + "acoes": [ + "Verificar fila criada pelos streams TCP", + "Priorizar comandos, RTCM e heartbeat", + "Reduzir bitrate/FPS se houver pressão corroborada", + ], + } + condicoes.append(condition) + jitter_cond.append(condition) + + self._append_individual( + individuais, + "ip_jitter", + "Jitter da conexão", + metrics["js"], + jitter_motivos, + jitter_cond, + em_uso=len(self.rtts_s) >= 4, + ) + + # NIC + nic_motivos = [] + nic_cond = [] + if metrics["ns"] < 80: + text = ( + f"Erros/descartes na NIC: fast {metrics['err_rate_f']:.3f}; " + f"slow {metrics['err_rate_s']:.3f} por mil pacotes." + ) + motivos.append(text) + nic_motivos.append(text) + condition = { + "label": "Erros na interface de rede", + "valor": round(metrics["err_rate_f"], 3), + "severidade": min(89, int(100 - metrics["ns_f"])), + "descricao": "A NIC registrou erros ou descartes acima do normal.", + "acoes": [ + "Verificar cabo e alimentação do bridge", + "Inspecionar porta Ethernet e conectores", + "Comparar com o contador do rádio HaLow", + ], + } + condicoes.append(condition) + nic_cond.append(condition) + + self._append_individual( + individuais, + "ip_nic_errors", + "Erros da interface de rede", + metrics["ns"], + nic_motivos, + nic_cond, + em_uso=True, + ) + + # MQTT / heartbeat: pode alertar o serviço, mas não declarar queda da rede. + hb_motivos = [] + hb_cond = [] + hb_age_display = self._heartbeat_age_for_display(heartbeat_snapshot) + mqtt_problem = ( + not heartbeat_snapshot["mqtt_connected"] + or metrics["hbs"] < 80 + ) + if mqtt_problem: + other_socket_text = ( + "Há sockets não-MQTT ativos para a base." + if evidence["non_mqtt_socket"] + else "Não há socket não-MQTT confirmado neste instante." + ) + text = ( + f"Serviço MQTT/heartbeat degradado: conectado=" + f"{heartbeat_snapshot['mqtt_connected']}, atraso=" + f"{hb_age_display:.1f}s. {other_socket_text}" + ) + motivos.append(text) + hb_motivos.append(text) + condition = { + "label": "Serviço MQTT/heartbeat degradado", + "valor": round(hb_age_display, 2), + "severidade": min(89, int(100 - metrics["hbs"])), + "descricao": ( + "O serviço MQTT ou o heartbeat apresentou atraso. Isso não " + "é tratado sozinho como falha física da ponte IP." + ), + "acoes": [ + "Verificar broker e publicador do heartbeat", + "Inspecionar filas e prioridade de mensagens", + "Correlacionar com sockets TCP e perda ICMP", + ], + } + condicoes.append(condition) + hb_cond.append(condition) + + self._append_individual( + individuais, + "ip_heartbeat", + "Heartbeat do rover", + metrics["hbs"], + hb_motivos, + hb_cond, + em_uso=True, + ) + + # Banda + bw_motivos = [] + bw_cond = [] + if instant["bandwidth_corroborated"]: + text = ( + f"Pressão de banda corroborada por sintomas: fast " + f"{metrics['bw_total_f']:.2f} Mbps; slow " + f"{metrics['bw_total_s']:.2f} Mbps." + ) + motivos.append(text) + bw_motivos.append(text) + condition = { + "label": "Pressão de banda no enlace", + "valor": round(metrics["bw_util_f"], 1), + "severidade": min(89, int(100 - metrics["bw_score_f"])), + "descricao": ( + f"Uso estimado {metrics['bw_util_s']:.0f}% acompanhado de " + "perda, latência, jitter ou atraso de heartbeat." + ), + "acoes": [ + "Reduzir bitrate/FPS dos streams", + "Priorizar RTCM, controle e heartbeat", + "Evitar rajadas de logs e imagens", + ], + } + condicoes.append(condition) + bw_cond.append(condition) + + self._append_individual( + individuais, + "ip_bandwidth", + "Uso de banda", + health_info["bw_effective_score"], + bw_motivos, + bw_cond, + em_uso=True, + ) + + # Estado persistente do enlace. + risk_score = round(100.0 - health_info["network_health"], 1) + + if self.link_state == self.LINK_DEGRADED: + complemento = ( + "Evitar ampliar a distância da base." + if regra_distancia_ativa + else "A posição GNSS não está confiável; a decisão usa apenas rede." + ) + condicoes.append( + { "label": "Enlace degradado persistente", - "valor": round(risk_score, 1), + "valor": risk_score, "severidade": 75, "descricao": ( - f"Rede degradando de forma persistente. " - f"Loss slow {loss_pct_s:.1f}%, timeout slow {tmout_pct_s:.1f}%, " - f"heartbeat {atraso_s:.1f}s. " - f"{complemento_descricao}" + f"Degradação persistente confirmada por tempo real. " + f"Loss slow {metrics['loss_s']:.1f}%, RTT slow " + f"{self._safe_round(metrics['avg_rtt_s'], 1)} ms, jitter " + f"{metrics['jitter_s']:.1f} ms. {complemento}" ), - "acoes": acoes_degradacao - }) - # Condição 2: Cerca invisível de rede violada - dist_violando_cerca = ( - regra_distancia_ativa - and self.link_state == "DEGRADED" - and self._cycles_degraded >= 15 - and risk_score >= 70 - and self._distancia_inicio_degradado is not None - and distancia_base is not None - and distancia_base > self._distancia_inicio_degradado + 5.0 + "acoes": [ + "Reduzir tráfego não crítico", + "Priorizar controle, RTCM e heartbeat", + "Permitir retorno ou manutenção de posição", + ], + } ) - if dist_violando_cerca: - condicoes.append({ + + if self.link_state == self.LINK_CRITICAL: + condicoes.append( + { + "label": "Risco crítico de perda de comunicação", + "valor": risk_score, + "severidade": 96, + "descricao": ( + "Múltiplas evidências ou falhas persistentes confirmaram " + "estado crítico do enlace." + ), + "acoes": [ + "Parar avanço", + "Executar retorno seguro", + "Manter apenas tráfego essencial", + ], + } + ) + + if down_confirmed: + condicoes.append( + { + "label": "Queda do enlace confirmada", + "valor": risk_score, + "severidade": 99, + "descricao": ( + "A rota/NIC está indisponível ou todas as evidências remotas " + "sumiram junto com falhas consecutivas de sondagem." + ), + "acoes": [ + "Executar fail-safe local", + "Interromper avanço imediatamente", + "Tentar restabelecer o enlace sem depender do MQTT", + ], + } + ) + + dist_violando_cerca = ( + regra_distancia_ativa + and self.link_state == self.LINK_DEGRADED + and self._distancia_inicio_degradado is not None + and distancia_base is not None + and distancia_base > self._distancia_inicio_degradado + 5.0 + ) + if dist_violando_cerca: + condicoes.append( + { "label": "Avanço além da margem segura de rede", "valor": round(distancia_base, 2), "severidade": 92, "descricao": ( - f"O rover continuou se afastando após a rede entrar em degradação persistente. " - f"Distância atual {distancia_base:.1f} m." + "O rover continuou se afastando após degradação persistente." ), "acoes": [ - "Parar avanço imediatamente", + "Bloquear novo avanço", "Retornar em direção à base", - "Manter apenas tráfego essencial" - ] - }) - #Condição 3: Link crítico persistente - if self.link_state == "CRITICAL": - condicoes.append({ - "label": "Risco crítico de perda de comunicação", - "valor": round(risk_score, 1), - "severidade": 96, - "descricao": ( - f"Enlace crítico e persistente. " - f"Loss slow {loss_pct_s:.1f}%, timeout slow {tmout_pct_s:.1f}%, " - f"heartbeat {atraso_s:.1f}s." - ), - "acoes": [ - "Parar operação", - "Iniciar retorno seguro", - "Priorizar mensagens de controle e heartbeat" - ] - }) - #Condição 4: Colapso iminente - if colapso_agora and self._cycles_critical >= 1: - condicoes.append({ - "label": "Colapso iminente do enlace", - "valor": round(risk_score, 1), - "severidade": 99, - "descricao": ( - f"Perda iminente de comunicação. " - f"Loss slow {loss_pct_s:.1f}%, timeout slow {tmout_pct_s:.1f}%, " - f"heartbeat {atraso_s:.1f}s." - ), - "acoes": [ - "Parada imediata", - "Abortar avanço", - "Executar fail-safe de retorno" - ] - }) - - - - - - # -------- STATUS GERAL DO MÓDULO -------- - if not conectado: - status = StatusModulo.DESCONECTADO - elif self.link_state == "CRITICAL": - status = StatusModulo.FALHA - elif self.link_state == "DEGRADED" or saude < SAUDE_MIN_ALERTA: - status = StatusModulo.ALERTA - else: - status = StatusModulo.OPERANTE - - payload = { - "conectado": conectado, - "status": status.value, - "saude": saude, - "motivos": motivos, - "saude_individual": saude_individual, - "condicoes_operacionais": condicoes, - "detalhes": { - "ls": ls, - "tmout": tmout, - "los": los, - "js": js, - "ns": ns, - "hbs": hbs, - "avg_rtt": avg_rtt, - "tmout_pct": tmout_pct, - "loss_pct": loss_pct, - "jitter": jitter, - "err_rate": err_rate, - "atraso_hb": atraso, - "rx_mbps": rx_mbps, - "tx_mbps": tx_mbps, - "bw_total_mbps": bw_total, - "bw_score": bw_score, - "bw_util_pct": bw_util_pct, - "bw_state": bw_state, - "bw_max_mbps": self.bw_max_mbps, - "bw_safe_mbps": self.bw_safe_mbps, - "bw_hard_mbps": self.bw_hard_mbps, - "bw_pressure_pct": bw_pressure_pct, - - "link_state": self.link_state, - "risk_score": round(risk_score, 1), - "cycles_degraded": self._cycles_degraded, - "cycles_critical": self._cycles_critical, - "cycles_recovered": self._cycles_recovered, - "pode_avancar": self.link_state == "OK", - "deve_conter_avanco": self.link_state == "DEGRADED", - "deve_parar_por_rede": self.link_state == "CRITICAL", - "distancia_inicio_degradado": self._distancia_inicio_degradado, - "distancia_inicio_critico": self._distancia_inicio_critico, - "max_distancia_ok": self._max_distancia_ok, - - "status_gnss": status_gnss, - "posicao_confiavel": posicao_confiavel, - "regra_distancia_ativa": regra_distancia_ativa, - - "d_base": distancia_base if regra_distancia_ativa else -1.0, - "d_base_bruta": distancia_base_bruta, - - "perfil_limite_banda": ( - "adaptativo_por_distancia" - if regra_distancia_ativa - else "nominal_sem_posicao" - ), + "Reduzir vídeo e logs", + ], } - } - - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - saude=payload ) - except Exception as e: - print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + return motivos, condicoes, individuais, risk_score + # ====================================================================== + # ATUALIZAÇÃO PRINCIPAL + # ====================================================================== + + def atualizar_saude_interno(self): + now_mono = time.monotonic() + + self._refresh_route() + self._ensure_mqtt_client() + + # Coleta assíncrona e rate-limited. + self._consume_ping_result_if_ready() + self._start_ping_async_if_needed() + ping_snapshot = self._get_ping_snapshot() + new_ping_sample = self._apply_new_ping_sample(ping_snapshot) + + self._update_nic_metrics_if_due() + self._update_socket_snapshot_if_due() + + # Distância e GNSS entram apenas no perfil estimado de banda. O estado + # do enlace continua baseado nas métricas reais e corroboradas. + module_data = ContextoGlobalRedis.get_modulo(self.t_code) or {} + distancia_base_bruta = module_data.get("distancia_base") + distancia_base = self._normalizar_distancia(distancia_base_bruta) + posicao_confiavel, status_gnss = self._posicao_confiavel(distancia_base) + regra_distancia_ativa = posicao_confiavel and distancia_base is not None + self._ajustar_limites_banda_por_distancia( + distancia_base if regra_distancia_ativa else None + ) + + heartbeat_snapshot = self._heartbeat_snapshot(now_mono) + metrics = self._calculate_metrics(heartbeat_snapshot) + evidence = self._reachability_evidence(now_mono, heartbeat_snapshot) + instant = self._evaluate_instant_conditions( + metrics, + evidence, + heartbeat_snapshot, + ) + + self._update_persistence_counters(new_ping_sample, instant) + previous_state, down_confirmed = self._update_link_state(now_mono, instant) + + health_info = self._calculate_health( + metrics, + evidence, + heartbeat_snapshot, + instant, + ) + bw_pressure_pct = self._bandwidth_pressure_pct( + metrics, + heartbeat_snapshot, + instant, + ) + + self._update_distance_markers( + previous_state, + regra_distancia_ativa, + distancia_base, + ) + + # Enquanto a rota local está de pé e a queda remota não foi confirmada, + # não marcamos desconectado por causa exclusiva do MQTT. + conectado = bool(evidence["route_ok"] and not down_confirmed) + + if not conectado: + status = StatusModulo.DESCONECTADO + elif self.link_state == self.LINK_CRITICAL: + status = StatusModulo.FALHA + elif self.link_state == self.LINK_DEGRADED or health_info["health"] < 80: + status = StatusModulo.ALERTA + else: + status = StatusModulo.OPERANTE + + motivos, condicoes, saude_individual, risk_score = self._build_messages( + metrics, + heartbeat_snapshot, + evidence, + instant, + health_info, + down_confirmed, + regra_distancia_ativa, + distancia_base, + ) + + degraded_for_s = self._elapsed_since(now_mono, self._degraded_since_mono) + critical_for_s = self._elapsed_since(now_mono, self._critical_since_mono) + healthy_for_s = self._elapsed_since(now_mono, self._healthy_since_mono) + down_for_s = self._elapsed_since(now_mono, self._down_since_mono) + + payload = { + "conectado": conectado, + "status": status.value, + "saude": health_info["health"], + "motivos": motivos, + "saude_individual": saude_individual, + "condicoes_operacionais": condicoes, + "detalhes": { + # Chaves antigas preservadas. + "ls": round(metrics["ls"], 1), + "tmout": round(metrics["tmout"], 1), + "los": round(metrics["los"], 1), + "js": round(metrics["js"], 1), + "ns": round(metrics["ns"], 1), + "hbs": round(metrics["hbs"], 1), + "avg_rtt": self._safe_round(metrics["avg_rtt_s"], 2), + "tmout_pct": round(metrics["timeout_pct_s"], 2), + "loss_pct": round(metrics["loss_s"], 2), + "jitter": round(metrics["jitter_s"], 2), + "err_rate": round(metrics["err_rate_s"], 4), + "atraso_hb": round( + self._heartbeat_age_for_display(heartbeat_snapshot), + 2, + ), + "rx_mbps": round(metrics["rx_s"], 3), + "tx_mbps": round(metrics["tx_s"], 3), + "bw_total_mbps": round(metrics["bw_total_s"], 3), + "bw_score": health_info["bw_effective_score"], + "bw_util_pct": round(metrics["bw_util_s"], 1), + "bw_state": metrics["bw_state_s"], + "bw_max_mbps": self.bw_max_mbps, + "bw_safe_mbps": self.bw_safe_mbps, + "bw_hard_mbps": self.bw_hard_mbps, + "bw_pressure_pct": bw_pressure_pct, + + "link_state": self.link_state, + "risk_score": risk_score, + "cycles_degraded": self._cycles_degraded, + "cycles_critical": self._cycles_critical, + "cycles_recovered": self._cycles_recovered, + "pode_avancar": conectado and self.link_state == self.LINK_OK, + "deve_conter_avanco": conectado and self.link_state == self.LINK_DEGRADED, + "deve_parar_por_rede": ( + not conectado or self.link_state == self.LINK_CRITICAL + ), + "distancia_inicio_degradado": self._distancia_inicio_degradado, + "distancia_inicio_critico": self._distancia_inicio_critico, + "max_distancia_ok": self._max_distancia_ok, + + "status_gnss": status_gnss, + "posicao_confiavel": posicao_confiavel, + "regra_distancia_ativa": regra_distancia_ativa, + "d_base": distancia_base if regra_distancia_ativa else -1.0, + "d_base_bruta": distancia_base_bruta, + "perfil_limite_banda": ( + "estimado_por_distancia_com_corroboração" + if regra_distancia_ativa + else "nominal_sem_posicao" + ), + + # Novas métricas de diagnóstico. + "network_health": health_info["network_health"], + "service_penalty": health_info["service_penalty"], + "reachability_score": health_info["reachability_score"], + "probe_ready": metrics["probe_ready"], + "probe_sample_count": metrics["probe_sample_count"], + "ping_loss_fast_pct": round(metrics["loss_f"], 2), + "ping_loss_slow_pct": round(metrics["loss_s"], 2), + "ping_rtt_fast_ms": self._safe_round(metrics["avg_rtt_f"], 2), + "ping_rtt_slow_ms": self._safe_round(metrics["avg_rtt_s"], 2), + "ping_jitter_fast_ms": round(metrics["jitter_f"], 2), + "ping_jitter_slow_ms": round(metrics["jitter_s"], 2), + "consecutive_ping_failures": self._consecutive_ping_failures, + "consecutive_ping_successes": self._consecutive_ping_successes, + "last_ping_success_age_s": self._safe_round( + evidence["ping_success_age_s"], + 2, + ), + "nic_name": self.nic_name, + "nic_up": evidence["nic_up"], + "route_ok": evidence["route_ok"], + "nic_activity_recent": evidence["nic_activity_recent"], + "nic_activity_age_s": self._safe_round( + evidence["nic_activity_age_s"], + 2, + ), + "tcp_established_total": self._tcp_established_total, + "tcp_established_non_mqtt": self._tcp_established_non_mqtt, + "udp_remote_total": self._udp_remote_total, + "socket_probe_supported": self._socket_probe_supported, + "mqtt_connected": heartbeat_snapshot["mqtt_connected"], + "mqtt_connecting": heartbeat_snapshot["mqtt_connecting"], + "mqtt_subscribed": heartbeat_snapshot["subscribed"], + "heartbeat_ok": heartbeat_snapshot["heartbeat_ok"], + "heartbeat_in_grace": heartbeat_snapshot[ + "heartbeat_in_grace" + ], + "heartbeat_last_wall": heartbeat_snapshot[ + "heartbeat_last_wall" + ], + "degraded_now": instant["degraded_now"], + "critical_now": instant["critical_now"], + "down_candidate": instant["down_candidate"], + "down_confirmed": down_confirmed, + "bandwidth_corroborated": instant[ + "bandwidth_corroborated" + ], + "degraded_for_s": round(degraded_for_s, 2), + "critical_for_s": round(critical_for_s, 2), + "healthy_for_s": round(healthy_for_s, 2), + "down_for_s": round(down_for_s, 2), + "assessment_confidence_pct": round( + min(100.0, (metrics["probe_sample_count"] / 20.0) * 100.0), + 1, + ), + }, + } + + ContextoGlobalRedis.atualizar_ctx_dict( + ContextoGlobalRedis.ModKey(self.t_code), + saude=payload, + ) diff --git a/AgroBase/OperationControl/Models/AppShell.cs b/AgroBase/OperationControl/Models/AppShell.cs index 6753624c8..f060ae5de 100644 --- a/AgroBase/OperationControl/Models/AppShell.cs +++ b/AgroBase/OperationControl/Models/AppShell.cs @@ -1,62 +1,443 @@ -using AgroBase.Models; +using AgroBase.Models; +using AgroBase.Models.Operacoes; using AgroBase.Services; using OperationControl.Windows; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using static AgroBase.Models.Enums; namespace OperationControl.Models { + /// + /// Construtor/lifecycle principal da aplicação OperationControl. + /// + /// Regras: + /// - MQTT é inicializado de forma aguardável antes dos loops; + /// - heartbeat, posição da base e supervisão dos rovers usam timers separados; + /// - não usa System.Timers.Timer com callbacks async ignorados; + /// - inicialização é idempotente; + /// - shutdown cancela timers, MQTT e UDP de forma ordenada; + /// - mock é aguardável e isolado. + /// public class AppShell { public static readonly bool Mock = false; public static readonly bool AddRover = false; - private readonly System.Timers.Timer tmrComunicacao = new(1000) { AutoReset = true }; - private int tmrComunicacaoTicks = 0; - private bool tmrComunicacaoTicking = false; + private readonly SemaphoreSlim _lifecycleLock = + new SemaphoreSlim(1, 1); + + private CancellationTokenSource _appCts; + + private AsyncTaskTimerModel _tmrHeartbeatBase; + private AsyncTaskTimerModel _tmrAtualizaRovers; + private AsyncTaskTimerModel _tmrPosicaoBase; + private AsyncTaskTimerModel _tmrMockUi; + + private Task _mockTask; + + private bool _initialized; + private bool _closing; public MainWindow Main { get; private set; } public DockWindow Dock { get; private set; } + /// + /// Compatibilidade com o contrato antigo. + /// Para startup determinístico, prefira: + /// await InicializarAsync(); + /// public void Inicializar() { - Show(); + Forget( + InicializarAsync(), + "Inicializar AppShell" + ); + } - APIService.IniciarRotinas(); - Variaveis.IniciarMQTT(); - Variaveis.IniciarUDP(); + public async Task InicializarAsync( + CancellationToken cancellationToken = + default(CancellationToken)) + { + await _lifecycleLock + .WaitAsync(cancellationToken) + .ConfigureAwait(false); - tmrComunicacao.Elapsed += (_, __) => tmrComunicacao_Elapsed(); - tmrComunicacao.Start(); + try + { + if (_initialized) + return; + + _closing = false; + + _appCts = CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken + ); + + Show(); + + APIService.IniciarRotinas(); + + /* + * O MQTT novo precisa estar pronto antes dos timers. + * Isso garante que tópicos, clientes e publisher RTCM + * existam antes do heartbeat e da posição da base. + */ + await Variaveis + .IniciarMqttAsync(_appCts.Token) + .ConfigureAwait(false); + + Variaveis.IniciarUDP(); + + CriarTimers(); + IniciarTimers(); + + if (Mock && AddRover) + { + _mockTask = Task.Run( + () => ConfigurarMockAsync( + _appCts.Token + ), + _appCts.Token + ); + } + + _initialized = true; + } + catch + { + await EncerrarInternoAsync() + .ConfigureAwait(false); + + throw; + } + finally + { + _lifecycleLock.Release(); + } } private void Show() { - // Aqui você decide qual janela é a inicial de fato - Main = new MainWindow(); - Dock = new DockWindow(); + if (Main == null) + Main = new MainWindow(); + + if (Dock == null) + Dock = new DockWindow(); + } + + private void CriarTimers() + { + int intervaloPosicaoMs = + Math.Max( + 1000, + VariaveisEquipamento + .TempoEntrePingsConexao + ); + + _tmrHeartbeatBase = + new AsyncTaskTimerModel( + "base.heartbeat", + async cancellationToken => + { + await VariaveisControleOperacao + .EnviarDadosHeartbeat() + .ConfigureAwait(false); + }, + interval: 1000, + timeout: 3000, + scheduleMode: + AsyncTaskTimerScheduleMode + .FixedRateSkipMissed + ); + + _tmrAtualizaRovers = + new AsyncTaskTimerModel( + "base.rovers.watchdog", + async cancellationToken => + { + await VariaveisControleOperacao + .AtualizarListaRoversNaRede() + .ConfigureAwait(false); + }, + interval: 1000, + timeout: 5000, + scheduleMode: + AsyncTaskTimerScheduleMode + .FixedRateSkipMissed + ); + + _tmrPosicaoBase = + new AsyncTaskTimerModel( + "base.gnss.position.publisher", + async cancellationToken => + { + await PublicarPosicaoBaseAsync( + cancellationToken + ).ConfigureAwait(false); + }, + interval: intervaloPosicaoMs, + timeout: 3000, + scheduleMode: + AsyncTaskTimerScheduleMode + .FixedRateSkipMissed + ); if (Mock && AddRover) { - Task.Run(async () => - { - await Task.Delay(1000); - VariaveisControleOperacao.AdicionarNovoRoverNaRede("01", "192.168.1.100"); - var r = VariaveisControleOperacao.RoversNaRede.FirstOrDefault(); - r.Modo = AgroBase.Models.Enums.ModoOperacao.MapaGPS; - r.Descricao = "Operacao Mockada"; - r.DadosLeitura = new AgroBase.Models.Operacoes.OperacaoParametrosDadosModel() - { - StatusRover = StatusModulo.Operante, - Operacao = new AgroBase.Models.Operacoes.OperacaoParametrosDadosOperacaoModel() + _tmrMockUi = + new AsyncTaskTimerModel( + "base.mock.ui", + async cancellationToken => { - Modo = AgroBase.Models.Enums.ModoOperacao.MapaGPS, - Status = StatusOperacao.Parametrizando, + await AtualizarMockUiAsync() + .ConfigureAwait(false); }, - Atuador = new AgroBase.Models.Operacoes.OperacaoParametrosDadosAtuadorModel() - { - Bombas = new List() + interval: 1000, + timeout: 3000, + scheduleMode: + AsyncTaskTimerScheduleMode + .FixedRateSkipMissed + ); + } + } + + private void IniciarTimers() + { + _tmrHeartbeatBase?.Start(); + _tmrAtualizaRovers?.Start(); + _tmrPosicaoBase?.Start(); + _tmrMockUi?.Start(); + } + + private async Task PublicarPosicaoBaseAsync( + CancellationToken cancellationToken) + { + if (_closing || + Variaveis.GpsService == null) + { + return; + } + + GPSModel leitura = + ObterSnapshotGpsBase(); + + if (leitura == null) + return; + + await VariaveisControleOperacao + .EnviarDadosPosicaoAsync( + leitura, + cancellationToken + ) + .ConfigureAwait(false); + } + + private GPSModel ObterSnapshotGpsBase() + { + try + { + GPSModel leitura = + Variaveis + .GpsService? + .UltimaLeitura; + + if (leitura == null) + return null; + + /* + * GPSModel no projeto já é usado com Clone() em outros + * serviços. O clone evita serializar um objeto mutável + * enquanto a serial GNSS o atualiza. + */ + return leitura.Clone(); + } + catch + { + return Variaveis + .GpsService? + .UltimaLeitura; + } + } + + public async Task EncerrarAsync() + { + await _lifecycleLock + .WaitAsync() + .ConfigureAwait(false); + + try + { + if (!_initialized && !_closing) + return; + + await EncerrarInternoAsync() + .ConfigureAwait(false); + } + finally + { + _lifecycleLock.Release(); + } + } + + private async Task EncerrarInternoAsync() + { + _closing = true; + + try + { + _appCts?.Cancel(); + } + catch { } + + await PararTimerAsync(_tmrMockUi) + .ConfigureAwait(false); + + await PararTimerAsync(_tmrPosicaoBase) + .ConfigureAwait(false); + + await PararTimerAsync(_tmrAtualizaRovers) + .ConfigureAwait(false); + + await PararTimerAsync(_tmrHeartbeatBase) + .ConfigureAwait(false); + + _tmrMockUi = null; + _tmrPosicaoBase = null; + _tmrAtualizaRovers = null; + _tmrHeartbeatBase = null; + + if (_mockTask != null) + { + try + { + await _mockTask + .ConfigureAwait(false); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Log( + "Erro ao encerrar mock: " + + ex.Message + ); + } + + _mockTask = null; + } + + try + { + await Variaveis + .EncerrarMqttAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Log( + "Erro ao encerrar MQTT da base: " + + ex.Message + ); + } + + try + { + Variaveis.StopUdpChannel(); + } + catch (Exception ex) + { + Log( + "Erro ao encerrar UDP da base: " + + ex.Message + ); + } + + _appCts?.Dispose(); + _appCts = null; + + _initialized = false; + _closing = false; + } + + private static async Task PararTimerAsync( + AsyncTaskTimerModel timer) + { + if (timer == null) + return; + + try + { + await timer.DisposeAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + Log( + "Erro ao parar timer " + + timer.Id + ": " + ex.Message + ); + } + } + + private async Task ConfigurarMockAsync( + CancellationToken cancellationToken) + { + await Task.Delay( + 1000, + cancellationToken + ).ConfigureAwait(false); + + await VariaveisControleOperacao + .AdicionarNovoRoverNaRede( + "01", + "192.168.1.100" + ) + .ConfigureAwait(false); + + OperacaoParametrosModel rover = + VariaveisControleOperacao + .GetRoversSnapshot() + .FirstOrDefault(); + + if (rover == null) + return; + + rover.Modo = ModoOperacao.MapaGPS; + rover.Descricao = "Operacao Mockada"; + rover.Alive = true; + rover.UltimoContato = DateTime.Now; + + rover.DadosLeitura = CriarDadosMock(); + rover.Controle = CriarControleMock(); + + await AtualizarMockUiAsync() + .ConfigureAwait(false); + } + + private static OperacaoParametrosDadosModel CriarDadosMock() + { + return new OperacaoParametrosDadosModel + { + StatusRover = StatusModulo.Operante, + + Operacao = + new OperacaoParametrosDadosOperacaoModel + { + Modo = ModoOperacao.MapaGPS, + Status = StatusOperacao.Parametrizando + }, + + Atuador = + new OperacaoParametrosDadosAtuadorModel + { + Bombas = + new List { - new AgroBase.Models.Operacoes.OperacaoParametrosDadosBombaModel() + new OperacaoParametrosDadosBombaModel { ComandoEstado = true, LeituraEstado = true, @@ -67,9 +448,9 @@ namespace OperationControl.Models LeituraPotencia = 50, TempoAtuado = 3000, ComandoPressao = 85, - LeituraPressao = 80, + LeituraPressao = 80 }, - new AgroBase.Models.Operacoes.OperacaoParametrosDadosBombaModel() + new OperacaoParametrosDadosBombaModel { ID = "BMBAGT", ID_Num = 11, @@ -78,185 +459,245 @@ namespace OperationControl.Models Inicializado = true, ComandoPotencia = 80, LeituraPotencia = 0, - TempoAtuado = 100, + TempoAtuado = 100 } }, - Bicos = new List() + + Bicos = + new List { - new AgroBase.Models.Operacoes.OperacaoParametrosDadosBicoModel() - { - AnguloAbertura = 30, - ComandoAngulo = 0, - ComandoEstado = true, - ID = "B01", - ID_Num = 1, - Inicializado = true, - LeituraAngulo = 0, - LeituraEstado = true, - Posicao = 1, - QtdAtuacoes = 5, - TempoAtuado = 300, - VazaoInstantaneaMLs = 15, - VazaoMediaMLs = 30, - VolumeVazadoML = 325 - }, - new AgroBase.Models.Operacoes.OperacaoParametrosDadosBicoModel() - { - AnguloAbertura = 30, - ComandoAngulo = 0, - ComandoEstado = true, - ID = "B02", - ID_Num = 2, - Inicializado = true, - LeituraAngulo = 0, - LeituraEstado = false, - Posicao = 2, - QtdAtuacoes = 5, - TempoAtuado = 300, - VazaoInstantaneaMLs = 15, - VazaoMediaMLs = 30, - VolumeVazadoML = 325 - }, - new AgroBase.Models.Operacoes.OperacaoParametrosDadosBicoModel() - { - AnguloAbertura = 30, - ComandoAngulo = 0, - ComandoEstado = false, - ID = "B03", - ID_Num = 3, - Inicializado = true, - LeituraAngulo = 0, - LeituraEstado = true, - Posicao = 3, - QtdAtuacoes = 5, - TempoAtuado = 300, - VazaoInstantaneaMLs = 15, - VazaoMediaMLs = 30, - VolumeVazadoML = 325 - }, + CriarBicoMock("B01", 1, true, true), + CriarBicoMock("B02", 2, true, false), + CriarBicoMock("B03", 3, false, true) }, - CapacidadeReservatorio = 60, - DistanciaEstimadaRestanteMetros = 254.5, - ErvasNoRadar = true, - HerbicidaConsumidoMl = 1541.32, - HerbicidaPorAtuacaoMl = 235.1, - Iniciado = true, - LPorMetro = 0.36, - LPorMinuto = 1.2, - MassaReservatorioKg = 25, - PercentualErvasNoRadar = 3.4, - PercentualErvasTerreno = 36.7, - PercentualReservatorio = 47, - PressaoLinhaPsi = 84.3, - QtdCamerasSolo = 1, - TempoEstimadoRestanteMinutos = 60, - VazaoInstantaneaMLs = 19.3, - VazaoMediaMLs = 15.8, - VolumeReservatorioL = 26, - VolumeVazaoMl = 1.39, - }, - Controle = new AgroBase.Models.Operacoes.OperacaoParametrosDadosControleModel() - { - AlturaBarra = 50, - Angulo = 0, - PercentualVelocidadeSPKmh = 0, - TipoMovimento = TipoMovimentoDirecional.RodasDianteiras, - }, - Gnss = new AgroBase.Models.Operacoes.OperacaoParametrosDadosGNSSModel() - { - Latitude = -22.17254631649402, - Longitude = -47.395203906184044, - OrientacaoReal = 187 - }, - ModulosSaude = new List() - { - new AgroBase.Models.Operadores.ManagerWorkerMessageResponseModulosPendentesModel() - { - modulo = T_Code.Gps, - saude = 0, - status = StatusModulo.Desconectado, - }, - new AgroBase.Models.Operadores.ManagerWorkerMessageResponseModulosPendentesModel() - { - modulo = T_Code.Can, - saude = 70, - status = StatusModulo.Alerta, - }, - } - }; - r.Controle = new AgroBase.Models.Operacoes.OperacaoParametrosControleModel() + + CapacidadeReservatorio = 60, + DistanciaEstimadaRestanteMetros = 254.5, + ErvasNoRadar = true, + HerbicidaConsumidoMl = 1541.32, + HerbicidaPorAtuacaoMl = 235.1, + Iniciado = true, + LPorMetro = 0.36, + LPorMinuto = 1.2, + MassaReservatorioKg = 25, + PercentualErvasNoRadar = 3.4, + PercentualErvasTerreno = 36.7, + PercentualReservatorio = 47, + PressaoLinhaPsi = 84.3, + QtdCamerasSolo = 1, + TempoEstimadoRestanteMinutos = 60, + VazaoInstantaneaMLs = 19.3, + VazaoMediaMLs = 15.8, + VolumeReservatorioL = 26, + VolumeVazaoMl = 1.39 + }, + + Controle = + new OperacaoParametrosDadosControleModel { - AtuAgitadorModo = ModoAgitadorCalda.Continuo, - AtuAlturaAreaPulverizacao = 10, - AtuDuracaoAtuacao = 200, - AtuPercentualErvasBicoOff = 1, - AtuPercentualErvasBicoOn = 2, - AtuPercentualInicioPulverizacao = 70, - AtuPressaoLinha = 80, - DirAnguloMaximo = 30, - DirecionalAutomatico = true, - MovimentoAutomatico = true, - PulverizadorAutomatico = true, - DirTipoMovimento = TiposControladorDirecional.MPC, - DirVelocidadeMovimento = 40, - FrenagemAutomaticaAoParar = false, - ImuParadaPorInclinacao = false, - MovVelocidadeCErvasPercent = 20, - MovVelocidadeSErvasPercent = 50, - MpcHorizonte = 4, - DirAuxilioSonar = false, - MovAuxilioSonar = false, - OakParadaPorObstaculo = false, - RegistrarDadosPosProcessamento = false, - AnteciparManobraCorredorM = 10 - }; - }); - } + AlturaBarra = 50, + Angulo = 0, + PercentualVelocidadeSPKmh = 0, + TipoMovimento = + TipoMovimentoDirecional + .RodasDianteiras + }, + + Gnss = + new OperacaoParametrosDadosGNSSModel + { + Latitude = -22.17254631649402, + Longitude = -47.395203906184044, + OrientacaoReal = 187 + }, + + ModulosSaude = + new List< + AgroBase.Models.Operadores + .ManagerWorkerMessageResponseModulosPendentesModel> + { + new AgroBase.Models.Operadores + .ManagerWorkerMessageResponseModulosPendentesModel + { + modulo = T_Code.Gps, + saude = 0, + status = StatusModulo.Desconectado + }, + new AgroBase.Models.Operadores + .ManagerWorkerMessageResponseModulosPendentesModel + { + modulo = T_Code.Can, + saude = 70, + status = StatusModulo.Alerta + } + } + }; } - private void tmrComunicacao_Elapsed() + private static OperacaoParametrosDadosBicoModel + CriarBicoMock( + string id, + int posicao, + bool comando, + bool leitura) + { + return new OperacaoParametrosDadosBicoModel + { + AnguloAbertura = 30, + ComandoAngulo = 0, + ComandoEstado = comando, + ID = id, + ID_Num = posicao, + Inicializado = true, + LeituraAngulo = 0, + LeituraEstado = leitura, + Posicao = posicao, + QtdAtuacoes = 5, + TempoAtuado = 300, + VazaoInstantaneaMLs = 15, + VazaoMediaMLs = 30, + VolumeVazadoML = 325 + }; + } + + private static OperacaoParametrosControleModel + CriarControleMock() + { + return new OperacaoParametrosControleModel + { + AtuAgitadorModo = ModoAgitadorCalda.Continuo, + AtuAlturaAreaPulverizacao = 10, + AtuDuracaoAtuacao = 200, + AtuPercentualErvasBicoOff = 1, + AtuPercentualErvasBicoOn = 2, + AtuPercentualInicioPulverizacao = 70, + AtuPressaoLinha = 80, + DirAnguloMaximo = 30, + DirecionalAutomatico = true, + MovimentoAutomatico = true, + PulverizadorAutomatico = true, + DirTipoMovimento = + TiposControladorDirecional.MPC, + DirVelocidadeMovimento = 40, + FrenagemAutomaticaAoParar = false, + ImuParadaPorInclinacao = false, + MovVelocidadeCErvasPercent = 20, + MovVelocidadeSErvasPercent = 50, + MpcHorizonte = 4, + DirAuxilioSonar = false, + MovAuxilioSonar = false, + OakParadaPorObstaculo = false, + RegistrarDadosPosProcessamento = false, + AnteciparManobraCorredorM = 10 + }; + } + + private static Task AtualizarMockUiAsync() + { + System.Windows.Application + .Current? + .Dispatcher? + .BeginInvoke( + new Action(() => + { + Variaveis.Dock? + ._vm? + .AtualizarDadosTela( + VariaveisControleOperacao + .RoverEmFoco + ); + }) + ); + + return Task.CompletedTask; + } + + public void AdicionarAlertaDock( + string roverId, + T_Code modulo, + SeveridadeAlerta severidade, + string mensagem, + string modId = null) + { + System.Windows.Application + .Current + .Dispatcher + .BeginInvoke( + new Action(() => + { + Dock?._vm?.AdicionarAlerta( + roverId, + modulo, + severidade, + mensagem, + modId + ); + }) + ); + } + + public void RemoverAlertaDock( + string roverId, + T_Code modulo, + SeveridadeAlerta? severidade = null, + string modId = null) + { + System.Windows.Application + .Current + .Dispatcher + .BeginInvoke( + new Action(() => + { + Dock?._vm?.RemoverAlerta( + roverId, + modulo, + severidade, + modId + ); + }) + ); + } + + private static void Forget( + Task task, + string context) + { + if (task == null) + return; + + _ = task.ContinueWith( + completed => + { + Exception ex = + completed.Exception? + .GetBaseException(); + + if (ex != null) + { + Log( + context + ": " + ex.Message + ); + } + }, + CancellationToken.None, + TaskContinuationOptions + .OnlyOnFaulted | + TaskContinuationOptions + .ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private static void Log(string message) { - if (tmrComunicacaoTicking) return; - tmrComunicacaoTicking = true; try { - if (tmrComunicacaoTicks % (VariaveisEquipamento.TempoEntrePingsConexao / 1000) == 0 && Variaveis.GpsService != null) - { - VariaveisControleOperacao.EnviarDadosPosicao(Variaveis.GpsService.UltimaLeitura); - } - VariaveisControleOperacao.EnviarDadosHeartbeat(); - VariaveisControleOperacao.AtualizarListaRoversNaRede(); - - if (Mock && AddRover) - { - System.Windows.Application.Current?.Dispatcher?.BeginInvoke(new Action(() => - { - Variaveis.Dock?._vm?.AtualizarDadosTela(VariaveisControleOperacao.RoverEmFoco); - })); - } + Variaveis.MostrarLog(message); } - finally + catch { - tmrComunicacaoTicks++; - tmrComunicacaoTicking = false; + System.Diagnostics.Debug.WriteLine(message); } } - - public void AdicionarAlertaDock(string roverId, T_Code modulo, SeveridadeAlerta severidade, string mensagem, string modId = null) - { - System.Windows.Application.Current.Dispatcher.BeginInvoke(new Action(() => - { - Dock?._vm?.AdicionarAlerta(roverId, modulo, severidade, mensagem, modId); - })); - } - - public void RemoverAlertaDock(string roverId, T_Code modulo, SeveridadeAlerta? severidade = null, string modId = null) - { - System.Windows.Application.Current.Dispatcher.BeginInvoke(new Action(() => - { - Dock?._vm?.RemoverAlerta(roverId, modulo, severidade, modId); - })); - } - } } diff --git a/AgroBase/OperationControl/Models/TcpVideoReceiver.cs b/AgroBase/OperationControl/Models/TcpVideoReceiver.cs index 0e7b74bdd..950a1799a 100644 --- a/AgroBase/OperationControl/Models/TcpVideoReceiver.cs +++ b/AgroBase/OperationControl/Models/TcpVideoReceiver.cs @@ -1,153 +1,1079 @@ -using System.IO; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Net; using System.Net.Sockets; +using System.Threading; +using System.Windows.Controls; using System.Windows.Media.Imaging; +using System.Windows.Threading; namespace OperationControl.Models { - public class TcpVideoReceiver + /// + /// Receptor TCP de vídeo orientado a operação de campo. + /// + /// Protocolo preservado e compatível com CameraTcpStreamer do rover: + /// 4 bytes uint32 big-endian com o tamanho do JPEG + /// N bytes contendo o JPEG + /// + /// Princípios desta implementação: + /// 1. A thread de rede apenas recebe e enquadra os JPEGs. + /// 2. Existe no máximo um JPEG pendente para decode (latest-only). + /// 3. O decode ocorre fora da thread de rede e fora da UI. + /// 4. Existe no máximo um Bitmap pendente para a UI. + /// 5. A UI nunca recebe uma fila ilimitada de BeginInvoke. + /// 6. Uma conexão nova substitui imediatamente uma conexão antiga/zumbi. + /// 7. Tamanhos inválidos são rejeitados antes de qualquer alocação grande. + /// 8. Parada, reinício e reconexão são idempotentes e thread-safe. + /// + public sealed class TcpVideoReceiver : IDisposable { + private const int HeaderSize = 4; + private const int MinJpegBytes = 32; + private const int DefaultMaxFrameBytes = 4 * 1024 * 1024; + private const int DefaultReceiveTimeoutMs = 7000; + private const int DefaultReceiveBufferBytes = 256 * 1024; + private readonly int _port; private readonly System.Windows.Controls.Image _imageControl; + private readonly Action _mostrarLog; + private readonly int _maxFrameBytes; + private readonly int _receiveTimeoutMs; + public readonly string imageName; - private volatile bool _rodando; - private Thread _thread; - private TcpListener _listener; + private readonly object _lifecycleLock = new object(); + private readonly object _clientLock = new object(); + private readonly object _metricsLock = new object(); + private readonly object _logLock = new object(); - public TcpVideoReceiver(int port, System.Windows.Controls.Image imageControl) + private readonly AutoResetEvent _frameReady = new AutoResetEvent(false); + + private volatile bool _rodando; + private volatile bool _disposed; + + private int _runGeneration; + private int _sessionSequence; + private int _activeSessionId; + + private Thread _acceptThread; + private Thread _decodeThread; + private Thread _clientThread; + + private TcpListener _listener; + private TcpClient _activeClient; + + // Slot latest-only entre rede e decode. + private FramePacket _pendingFrame; + + // Slot latest-only entre decode e UI. + private UiFrame _pendingUiFrame; + private int _uiDispatchScheduled; + + // Métricas atômicas. + private long _connectionsAccepted; + private long _connectionsReplaced; + private long _disconnects; + private long _framesReceived; + private long _framesDecoded; + private long _framesDisplayed; + private long _framesDroppedPending; + private long _framesDroppedUi; + private long _framesInvalidSize; + private long _framesInvalidJpeg; + private long _framesDecodeErrors; + private long _receiveErrors; + private long _bytesReceived; + + // Métricas compostas protegidas por lock. + private bool _clientConnected; + private string _remoteEndpoint; + private string _lastError; + private DateTime? _connectedSinceUtc; + private DateTime? _lastFrameReceivedUtc; + private DateTime? _lastFrameDisplayedUtc; + private double _receiveKbpsEma; + private double _receiveFpsEma; + private double _decodeMsEma; + private long _lastFrameReceivedTicks; + + private readonly Dictionary _lastLogTicks = new Dictionary(); + private readonly TimeSpan _logThrottle = TimeSpan.FromSeconds(5); + + /// + /// Mantém compatibilidade com as chamadas existentes de dois argumentos. + /// Os parâmetros adicionais são opcionais. + /// + public TcpVideoReceiver( + int port, + System.Windows.Controls.Image imageControl, + Action mostrarLog = null, + int maxFrameBytes = DefaultMaxFrameBytes, + int receiveTimeoutMs = DefaultReceiveTimeoutMs) { + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + + if (imageControl == null) + throw new ArgumentNullException(nameof(imageControl)); + + if (maxFrameBytes < 64 * 1024) + throw new ArgumentOutOfRangeException(nameof(maxFrameBytes)); + + if (receiveTimeoutMs < 2000) + throw new ArgumentOutOfRangeException(nameof(receiveTimeoutMs)); + _port = port; _imageControl = imageControl; + _mostrarLog = mostrarLog ?? (msg => Console.WriteLine(msg)); + _maxFrameBytes = maxFrameBytes; + _receiveTimeoutMs = receiveTimeoutMs; + imageName = imageControl.Name; } + public bool Rodando => _rodando; + + public bool ClienteConectado + { + get + { + lock (_metricsLock) + return _clientConnected; + } + } + + /// + /// Inicia listener, decoder e ciclo de recepção. + /// Pode ser chamado novamente após Parar(). + /// public void Iniciar() { - if (_rodando) return; + ThrowIfDisposed(); - _rodando = true; - _thread = new Thread(Run) { IsBackground = true }; - _thread.Start(); - } - - public void Parar() - { - _rodando = false; - try + lock (_lifecycleLock) { - _listener?.Stop(); - _imageControl.Source = null; - } - catch { /* ignora */ } - } + if (_rodando) + return; - private void Run() - { - try - { - _listener = new TcpListener(IPAddress.Any, _port); - _listener.Start(); - Console.WriteLine($"[VideoReceiver] Ouvindo na porta {_port}..."); + TcpListener listener = null; - while (_rodando) + try { - TcpClient client = null; + listener = new TcpListener(IPAddress.Any, _port); + // Impede duas instâncias locais de ocuparem silenciosamente + // a mesma porta no Windows. try { - // Em vez de bloquear direto no Accept, usamos Pending() + Sleep - if (!_listener.Pending()) + listener.Server.ExclusiveAddressUse = true; + } + catch + { + // Best effort. + } + + listener.Start(4); + } + catch (Exception ex) + { + try { listener?.Stop(); } catch { } + SetLastError("Falha ao iniciar listener: " + ex.Message); + Log($"[VideoReceiver:{imageName}] Falha ao ouvir porta {_port}: {ex.Message}"); + return; + } + + ResetPendingSlots(); + ResetSessionMetrics(); + + int generation = Interlocked.Increment(ref _runGeneration); + + _listener = listener; + _rodando = true; + + _decodeThread = new Thread(() => DecodeLoop(generation)) + { + IsBackground = true, + Name = $"video-decode-{imageName}-{_port}" + }; + + _acceptThread = new Thread(() => AcceptLoop(generation)) + { + IsBackground = true, + Name = $"video-accept-{imageName}-{_port}" + }; + + _decodeThread.Start(); + _acceptThread.Start(); + + Log($"[VideoReceiver:{imageName}] Ouvindo na porta {_port}."); + } + } + + /// + /// Para listener, conexão e workers. É seguro chamar mais de uma vez. + /// + public void Parar() + { + Thread acceptThread; + Thread decodeThread; + Thread clientThread; + TcpListener listener; + TcpClient client; + int stoppedGeneration; + + lock (_lifecycleLock) + { + if (!_rodando) + { + ScheduleImageClear(Volatile.Read(ref _runGeneration)); + return; + } + + _rodando = false; + stoppedGeneration = Interlocked.Increment(ref _runGeneration); + + listener = _listener; + _listener = null; + + acceptThread = _acceptThread; + decodeThread = _decodeThread; + _acceptThread = null; + _decodeThread = null; + + lock (_clientLock) + { + client = _activeClient; + _activeClient = null; + _activeSessionId = 0; + clientThread = _clientThread; + _clientThread = null; + } + } + + try { listener?.Stop(); } catch { } + CloseClient(client); + + // Libera imediatamente o decoder caso esteja aguardando. + _frameReady.Set(); + + JoinThread(acceptThread, 1500); + JoinThread(clientThread, 1500); + JoinThread(decodeThread, 1500); + + ResetPendingSlots(); + + lock (_metricsLock) + { + _clientConnected = false; + _remoteEndpoint = null; + _connectedSinceUtc = null; + } + + ScheduleImageClear(stoppedGeneration); + Log($"[VideoReceiver:{imageName}] Receptor parado."); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + Parar(); + + // O AutoResetEvent é intencionalmente mantido até a coleta da + // instância. Isso evita uma corrida rara com uma thread encerrando + // após o timeout defensivo de Join. + } + + /// + /// Snapshot thread-safe para logs, UI ou telemetria da base. + /// + public TcpVideoReceiverMetrics GetMetrics() + { + bool connected; + string remote; + string lastError; + DateTime? connectedSince; + DateTime? lastReceived; + DateTime? lastDisplayed; + double kbps; + double fps; + double decodeMs; + + lock (_metricsLock) + { + connected = _clientConnected; + remote = _remoteEndpoint; + lastError = _lastError; + connectedSince = _connectedSinceUtc; + lastReceived = _lastFrameReceivedUtc; + lastDisplayed = _lastFrameDisplayedUtc; + kbps = _receiveKbpsEma; + fps = _receiveFpsEma; + decodeMs = _decodeMsEma; + } + + double lastFrameAgeMs = -1; + if (lastReceived.HasValue) + lastFrameAgeMs = Math.Max(0, (DateTime.UtcNow - lastReceived.Value).TotalMilliseconds); + + return new TcpVideoReceiverMetrics + { + ImageName = imageName, + Port = _port, + Running = _rodando, + ClientConnected = connected, + RemoteEndpoint = remote, + ConnectedSinceUtc = connectedSince, + LastFrameReceivedUtc = lastReceived, + LastFrameDisplayedUtc = lastDisplayed, + LastFrameAgeMs = lastFrameAgeMs, + ReceiveKbps = kbps, + ReceiveFps = fps, + DecodeMs = decodeMs, + QueueDepth = Volatile.Read(ref _pendingFrame) == null ? 0 : 1, + UiQueueDepth = Volatile.Read(ref _pendingUiFrame) == null ? 0 : 1, + ConnectionsAccepted = Interlocked.Read(ref _connectionsAccepted), + ConnectionsReplaced = Interlocked.Read(ref _connectionsReplaced), + Disconnects = Interlocked.Read(ref _disconnects), + FramesReceived = Interlocked.Read(ref _framesReceived), + FramesDecoded = Interlocked.Read(ref _framesDecoded), + FramesDisplayed = Interlocked.Read(ref _framesDisplayed), + FramesDroppedPending = Interlocked.Read(ref _framesDroppedPending), + FramesDroppedUi = Interlocked.Read(ref _framesDroppedUi), + FramesInvalidSize = Interlocked.Read(ref _framesInvalidSize), + FramesInvalidJpeg = Interlocked.Read(ref _framesInvalidJpeg), + FramesDecodeErrors = Interlocked.Read(ref _framesDecodeErrors), + ReceiveErrors = Interlocked.Read(ref _receiveErrors), + BytesReceived = Interlocked.Read(ref _bytesReceived), + LastError = lastError + }; + } + + // ================================================================= + // ACCEPT / CLIENT SESSION + // ================================================================= + + private void AcceptLoop(int generation) + { + while (IsRunActive(generation)) + { + TcpClient client = null; + + try + { + TcpListener listener = _listener; + if (listener == null) + break; + + // Stop() em Parar() libera este bloqueio imediatamente. + client = listener.AcceptTcpClient(); + + if (!IsRunActive(generation)) + { + CloseClient(client); + break; + } + + ConfigureClient(client); + StartClientSession(client, generation); + client = null; // a sessão assumiu a propriedade + } + catch (SocketException ex) + { + CloseClient(client); + + if (!IsRunActive(generation)) + break; + + Interlocked.Increment(ref _receiveErrors); + SetLastError("Erro no Accept: " + ex.Message); + LogThrottled("accept_socket", $"[VideoReceiver:{imageName}] Erro ao aceitar conexão: {ex.Message}"); + Thread.Sleep(250); + } + catch (ObjectDisposedException) + { + CloseClient(client); + break; + } + catch (Exception ex) + { + CloseClient(client); + + if (!IsRunActive(generation)) + break; + + Interlocked.Increment(ref _receiveErrors); + SetLastError("Erro geral no Accept: " + ex.Message); + LogThrottled("accept_general", $"[VideoReceiver:{imageName}] Erro no listener: {ex.Message}"); + Thread.Sleep(500); + } + } + } + + private void StartClientSession(TcpClient client, int generation) + { + int sessionId = Interlocked.Increment(ref _sessionSequence); + TcpClient oldClient; + + var sessionThread = new Thread(() => ClientReadLoop(client, generation, sessionId)) + { + IsBackground = true, + Name = $"video-client-{imageName}-{_port}-{sessionId}" + }; + + lock (_clientLock) + { + if (!IsRunActive(generation)) + { + CloseClient(client); + return; + } + + oldClient = _activeClient; + if (oldClient != null) + Interlocked.Increment(ref _connectionsReplaced); + + _activeClient = client; + _activeSessionId = sessionId; + _clientThread = sessionThread; + } + + // Uma conexão mais nova sempre vence. Isso elimina sessões zumbis + // que ainda pareciam conectadas para o sistema operacional. + CloseClient(oldClient); + + string remote = SafeRemoteEndpoint(client); + + lock (_metricsLock) + { + _clientConnected = true; + _remoteEndpoint = remote; + _connectedSinceUtc = DateTime.UtcNow; + _lastError = null; + } + + Interlocked.Increment(ref _connectionsAccepted); + + // Mesmo que Parar() aconteça neste pequeno intervalo, a thread + // inicia, detecta a geração inválida e encerra sem tocar na UI. + sessionThread.Start(); + Log($"[VideoReceiver:{imageName}] Cliente conectado: {remote}."); + } + + private void ClientReadLoop(TcpClient client, int generation, int sessionId) + { + string disconnectReason = null; + + try + { + using (NetworkStream stream = client.GetStream()) + { + byte[] header = new byte[HeaderSize]; + + while (IsSessionActive(client, generation, sessionId)) + { + if (!ReadExactly(stream, header, 0, HeaderSize, client, generation, sessionId)) { - Thread.Sleep(100); + disconnectReason = "fim do stream"; + break; + } + + int size = ReadBigEndianInt32(header); + + // Um tamanho inválido significa que não é seguro tentar + // continuar no mesmo fluxo: o enquadramento pode estar perdido. + if (size < MinJpegBytes || size > _maxFrameBytes) + { + Interlocked.Increment(ref _framesInvalidSize); + disconnectReason = $"tamanho de frame inválido: {size} bytes"; + SetLastError(disconnectReason); + break; + } + + byte[] jpeg = new byte[size]; + + if (!ReadExactly(stream, jpeg, 0, size, client, generation, sessionId)) + { + disconnectReason = "frame incompleto"; + break; + } + + Interlocked.Add(ref _bytesReceived, size + HeaderSize); + + if (!LooksLikeJpeg(jpeg)) + { + // O tamanho foi consumido corretamente, portanto podemos + // descartar este frame e continuar no próximo cabeçalho. + Interlocked.Increment(ref _framesInvalidJpeg); + SetLastError("JPEG sem marcadores SOI/EOI válidos."); continue; } - client = _listener.AcceptTcpClient(); - client.NoDelay = true; - Console.WriteLine("[VideoReceiver] Cliente conectado."); + RegisterReceivedFrame(size); - using (var stream = client.GetStream()) - using (var br = new BinaryReader(stream)) + var packet = new FramePacket { - while (_rodando && client.Connected) - { - // 1) lê tamanho (4 bytes big-endian) - byte[] sizeBytes = br.ReadBytes(4); - if (sizeBytes.Length < 4) - break; + Bytes = jpeg, + Generation = generation, + SessionId = sessionId, + ReceivedUtc = DateTime.UtcNow + }; - int size = - (sizeBytes[0] << 24) | - (sizeBytes[1] << 16) | - (sizeBytes[2] << 8) | - (sizeBytes[3]); + FramePacket replaced = Interlocked.Exchange(ref _pendingFrame, packet); + if (replaced != null) + Interlocked.Increment(ref _framesDroppedPending); - // 2) lê JPEG - byte[] imgBytes = br.ReadBytes(size); - if (imgBytes.Length < size) - break; - - _imageControl.Dispatcher.BeginInvoke(new Action(() => - { - try - { - using (var ms = new MemoryStream(imgBytes)) - { - var bmp = new BitmapImage(); - bmp.BeginInit(); - bmp.CacheOption = BitmapCacheOption.OnLoad; - bmp.StreamSource = ms; - bmp.EndInit(); - bmp.Freeze(); - _imageControl.Source = bmp; - } - } - catch - { - // ignora frame corrompido ou problema de decode - } - })); - } - } - - Console.WriteLine("[VideoReceiver] Cliente desconectado."); - client.Close(); - } - catch (SocketException ex) - { - // WSACancelBlockingCall ao parar o listener é normal - if (!_rodando) - break; - - Console.WriteLine("[VideoReceiver] SocketException: " + ex.Message); - client?.Close(); - Thread.Sleep(500); - } - catch (IOException) - { - // desconexão abrupta do cliente - client?.Close(); - Thread.Sleep(200); - } - catch (Exception ex) - { - Console.WriteLine("[VideoReceiver] Erro geral: " + ex.Message); - client?.Close(); - Thread.Sleep(500); + _frameReady.Set(); } } - - Console.WriteLine("[VideoReceiver] Loop encerrado."); + } + catch (IOException ex) + { + if (IsSessionActive(client, generation, sessionId)) + { + Interlocked.Increment(ref _receiveErrors); + disconnectReason = IsSocketTimeout(ex) + ? $"sem frames por {_receiveTimeoutMs} ms" + : ex.Message; + SetLastError(disconnectReason); + LogThrottled("client_io", $"[VideoReceiver:{imageName}] Sessão interrompida: {disconnectReason}"); + } + } + catch (SocketException ex) + { + if (IsSessionActive(client, generation, sessionId)) + { + Interlocked.Increment(ref _receiveErrors); + disconnectReason = ex.Message; + SetLastError(disconnectReason); + LogThrottled("client_socket", $"[VideoReceiver:{imageName}] Erro de socket: {ex.Message}"); + } + } + catch (ObjectDisposedException) + { + // Normal quando uma conexão nova substitui a antiga ou ao parar. } catch (Exception ex) { - Console.WriteLine("[VideoReceiver] Erro ao iniciar listener: " + ex.Message); + if (IsSessionActive(client, generation, sessionId)) + { + Interlocked.Increment(ref _receiveErrors); + disconnectReason = ex.Message; + SetLastError(disconnectReason); + LogThrottled("client_general", $"[VideoReceiver:{imageName}] Erro ao receber vídeo: {ex.Message}"); + } } finally { - try { _listener?.Stop(); } catch { } + bool wasActive = false; + + lock (_clientLock) + { + if (_activeClient == client && _activeSessionId == sessionId) + { + _activeClient = null; + _activeSessionId = 0; + _clientThread = null; + wasActive = true; + } + } + + CloseClient(client); + + if (wasActive) + { + lock (_metricsLock) + { + _clientConnected = false; + _remoteEndpoint = null; + _connectedSinceUtc = null; + } + + Interlocked.Increment(ref _disconnects); + + // Fecha apenas o estado da sessão. O listener permanece pronto + // para o sender Python reconectar após seu backoff. + Log($"[VideoReceiver:{imageName}] Cliente desconectado{FormatReason(disconnectReason)}."); + } } } + + // ================================================================= + // DECODE / UI LATEST-ONLY + // ================================================================= + + private void DecodeLoop(int generation) + { + while (IsRunActive(generation)) + { + _frameReady.WaitOne(500); + + if (!IsRunActive(generation)) + break; + + FramePacket packet = Interlocked.Exchange(ref _pendingFrame, null); + if (packet == null) + continue; + + // Descarta frames pertencentes a uma execução anterior. + if (packet.Generation != generation) + continue; + + Stopwatch sw = Stopwatch.StartNew(); + + try + { + BitmapImage bitmap; + + using (var ms = new MemoryStream(packet.Bytes, false)) + { + bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.CreateOptions = BitmapCreateOptions.None; + bitmap.StreamSource = ms; + bitmap.EndInit(); + bitmap.Freeze(); + } + + sw.Stop(); + RegisterDecodedFrame(sw.Elapsed.TotalMilliseconds); + QueueBitmapForUi(bitmap, generation, packet.ReceivedUtc); + } + catch (Exception ex) + { + sw.Stop(); + Interlocked.Increment(ref _framesDecodeErrors); + SetLastError("Falha ao decodificar JPEG: " + ex.Message); + LogThrottled("decode", $"[VideoReceiver:{imageName}] JPEG inválido ou erro de decode: {ex.Message}"); + } + } + } + + private void QueueBitmapForUi(BitmapSource bitmap, int generation, DateTime receivedUtc) + { + var uiFrame = new UiFrame + { + Bitmap = bitmap, + Generation = generation, + ReceivedUtc = receivedUtc + }; + + UiFrame replaced = Interlocked.Exchange(ref _pendingUiFrame, uiFrame); + if (replaced != null) + Interlocked.Increment(ref _framesDroppedUi); + + TryScheduleUiDispatch(); + } + + private void TryScheduleUiDispatch() + { + if (Interlocked.CompareExchange(ref _uiDispatchScheduled, 1, 0) != 0) + return; + + try + { + if (_imageControl.Dispatcher.HasShutdownStarted || + _imageControl.Dispatcher.HasShutdownFinished) + { + Interlocked.Exchange(ref _uiDispatchScheduled, 0); + return; + } + + _imageControl.Dispatcher.BeginInvoke( + DispatcherPriority.Render, + new Action(ApplyLatestBitmapOnUi)); + } + catch (Exception ex) + { + Interlocked.Exchange(ref _uiDispatchScheduled, 0); + SetLastError("Falha ao agendar atualização da UI: " + ex.Message); + } + } + + private void ApplyLatestBitmapOnUi() + { + try + { + UiFrame frame = Interlocked.Exchange(ref _pendingUiFrame, null); + int currentGeneration = Volatile.Read(ref _runGeneration); + + if (frame != null && + _rodando && + frame.Generation == currentGeneration) + { + _imageControl.Source = frame.Bitmap; + + Interlocked.Increment(ref _framesDisplayed); + + lock (_metricsLock) + _lastFrameDisplayedUtc = DateTime.UtcNow; + } + } + catch (Exception ex) + { + SetLastError("Falha ao atualizar imagem na UI: " + ex.Message); + LogThrottled("ui", $"[VideoReceiver:{imageName}] Falha ao atualizar UI: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _uiDispatchScheduled, 0); + + // Se outro bitmap chegou enquanto a UI trabalhava, agenda apenas + // mais uma atualização, sempre com o bitmap mais recente. + if (_rodando && Volatile.Read(ref _pendingUiFrame) != null) + TryScheduleUiDispatch(); + } + } + + private void ScheduleImageClear(int stoppedGeneration) + { + try + { + Action clear = () => + { + // Não limpa uma imagem de uma execução nova iniciada antes + // de este callback antigo chegar ao Dispatcher. + if (!_rodando && Volatile.Read(ref _runGeneration) == stoppedGeneration) + _imageControl.Source = null; + }; + + if (_imageControl.Dispatcher.CheckAccess()) + { + clear(); + } + else if (!_imageControl.Dispatcher.HasShutdownStarted && + !_imageControl.Dispatcher.HasShutdownFinished) + { + _imageControl.Dispatcher.BeginInvoke(DispatcherPriority.Normal, clear); + } + } + catch + { + // Encerramento da UI não deve derrubar o serviço. + } + } + + // ================================================================= + // NETWORK HELPERS + // ================================================================= + + private void ConfigureClient(TcpClient client) + { + client.NoDelay = true; + client.ReceiveTimeout = _receiveTimeoutMs; + client.ReceiveBufferSize = DefaultReceiveBufferBytes; + + try + { + client.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.KeepAlive, + true); + } + catch + { + // Best effort. + } + + // Keepalive mais curto no Windows. Se a plataforma ou runtime não + // suportar IOControl, o keepalive padrão continua ativo. + try + { + byte[] keepAlive = new byte[12]; + BitConverter.GetBytes((uint)1).CopyTo(keepAlive, 0); + BitConverter.GetBytes((uint)10000).CopyTo(keepAlive, 4); + BitConverter.GetBytes((uint)3000).CopyTo(keepAlive, 8); + + client.Client.IOControl( + IOControlCode.KeepAliveValues, + keepAlive, + null); + } + catch + { + // Best effort. + } + } + + private bool ReadExactly( + NetworkStream stream, + byte[] buffer, + int offset, + int count, + TcpClient client, + int generation, + int sessionId) + { + int total = 0; + + while (total < count) + { + if (!IsSessionActive(client, generation, sessionId)) + return false; + + int read = stream.Read(buffer, offset + total, count - total); + if (read <= 0) + return false; + + total += read; + } + + return true; + } + + private bool IsSessionActive(TcpClient client, int generation, int sessionId) + { + if (!IsRunActive(generation)) + return false; + + lock (_clientLock) + { + return _activeClient == client && + _activeSessionId == sessionId; + } + } + + private bool IsRunActive(int generation) + { + return _rodando && Volatile.Read(ref _runGeneration) == generation; + } + + private static int ReadBigEndianInt32(byte[] header) + { + return (header[0] << 24) | + (header[1] << 16) | + (header[2] << 8) | + header[3]; + } + + private static bool LooksLikeJpeg(byte[] bytes) + { + if (bytes == null || bytes.Length < MinJpegBytes) + return false; + + return bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[bytes.Length - 2] == 0xFF && + bytes[bytes.Length - 1] == 0xD9; + } + + private static bool IsSocketTimeout(IOException ex) + { + var socketEx = ex.InnerException as SocketException; + return socketEx != null && socketEx.SocketErrorCode == SocketError.TimedOut; + } + + private static void CloseClient(TcpClient client) + { + if (client == null) + return; + + try { client.Client.Shutdown(SocketShutdown.Both); } catch { } + try { client.Close(); } catch { } + } + + private static void JoinThread(Thread thread, int timeoutMs) + { + if (thread == null || thread == Thread.CurrentThread) + return; + + try { thread.Join(timeoutMs); } catch { } + } + + private static string SafeRemoteEndpoint(TcpClient client) + { + try + { + return client?.Client?.RemoteEndPoint?.ToString() ?? "desconhecido"; + } + catch + { + return "desconhecido"; + } + } + + private static string FormatReason(string reason) + { + return string.IsNullOrWhiteSpace(reason) ? "" : $" ({reason})"; + } + + // ================================================================= + // METRICS / LOG + // ================================================================= + + private void RegisterReceivedFrame(int jpegBytes) + { + long nowTicks = Stopwatch.GetTimestamp(); + DateTime nowUtc = DateTime.UtcNow; + + Interlocked.Increment(ref _framesReceived); + + lock (_metricsLock) + { + if (_lastFrameReceivedTicks > 0) + { + double dt = (nowTicks - _lastFrameReceivedTicks) / + (double)Stopwatch.Frequency; + + if (dt > 0.0001) + { + double fps = 1.0 / dt; + double kbps = (jpegBytes * 8.0) / dt / 1000.0; + + _receiveFpsEma = Ema(_receiveFpsEma, fps, 0.20); + _receiveKbpsEma = Ema(_receiveKbpsEma, kbps, 0.20); + } + } + + _lastFrameReceivedTicks = nowTicks; + _lastFrameReceivedUtc = nowUtc; + _lastError = null; + } + } + + private void RegisterDecodedFrame(double decodeMs) + { + Interlocked.Increment(ref _framesDecoded); + + lock (_metricsLock) + _decodeMsEma = Ema(_decodeMsEma, decodeMs, 0.20); + } + + private static double Ema(double previous, double current, double alpha) + { + if (previous <= 0) + return current; + + return previous * (1.0 - alpha) + current * alpha; + } + + private void SetLastError(string error) + { + lock (_metricsLock) + _lastError = error; + } + + private void ResetSessionMetrics() + { + Interlocked.Exchange(ref _connectionsAccepted, 0); + Interlocked.Exchange(ref _connectionsReplaced, 0); + Interlocked.Exchange(ref _disconnects, 0); + Interlocked.Exchange(ref _framesReceived, 0); + Interlocked.Exchange(ref _framesDecoded, 0); + Interlocked.Exchange(ref _framesDisplayed, 0); + Interlocked.Exchange(ref _framesDroppedPending, 0); + Interlocked.Exchange(ref _framesDroppedUi, 0); + Interlocked.Exchange(ref _framesInvalidSize, 0); + Interlocked.Exchange(ref _framesInvalidJpeg, 0); + Interlocked.Exchange(ref _framesDecodeErrors, 0); + Interlocked.Exchange(ref _receiveErrors, 0); + Interlocked.Exchange(ref _bytesReceived, 0); + + lock (_metricsLock) + { + _clientConnected = false; + _remoteEndpoint = null; + _lastError = null; + _connectedSinceUtc = null; + _lastFrameReceivedUtc = null; + _lastFrameDisplayedUtc = null; + _receiveKbpsEma = 0; + _receiveFpsEma = 0; + _decodeMsEma = 0; + _lastFrameReceivedTicks = 0; + } + } + + private void ResetPendingSlots() + { + Interlocked.Exchange(ref _pendingFrame, null); + Interlocked.Exchange(ref _pendingUiFrame, null); + Interlocked.Exchange(ref _uiDispatchScheduled, 0); + _frameReady.Reset(); + } + + private void Log(string message) + { + try { _mostrarLog(message); } catch { } + } + + private void LogThrottled(string key, string message) + { + long now = Stopwatch.GetTimestamp(); + bool shouldLog = false; + + lock (_logLock) + { + long last; + if (!_lastLogTicks.TryGetValue(key, out last) || + (now - last) / (double)Stopwatch.Frequency >= _logThrottle.TotalSeconds) + { + _lastLogTicks[key] = now; + shouldLog = true; + } + } + + if (shouldLog) + Log(message); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(TcpVideoReceiver)); + } + + private sealed class FramePacket + { + public byte[] Bytes; + public int Generation; + public int SessionId; + public DateTime ReceivedUtc; + } + + private sealed class UiFrame + { + public BitmapSource Bitmap; + public int Generation; + public DateTime ReceivedUtc; + } + } + + public sealed class TcpVideoReceiverMetrics + { + public string ImageName { get; set; } + public int Port { get; set; } + public bool Running { get; set; } + public bool ClientConnected { get; set; } + public string RemoteEndpoint { get; set; } + public DateTime? ConnectedSinceUtc { get; set; } + public DateTime? LastFrameReceivedUtc { get; set; } + public DateTime? LastFrameDisplayedUtc { get; set; } + public double LastFrameAgeMs { get; set; } + public double ReceiveKbps { get; set; } + public double ReceiveFps { get; set; } + public double DecodeMs { get; set; } + public int QueueDepth { get; set; } + public int UiQueueDepth { get; set; } + public long ConnectionsAccepted { get; set; } + public long ConnectionsReplaced { get; set; } + public long Disconnects { get; set; } + public long FramesReceived { get; set; } + public long FramesDecoded { get; set; } + public long FramesDisplayed { get; set; } + public long FramesDroppedPending { get; set; } + public long FramesDroppedUi { get; set; } + public long FramesInvalidSize { get; set; } + public long FramesInvalidJpeg { get; set; } + public long FramesDecodeErrors { get; set; } + public long ReceiveErrors { get; set; } + public long BytesReceived { get; set; } + public string LastError { get; set; } } } diff --git a/AgroBase/OperationControl/Models/Variaveis.cs b/AgroBase/OperationControl/Models/Variaveis.cs index 80ca414a0..44aa53ba4 100644 --- a/AgroBase/OperationControl/Models/Variaveis.cs +++ b/AgroBase/OperationControl/Models/Variaveis.cs @@ -1,21 +1,61 @@ -using AgroBase.Models; -using Newtonsoft.Json; -using System.Diagnostics; -using OperationControl.Services; -using AgroMonitor; -using Application = System.Windows.Application; +using AgroBase.Models; using AgroBase.Models.Operacoes; +using AgroMonitor; +using Newtonsoft.Json; +using OperationControl.Services; using OperationControl.Windows; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Application = System.Windows.Application; namespace OperationControl.Models { + /// + /// Recursos globais de comunicação da base. + /// + /// O MQTT foi dividido em dois clientes independentes: + /// - Critical: RTCM, heartbeat, comandos, posição e discovery ACK; + /// - Monitoring: discovery, telemetria e parâmetros. + /// + /// Essa divisão evita que JSON pesado, callbacks de interface ou + /// reconexões de monitoramento disputem a fila interna do RTCM. + /// public class Variaveis { - public static MqttService MqttService; + private static readonly SemaphoreSlim _mqttLifecycleLock = + new SemaphoreSlim(1, 1); + private static readonly object _udpLifecycleLock = + new object(); + + public static MqttService MqttServiceCritical { get; private set; } + public static MqttService MqttServiceMonitoring { get; private set; } + + /// + /// Ponte temporária de compatibilidade. + /// Código novo deve escolher explicitamente Critical ou Monitoring. + /// + public static MqttService MqttService + { + get { return MqttServiceMonitoring; } + set { MqttServiceMonitoring = value; } + } + + public static MqttService.MqttTopicosModel TopicoRtcm { get; private set; } + public static MqttService.MqttTopicosModel TopicoPosicaoBase { get; private set; } + public static MqttService.MqttTopicosModel TopicoDiscovery { get; private set; } + + public static RtcmPublisherService RtcmPublisher { get; private set; } + public static GpsService GpsService; public static UdpReliableChannel UdpChannel; public static ManualControlSender ControlSenderDir; public static ManualControlSender ControlSenderMov; + public static AppShell? Shell => ((App)Application.Current)?.Shell; public static DockWindow? Dock => Shell?.Dock; @@ -24,291 +64,1553 @@ namespace OperationControl.Models Debug.WriteLine(message); } - public static async void IniciarMQTT() + /// + /// Compatibilidade com chamadas antigas. O startup deve preferir: + /// await Variaveis.IniciarMqttAsync(); + /// + public static Task IniciarMQTT() { - if (MqttService != null) + return IniciarMqttAsync(); + } + + public static async Task IniciarMqttAsync( + CancellationToken cancellationToken = default(CancellationToken)) + { + await _mqttLifecycleLock + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + try { - foreach (var topico in MqttService.Topicos.Where(x => x.Inscrever)) - { - await MqttService.UnsubscribeAsync(topico); - } - MqttService.Topicos.Clear(); + await EncerrarMqttInternoAsync( + "Reinicialização MQTT" + ).ConfigureAwait(false); + + VariaveisControleOperacao.ResetarTopicosMqtt(); + + MqttServiceCritical = new MqttService( + "localhost", + 1883, + "base-critical", + true, + msg => MostrarLog( + "[MQTT CRITICAL localhost:1883] - " + msg + ) + ); + + MqttServiceMonitoring = new MqttService( + "localhost", + 1883, + "base-monitoring", + true, + msg => MostrarLog( + "[MQTT MONITORING localhost:1883] - " + msg + ) + ); + + await ConfigurarTopicosBaseAsync() + .ConfigureAwait(false); + + RtcmPublisher = new RtcmPublisherService( + () => MqttServiceCritical, + () => TopicoRtcm, + MostrarLog + ); + + RtcmPublisher.Start(); + + await Task.WhenAll( + MqttServiceCritical.StartAsync(), + MqttServiceMonitoring.StartAsync() + ).ConfigureAwait(false); + + if (GpsService == null) + GpsService = new GpsService(); + + await VariaveisControleOperacao + .ReconfigurarRoversConhecidosAsync() + .ConfigureAwait(false); } + catch + { + await EncerrarMqttInternoAsync( + "Falha durante inicialização MQTT" + ).ConfigureAwait(false); - MqttService = new MqttService("localhost", 1883, "base", true, msg => MostrarLog($"[MQTT localhost:1883] - {msg}")); + throw; + } + finally + { + _mqttLifecycleLock.Release(); + } + } - await MqttService.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttPosicao); - await MqttService.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttRTCM); - await MqttService.AdicionarNovoTopico(VariaveisMonitoramento.TopicoMqttDispositivos, true, 1, async (message) => + private static async Task ConfigurarTopicosBaseAsync() + { + TopicoPosicaoBase = + await MqttServiceCritical.AdicionarNovoTopico( + VariaveisMonitoramento.TopicoMqttPosicao, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: + MqttQueueOverflowPolicy.DropOldest, + qos: + MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoRtcm = + await MqttServiceCritical.AdicionarNovoTopico( + VariaveisMonitoramento.TopicoMqttRTCM, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Binary, + dispatchMode: MqttDispatchMode.Sequential, + queueCapacity: 32, + overflowPolicy: + MqttQueueOverflowPolicy.DropOldest, + qos: + MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + + TopicoDiscovery = + await MqttServiceMonitoring.AdicionarNovoTopico( + VariaveisMonitoramento + .TopicoMqttDispositivos, + inscrever: true, + mensagensManter: 2, + callback: async message => + { + try + { + RoverDiscoveryInfo discovery = + RoverDiscoveryInfo.Parse( + message.Mensagem + ); + + if (discovery == null) + return; + + await VariaveisControleOperacao + .AdicionarNovoRoverNaRede( + discovery.RoverId, + discovery.RoverIp, + discovery.SessionId + ).ConfigureAwait(false); + } + catch (Exception ex) + { + MostrarLog( + "Erro ao processar discovery do rover: " + + ex.Message + ); + } + }, + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.Sequential, + queueCapacity: 128, + overflowPolicy: + MqttQueueOverflowPolicy.DropOldest, + qos: + MqttQosLevel.AtMostOnce, + retain: false + ).ConfigureAwait(false); + } + + public static async Task EncerrarMqttAsync() + { + await _mqttLifecycleLock + .WaitAsync() + .ConfigureAwait(false); + + try + { + await EncerrarMqttInternoAsync( + "Encerramento solicitado" + ).ConfigureAwait(false); + } + finally + { + _mqttLifecycleLock.Release(); + } + } + + private static async Task EncerrarMqttInternoAsync( + string reason) + { + RtcmPublisherService rtcm = RtcmPublisher; + RtcmPublisher = null; + + if (rtcm != null) { try { - var p = message.Mensagem.Split(','); - if (string.IsNullOrEmpty(message.Mensagem) || p.Length < 2) - return; - - string device_id = p[0]; - string device_ip = p[1]; - VariaveisControleOperacao.AdicionarNovoRoverNaRede(device_id, device_ip); + await rtcm.StopAsync() + .ConfigureAwait(false); } catch (Exception ex) { - MostrarLog($"Erro ao deserializar ping do rover: {ex.Message}"); + MostrarLog( + "Erro ao encerrar publisher RTCM: " + + ex.Message + ); } - }); - GpsService = new GpsService(); + rtcm.Dispose(); + } + + MqttService critical = MqttServiceCritical; + MqttService monitoring = MqttServiceMonitoring; + + MqttServiceCritical = null; + MqttServiceMonitoring = null; + + TopicoRtcm = null; + TopicoPosicaoBase = null; + TopicoDiscovery = null; + + VariaveisControleOperacao.ResetarTopicosMqtt(); + + if (critical != null) + { + try + { + await critical.DisposeAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + MostrarLog( + "Erro ao encerrar MQTT crítico: " + + ex.Message + ); + } + } + + if (monitoring != null) + { + try + { + await monitoring.DisposeAsync() + .ConfigureAwait(false); + } + catch (Exception ex) + { + MostrarLog( + "Erro ao encerrar MQTT de monitoramento: " + + ex.Message + ); + } + } + + if (!string.IsNullOrWhiteSpace(reason)) + MostrarLog("[MQTT] " + reason); + } + + public static OperationControlCommunicationMetrics + GetCommunicationMetrics() + { + return new OperationControlCommunicationMetrics + { + Critical = + MqttServiceCritical?.GetMetrics(), + Monitoring = + MqttServiceMonitoring?.GetMetrics(), + Rtcm = + RtcmPublisher?.GetMetrics(), + RoverCount = + VariaveisControleOperacao + .GetRoversSnapshot() + .Count + }; } public static void IniciarUDP() { - UdpChannel = new UdpReliableChannel + lock (_udpLifecycleLock) { - EnableHeartbeat = true, - HeartbeatIntervalMs = 1000, - HeartbeatPayload = new byte[] { 0 } - }; - UdpChannel.Start(VariaveisPortas.Ethernet_UDP_TX); + /* + * Garante idempotência. + * Se a base reiniciar a comunicação, não deixa canal UDP + * e senders antigos rodando no fundo. + */ + StopUdpChannelInterno(); - ControlSenderDir = new ManualControlSender(UdpChannel, (byte)AgroBase.Models.Enums.T_Code.Dir); - ControlSenderDir.Start(); - ControlSenderMov = new ManualControlSender(UdpChannel, (byte)AgroBase.Models.Enums.T_Code.Mov); - ControlSenderMov.Start(); + UdpReliableChannel channel = new UdpReliableChannel + { + EnableHeartbeat = true, + HeartbeatIntervalMs = 1000, + HeartbeatPayload = new byte[] { 0 } + }; + + channel.Start( + VariaveisPortas.Ethernet_UDP_TX + ); + + UdpChannel = channel; + + ControlSenderDir = new ManualControlSender( + UdpChannel, + (byte)AgroBase.Models.Enums.T_Code.Dir + ); + + ControlSenderDir.Start(); + + ControlSenderMov = new ManualControlSender( + UdpChannel, + (byte)AgroBase.Models.Enums.T_Code.Mov + ); + + ControlSenderMov.Start(); + + MostrarLog("[UDP] Canal UDP iniciado."); + } + } + + public static void StopUdpChannel() + { + lock (_udpLifecycleLock) + { + StopUdpChannelInterno(); + } + } + + public static void EncerrarUDP() + { + StopUdpChannel(); + } + + private static void StopUdpChannelInterno() + { + ManualControlSender senderDir = ControlSenderDir; + ManualControlSender senderMov = ControlSenderMov; + UdpReliableChannel channel = UdpChannel; + + ControlSenderDir = null; + ControlSenderMov = null; + UdpChannel = null; + + PararObjetoSePossivel( + senderDir, + "ManualControlSender DIR" + ); + + PararObjetoSePossivel( + senderMov, + "ManualControlSender MOV" + ); + + if (channel != null) + { + try + { + channel.SetRemote( + null, + VariaveisPortas.Ethernet_UDP_RX + ); + } + catch { } + + PararObjetoSePossivel( + channel, + "UdpReliableChannel" + ); + } + + MostrarLog("[UDP] Canal UDP encerrado."); + } + + private static void PararObjetoSePossivel( + object obj, + string nome) + { + if (obj == null) + return; + + try + { + InvocarMetodoSemParametroSeExistir( + obj, + "Stop" + ); + + InvocarMetodoSemParametroSeExistir( + obj, + "Close" + ); + + if (obj is IDisposable disposable) + { + disposable.Dispose(); + } + else + { + InvocarMetodoSemParametroSeExistir( + obj, + "Dispose" + ); + } + } + catch (Exception ex) + { + MostrarLog( + "[UDP] Erro ao encerrar " + + nome + ": " + ex.Message + ); + } + } + + private static void InvocarMetodoSemParametroSeExistir( + object obj, + string metodo) + { + if (obj == null || + string.IsNullOrWhiteSpace(metodo)) + { + return; + } + + var method = obj + .GetType() + .GetMethod( + metodo, + Type.EmptyTypes + ); + + if (method == null) + return; + + method.Invoke(obj, null); } } public class VariaveisControleOperacao { - public static double LeverArmFrontal { get; } = 0.0; // cm - public static double LeverArmLateral { get; } = 0.0; // cm + public static double LeverArmFrontal { get; } = 0.0; + public static double LeverArmLateral { get; } = 0.0; public static readonly string BaseMarkerID = "BASE"; - private static readonly object _RoversLock = new object(); + + private static readonly object _RoversLock = + new object(); + + private static readonly ConcurrentDictionary< + string, + SemaphoreSlim> _roverSetupLocks = + new ConcurrentDictionary< + string, + SemaphoreSlim>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + long> _roverLastContactMono = + new ConcurrentDictionary< + string, + long>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + MqttService.MqttTopicosModel> + _heartbeatTopics = + new ConcurrentDictionary< + string, + MqttService.MqttTopicosModel>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + MqttService.MqttTopicosModel> + _commandTopics = + new ConcurrentDictionary< + string, + MqttService.MqttTopicosModel>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + MqttService.MqttTopicosModel> + _telemetryTopics = + new ConcurrentDictionary< + string, + MqttService.MqttTopicosModel>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + MqttService.MqttTopicosModel> + _parameterTopics = + new ConcurrentDictionary< + string, + MqttService.MqttTopicosModel>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + MqttService.MqttTopicosModel> + _discoveryAckTopics = + new ConcurrentDictionary< + string, + MqttService.MqttTopicosModel>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + byte> _telemetryUiScheduled = + new ConcurrentDictionary< + string, + byte>( + StringComparer.OrdinalIgnoreCase + ); + + private static readonly ConcurrentDictionary< + string, + byte> _parameterUiScheduled = + new ConcurrentDictionary< + string, + byte>( + StringComparer.OrdinalIgnoreCase + ); + + private static int _updatingRovers; + public static double TempoRoverVivo = 5.0; - private static bool AdicionandoNovoRover = false; - private static bool AtualizandoListaRovers = false; - public static AgroBase.Models.Enums.StatusModulo StatusBase + + public static AgroBase.Models.Enums.StatusModulo + StatusBase { get { - if ((Variaveis.GpsService?.IsConnected ?? false) && (Variaveis.GpsService?.BaseFix?.CorrecaoAbsoluta ?? false)) - return AgroBase.Models.Enums.StatusModulo.Operante; + if ((Variaveis.GpsService?.IsConnected ?? false) && + (Variaveis.GpsService? + .BaseFix? + .CorrecaoAbsoluta ?? false)) + { + return AgroBase.Models.Enums + .StatusModulo.Operante; + } + if (Variaveis.GpsService?.IsConnected ?? false) - return AgroBase.Models.Enums.StatusModulo.Alerta; - return AgroBase.Models.Enums.StatusModulo.Desconectado; + { + return AgroBase.Models.Enums + .StatusModulo.Alerta; + } + + return AgroBase.Models.Enums + .StatusModulo.Desconectado; } } - public static List RoversNaRede { get; set; } = new List(); - public static string SelectedRoverId { get; set; } = BaseMarkerID; - public static OperacaoParametrosModel? RoverEmFoco => RoversNaRede.FirstOrDefault(x => x.RoverId == SelectedRoverId); - public static bool BaseEmFoco => SelectedRoverId == BaseMarkerID; - - public static async void AdicionarNovoRoverNaRede(string device_id, string device_ip) + + public static List + RoversNaRede { get; set; } = + new List(); + + public static string SelectedRoverId { get; set; } = + BaseMarkerID; + + public static OperacaoParametrosModel? RoverEmFoco { - if (AdicionandoNovoRover) return; - AdicionandoNovoRover = true; + get + { + lock (_RoversLock) + { + return RoversNaRede.FirstOrDefault( + x => x.RoverId == SelectedRoverId + ); + } + } + } + + public static bool BaseEmFoco => + SelectedRoverId == BaseMarkerID; + + public static List + GetRoversSnapshot() + { + lock (_RoversLock) + { + return new List( + RoversNaRede + ); + } + } + + internal static void ResetarTopicosMqtt() + { + _heartbeatTopics.Clear(); + _commandTopics.Clear(); + _telemetryTopics.Clear(); + _parameterTopics.Clear(); + _discoveryAckTopics.Clear(); + } + + internal static async Task + ReconfigurarRoversConhecidosAsync() + { + List snapshot = + GetRoversSnapshot(); + + foreach (OperacaoParametrosModel rover in snapshot) + { + if (rover == null || + string.IsNullOrWhiteSpace(rover.RoverId)) + { + continue; + } + + await GarantirTopicosRoverAsync( + rover.RoverId + ).ConfigureAwait(false); + } + } + + public static async Task AdicionarNovoRoverNaRede( + string device_id, + string device_ip, + string sessionId = null) + { + if (string.IsNullOrWhiteSpace(device_id)) + return; + + device_id = device_id.Trim(); + device_ip = (device_ip ?? string.Empty).Trim(); + + SemaphoreSlim gate = + _roverSetupLocks.GetOrAdd( + device_id, + _ => new SemaphoreSlim(1, 1) + ); + + await gate.WaitAsync().ConfigureAwait(false); try { - if (string.IsNullOrEmpty(device_id)) - return; + bool novoRover = false; + OperacaoParametrosModel rover; - bool rover_ja_adicionado = false; lock (_RoversLock) { - rover_ja_adicionado = RoversNaRede.Any(x => x.RoverId == device_id); - } + rover = RoversNaRede.FirstOrDefault( + x => string.Equals( + x.RoverId, + device_id, + StringComparison.OrdinalIgnoreCase + ) + ); - if (!rover_ja_adicionado) - { - lock (_RoversLock) + if (rover == null) { - RoversNaRede.Add(new OperacaoParametrosModel() + rover = new OperacaoParametrosModel { RoverId = device_id, IP = device_ip, Configurado = false, Alive = true, - DadosLeitura = new OperacaoParametrosDadosModel() - { - Momento = DateTime.Now - } - }); - } - - bool t_hbt_added = Variaveis.MqttService.Topicos.Any(x => x.Topico == VariaveisEquipamento.TopicoMqttHeartbeat.Replace("", device_id)); - if (!t_hbt_added) - await Variaveis.MqttService.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttHeartbeat.Replace("", device_id)); - bool t_cmd_added = Variaveis.MqttService.Topicos.Any(x => x.Topico == VariaveisEquipamento.TopicoMqttComandos.Replace("", device_id)); - if (!t_cmd_added) - await Variaveis.MqttService.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttComandos.Replace("", device_id)); - bool t_tel_added = Variaveis.MqttService.Topicos.Any(x => x.Topico == VariaveisEquipamento.TopicoMqttTelemetria.Replace("", device_id)); - if (!t_tel_added) - await Variaveis.MqttService.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttTelemetria.Replace("", device_id), true, 1, async (message) => - { - try - { - string json = message.Mensagem; - if (string.IsNullOrEmpty(json)) return; - var obj = JsonConvert.DeserializeObject(json); - if (obj == null) return; - OperacaoParametrosModel rover; - lock (_RoversLock) + UltimoContato = DateTime.Now, + DadosLeitura = + new OperacaoParametrosDadosModel { - rover = RoversNaRede?.FirstOrDefault(x => x.RoverId == device_id); - if (rover != null) - { - obj.Momento = DateTime.Now; - var logs = rover.DadosLeitura?.Logs ?? new List(); - obj.Logs?.InsertRange(0, logs); - rover.DadosLeitura = obj; - rover.UltimoContato = DateTime.Now; - } + Momento = DateTime.Now } - //((App)Application.Current).Shell.Main?.AtualizarDadosTela_Telemetria(device_id); + }; - Application.Current?.Dispatcher?.BeginInvoke(new Action(() => - { - Variaveis.Dock?._vm?.AtualizarDadosTela(rover); - })); - } - catch (Exception ex) - { - Variaveis.MostrarLog($"Erro ao deserializar dados de telemetria do rover {device_id}: {ex.Message}"); - } - }); - bool t_par_added = Variaveis.MqttService.Topicos.Any(x => x.Topico == VariaveisEquipamento.TopicoMqttParametros.Replace("", device_id)); - if (!t_par_added) - await Variaveis.MqttService.AdicionarNovoTopico(VariaveisEquipamento.TopicoMqttParametros.Replace("", device_id), true, 1, async (message) => - { - try - { - string json = message.Mensagem; - if (string.IsNullOrEmpty(json)) return; - var obj = JsonConvert.DeserializeObject(json); - lock (_RoversLock) - { - var index = RoversNaRede.FindIndex(x => x.RoverId == device_id); - if (index >= 0) - { - var logs = RoversNaRede[index].DadosLeitura?.Logs ?? new List(); - obj.UltimoContato = DateTime.Now; - obj.DadosLeitura.Logs?.AddRange(logs); - RoversNaRede[index] = obj; - } - } - //((App)Application.Current).Shell.Main?.AtualizarDadosTela_ParametrosOperacao(obj.Controle); - //((App)Application.Current).Shell.Main?.MAP?.CarregarDadosMapa(obj.Mapa, obj.RuasPercorrer); - Variaveis.Dock?._vm?.AtualizarParametrosRover(obj); - } - catch (Exception ex) - { - Variaveis.MostrarLog($"Erro ao deserializar dados de parametros do rover {device_id}: {ex.Message}"); - } - }); - - - List dispositivos_ids = new List() { "BASE" }; - lock (_RoversLock) - { - dispositivos_ids = dispositivos_ids.Union(RoversNaRede.Select(x => x.RoverId).ToList()).ToList(); - } - //((App)Application.Current).Shell.Main?.AtualizarListaDispositivos(dispositivos_ids); - - RequisitarParametrosOperacao(); - } - } - finally - { - AdicionandoNovoRover = false; - } - } - - public static async void AtualizarListaRoversNaRede() - { - if (AtualizandoListaRovers) return; - AtualizandoListaRovers = true; - - try - { - var rovers_atual = new List(); - lock (_RoversLock) - { - rovers_atual = new List(RoversNaRede); - } - foreach (var rover in rovers_atual) - { - rover.Alive = AppShell.Mock || (DateTime.Now - rover.UltimoContato).TotalSeconds < TempoRoverVivo; - if (!rover.Alive) - { - //((App)Application.Current).Shell.Main?.MAP?.markers?.UpdateMarkerInfo(rover.RoverId, status: AgroBase.Models.Enums.StatusOperacao.Erro); - rover.DadosLeitura = new OperacaoParametrosDadosModel(); - rover.DadosLeitura.Momento = DateTime.Now; - if (rover.DadosLeitura.Operacao == null) rover.DadosLeitura.Operacao = new OperacaoParametrosDadosOperacaoModel(); - rover.DadosLeitura.Operacao.Status = AgroBase.Models.Enums.StatusOperacao.Erro; - //((App)Application.Current)?.Shell?.Main?.AtualizarDadosTela_Telemetria(rover.RoverId); - //((App)Application.Current)?.Shell?.Main?.AdicionarAlerta(rover.RoverId, AgroBase.Models.Enums.T_Code.Ipb, SeveridadeAlerta.Critical, "Perda de comunicação com o equipamento!"); - - - Variaveis.Shell?.AdicionarAlertaDock(rover.RoverId, AgroBase.Models.Enums.T_Code.Ipb, SeveridadeAlerta.Critical, "Perda de comunicação com o equipamento!"); - Variaveis.Dock?._vm?._viewOperacaoCenter?.Mapa?.markers?.UpdateMarkerInfo(rover.RoverId, status: AgroBase.Models.Enums.StatusOperacao.Erro); + RoversNaRede.Add(rover); + novoRover = true; } else { - //((App)Application.Current)?.Shell?.Main?.RemoverAlerta(rover.RoverId, AgroBase.Models.Enums.T_Code.Ipb); - Variaveis.Shell?.RemoverAlertaDock(rover.RoverId, AgroBase.Models.Enums.T_Code.Ipb); + if (!string.IsNullOrWhiteSpace(device_ip)) + rover.IP = device_ip; + + rover.Alive = true; + rover.UltimoContato = DateTime.Now; } - Variaveis.Shell?.Dock?._vm?.AtualizarBarraSuperior(rover); } - Application.Current?.Dispatcher?.BeginInvoke(new Action(() => + MarcarContatoRover(device_id); + + await GarantirTopicosRoverAsync(device_id) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(sessionId)) { - Variaveis.Shell?.Dock?._vm?.AtualizarListaRovers(rovers_atual); - })); + await EnviarDiscoveryAckAsync( + device_id, + sessionId + ).ConfigureAwait(false); + } + + if (novoRover) + { + await RequisitarParametrosOperacaoAsync( + device_id + ).ConfigureAwait(false); + } + + AgendarAtualizacaoListaRovers(); } finally { - AtualizandoListaRovers = false; + gate.Release(); } } - public static async void EnviarDadosHeartbeat() + private static async Task GarantirTopicosRoverAsync( + string roverId) { - var topicos = Variaveis.MqttService.Topicos.Where(x => x.Topico.Contains(VariaveisEquipamento.TopicoMqttHeartbeat.Split("/")[VariaveisEquipamento.TopicoMqttHeartbeat.Split("/").Length - 1])); - foreach (var topico in topicos) + MqttService critical = + Variaveis.MqttServiceCritical; + + MqttService monitoring = + Variaveis.MqttServiceMonitoring; + + if (critical == null || monitoring == null) + return; + + string heartbeatName = + VariaveisEquipamento + .TopicoMqttHeartbeat + .Replace("", roverId); + + string commandName = + VariaveisEquipamento + .TopicoMqttComandos + .Replace("", roverId); + + string telemetryName = + VariaveisEquipamento + .TopicoMqttTelemetria + .Replace("", roverId); + + string parameterName = + VariaveisEquipamento + .TopicoMqttParametros + .Replace("", roverId); + + if (!_heartbeatTopics.ContainsKey(roverId)) { - await Variaveis.MqttService.PublishAsync(topico, "0"); + MqttService.MqttTopicosModel topic = + await critical.AdicionarNovoTopico( + heartbeatName, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: + MqttQueueOverflowPolicy + .DropOldest, + qos: + MqttQosLevel + .AtMostOnce, + retain: false + ).ConfigureAwait(false); + + _heartbeatTopics[roverId] = topic; + } + + if (!_commandTopics.ContainsKey(roverId)) + { + MqttService.MqttTopicosModel topic = + await critical.AdicionarNovoTopico( + commandName, + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.Sequential, + queueCapacity: 64, + overflowPolicy: + MqttQueueOverflowPolicy + .DropNewest, + qos: + MqttQosLevel + .AtLeastOnce, + retain: false + ).ConfigureAwait(false); + + _commandTopics[roverId] = topic; + } + + if (!_telemetryTopics.ContainsKey(roverId)) + { + MqttService.MqttTopicosModel topic = + await monitoring.AdicionarNovoTopico( + telemetryName, + inscrever: true, + mensagensManter: 1, + callback: message => + ProcessarTelemetriaAsync( + roverId, + message + ), + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: + MqttQueueOverflowPolicy + .DropOldest, + qos: + MqttQosLevel + .AtMostOnce, + retain: false + ).ConfigureAwait(false); + + _telemetryTopics[roverId] = topic; + } + + if (!_parameterTopics.ContainsKey(roverId)) + { + MqttService.MqttTopicosModel topic = + await monitoring.AdicionarNovoTopico( + parameterName, + inscrever: true, + mensagensManter: 1, + callback: message => + ProcessarParametrosAsync( + roverId, + message + ), + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: + MqttQueueOverflowPolicy + .DropOldest, + qos: + MqttQosLevel + .AtLeastOnce, + retain: false + ).ConfigureAwait(false); + + _parameterTopics[roverId] = topic; } } - private static async void EnviarDadosControle(string rover_id, OperacaoComandoBaseModel controle) + private static Task ProcessarTelemetriaAsync( + string roverId, + MqttService.MqttTopicosMensagensModel message) { - var topico = Variaveis.MqttService.Topicos.FirstOrDefault(x => x.Topico == VariaveisEquipamento.TopicoMqttComandos.Replace("", rover_id)); - if (topico != null) + if (string.IsNullOrWhiteSpace(message.Mensagem)) + return Task.CompletedTask; + + try { - string json = JsonConvert.SerializeObject(controle); - await Variaveis.MqttService.PublishAsync(topico, json); + OperacaoParametrosDadosModel obj = + JsonConvert.DeserializeObject< + OperacaoParametrosDadosModel>( + message.Mensagem + ); + + if (obj == null) + return Task.CompletedTask; + + OperacaoParametrosModel rover = null; + + lock (_RoversLock) + { + rover = RoversNaRede.FirstOrDefault( + x => string.Equals( + x.RoverId, + roverId, + StringComparison.OrdinalIgnoreCase + ) + ); + + if (rover != null) + { + obj.Momento = DateTime.Now; + + List< + OperacaoSensoriamentoLogErrosModel> + logsAnteriores = + rover.DadosLeitura?.Logs ?? + new List< + OperacaoSensoriamentoLogErrosModel>(); + + if (obj.Logs == null) + { + obj.Logs = + new List< + OperacaoSensoriamentoLogErrosModel>(); + } + + if (logsAnteriores.Count > 0) + { + obj.Logs.InsertRange( + 0, + logsAnteriores + ); + } + + rover.DadosLeitura = obj; + rover.UltimoContato = DateTime.Now; + rover.Alive = true; + } + } + + MarcarContatoRover(roverId); + + if (rover != null) + AgendarAtualizacaoTelemetriaUi(rover); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "Erro ao deserializar telemetria do rover " + + roverId + ": " + ex.Message + ); + } + + return Task.CompletedTask; + } + + private static Task ProcessarParametrosAsync( + string roverId, + MqttService.MqttTopicosMensagensModel message) + { + if (string.IsNullOrWhiteSpace(message.Mensagem)) + return Task.CompletedTask; + + try + { + OperacaoParametrosModel obj = + JsonConvert.DeserializeObject< + OperacaoParametrosModel>( + message.Mensagem + ); + + if (obj == null) + return Task.CompletedTask; + + lock (_RoversLock) + { + int index = RoversNaRede.FindIndex( + x => string.Equals( + x.RoverId, + roverId, + StringComparison.OrdinalIgnoreCase + ) + ); + + if (index >= 0) + { + List< + OperacaoSensoriamentoLogErrosModel> + logs = + RoversNaRede[index] + .DadosLeitura? + .Logs ?? + new List< + OperacaoSensoriamentoLogErrosModel>(); + + obj.RoverId = roverId; + obj.UltimoContato = DateTime.Now; + obj.Alive = true; + + if (obj.DadosLeitura == null) + { + obj.DadosLeitura = + new OperacaoParametrosDadosModel(); + } + + if (obj.DadosLeitura.Logs == null) + { + obj.DadosLeitura.Logs = + new List< + OperacaoSensoriamentoLogErrosModel>(); + } + + obj.DadosLeitura.Logs.AddRange(logs); + RoversNaRede[index] = obj; + } + } + + MarcarContatoRover(roverId); + AgendarAtualizacaoParametrosUi(obj); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + "Erro ao deserializar parâmetros do rover " + + roverId + ": " + ex.Message + ); + } + + return Task.CompletedTask; + } + + public static Task AtualizarListaRoversNaRede() + { + if (Interlocked.Exchange( + ref _updatingRovers, + 1) == 1) + { + return Task.CompletedTask; + } + + try + { + List snapshot = + GetRoversSnapshot(); + + foreach (OperacaoParametrosModel rover + in snapshot) + { + bool alive = + AppShell.Mock || + GetRoverContactAgeSeconds( + rover.RoverId, + rover.UltimoContato + ) < TempoRoverVivo; + + lock (_RoversLock) + { + OperacaoParametrosModel current = + RoversNaRede.FirstOrDefault( + x => string.Equals( + x.RoverId, + rover.RoverId, + StringComparison + .OrdinalIgnoreCase + ) + ); + + if (current == null) + continue; + + current.Alive = alive; + + if (!alive) + { + current.DadosLeitura = + new OperacaoParametrosDadosModel + { + Momento = DateTime.Now, + Operacao = + new OperacaoParametrosDadosOperacaoModel + { + Status = + AgroBase.Models.Enums + .StatusOperacao + .Erro + } + }; + } + } + + if (!alive) + { + Variaveis.Shell? + .AdicionarAlertaDock( + rover.RoverId, + AgroBase.Models.Enums.T_Code.Ipb, + SeveridadeAlerta.Critical, + "Perda de comunicação com o equipamento!" + ); + + Variaveis.Dock? + ._vm? + ._viewOperacaoCenter? + .Mapa? + .markers? + .UpdateMarkerInfo( + rover.RoverId, + status: + AgroBase.Models.Enums + .StatusOperacao.Erro + ); + } + else + { + Variaveis.Shell? + .RemoverAlertaDock( + rover.RoverId, + AgroBase.Models.Enums.T_Code.Ipb + ); + } + + Variaveis.Shell? + .Dock? + ._vm? + .AtualizarBarraSuperior(rover); + } + + Application.Current? + .Dispatcher? + .BeginInvoke( + new Action(() => + { + Variaveis.Shell? + .Dock? + ._vm? + .AtualizarListaRovers( + snapshot + ); + }) + ); + } + finally + { + Interlocked.Exchange( + ref _updatingRovers, + 0 + ); + } + + return Task.CompletedTask; + } + + public static async Task EnviarDadosHeartbeat() + { + MqttService service = + Variaveis.MqttServiceCritical; + + if (service == null) + return; + + KeyValuePair< + string, + MqttService.MqttTopicosModel>[] topics = + _heartbeatTopics.ToArray(); + + foreach (var item in topics) + { + MqttPublishResult result = + await service.PublishWithResultAsync( + item.Value, + "0" + ).ConfigureAwait(false); + + if (!result.Succeeded && + result.Status != + MqttPublishStatus.Disconnected) + { + Variaveis.MostrarLog( + "Falha no heartbeat para " + + item.Key + ": " + result.Error + ); + } } } - public static async void EnviarDadosPosicao(GPSModel posicao) + private static void EnviarDadosControle( + string rover_id, + OperacaoComandoBaseModel controle) { - var topico = Variaveis.MqttService.Topicos.FirstOrDefault(x => x.Topico == VariaveisMonitoramento.TopicoMqttPosicao); - if (topico != null) + Forget( + EnviarDadosControleAsync( + rover_id, + controle + ), + "Enviar comando para " + rover_id + ); + } + + private static async Task + EnviarDadosControleAsync( + string roverId, + OperacaoComandoBaseModel controle, + CancellationToken cancellationToken = + default(CancellationToken)) + { + if (string.IsNullOrWhiteSpace(roverId) || + roverId == BaseMarkerID || + controle == null) { - string json = JsonConvert.SerializeObject(posicao); - await Variaveis.MqttService.PublishAsync(topico, json); + return MqttPublishResult.Failure( + MqttPublishStatus.InvalidTopic, + "Rover ou comando inválido." + ); + } + + await GarantirTopicosRoverAsync(roverId) + .ConfigureAwait(false); + + MqttService.MqttTopicosModel topic; + + if (!_commandTopics.TryGetValue( + roverId, + out topic)) + { + return MqttPublishResult.Failure( + MqttPublishStatus.InvalidTopic, + "Tópico de comandos não configurado." + ); + } + + string json = + JsonConvert.SerializeObject(controle); + + return await Variaveis + .MqttServiceCritical + .PublishWithResultAsync( + topic, + json, + cancellationToken: + cancellationToken + ).ConfigureAwait(false); + } + + public static void EnviarDadosPosicao( + GPSModel posicao) + { + Forget( + EnviarDadosPosicaoAsync(posicao), + "Enviar posição da base" + ); + } + + public static async Task + EnviarDadosPosicaoAsync( + GPSModel posicao, + CancellationToken cancellationToken = + default(CancellationToken)) + { + if (posicao == null || + Variaveis.MqttServiceCritical == null || + Variaveis.TopicoPosicaoBase == null) + { + return MqttPublishResult.Failure( + MqttPublishStatus.InvalidTopic, + "Posição, serviço ou tópico inválido." + ); + } + + string json = + JsonConvert.SerializeObject(posicao); + + return await Variaveis + .MqttServiceCritical + .PublishWithResultAsync( + Variaveis.TopicoPosicaoBase, + json, + cancellationToken: + cancellationToken + ).ConfigureAwait(false); + } + + /// + /// Assinatura usada pelo GpsService da base. + /// A chamada apenas copia/enfileira e retorna imediatamente. + /// + public static void EnviarDadosCorrecaoRTCM( + byte[] correcao) + { + RtcmPublisherService publisher = + Variaveis.RtcmPublisher; + + if (publisher == null) + { + throw new InvalidOperationException( + "O publicador RTCM da base não está iniciado." + ); + } + + if (!publisher.TryEnqueue(correcao)) + { + throw new ArgumentException( + "O frame RTCM é inválido ou o publicador está encerrado.", + nameof(correcao) + ); } } - public static async void EnviarDadosCorrecaoRTCM(byte[] correcao) + public static bool TentarEnfileirarCorrecaoRTCM( + byte[] correcao) { - var topico = Variaveis.MqttService.Topicos.FirstOrDefault(x => x.Topico == VariaveisMonitoramento.TopicoMqttRTCM); - if (topico != null) + return Variaveis.RtcmPublisher? + .TryEnqueue(correcao) == true; + } + + public static RtcmPublisherMetrics + GetRtcmPublisherMetrics() + { + return Variaveis.RtcmPublisher? + .GetMetrics(); + } + + private static async Task + RequisitarParametrosOperacaoAsync( + string roverId) + { + await EnviarDadosControleAsync( + roverId, + new OperacaoComandoBaseModel + { + Momento = DateTime.Now, + Dispositivo = + AgroBase.Models.Enums.T_Code.Mod, + Tecla = + AgroBase.Models.Enums + .BotoesJoystick.Share + } + ).ConfigureAwait(false); + } + + private static async Task EnviarDiscoveryAckAsync( + string roverId, + string sessionId) + { + if (string.IsNullOrWhiteSpace(roverId) || + string.IsNullOrWhiteSpace(sessionId)) { - await Variaveis.MqttService.PublishAsync(topico, correcao); + return; } + + MqttService critical = + Variaveis.MqttServiceCritical; + + if (critical == null) + return; + + MqttService.MqttTopicosModel topic; + + if (!_discoveryAckTopics.TryGetValue( + roverId, + out topic)) + { + topic = + await critical.AdicionarNovoTopico( + BuildDiscoveryAckTopic(roverId), + inscrever: false, + mensagensManter: 0, + callback: null, + payloadType: MqttPayloadType.Text, + dispatchMode: + MqttDispatchMode.LatestOnly, + queueCapacity: 1, + overflowPolicy: + MqttQueueOverflowPolicy + .DropOldest, + qos: + MqttQosLevel + .AtMostOnce, + retain: false + ).ConfigureAwait(false); + + _discoveryAckTopics[roverId] = topic; + } + + var ack = new RoverDiscoveryAck + { + RoverId = roverId, + SessionId = sessionId, + BaseId = BaseMarkerID, + Accepted = true, + SentAtUtc = DateTime.UtcNow + }; + + await critical.PublishWithResultAsync( + topic, + JsonConvert.SerializeObject(ack) + ).ConfigureAwait(false); + } + + private static string BuildDiscoveryAckTopic( + string roverId) + { + return "agrobot/v1/rover/" + + roverId + + "/discovery_ack"; + } + + private static void MarcarContatoRover( + string roverId) + { + if (string.IsNullOrWhiteSpace(roverId)) + return; + + _roverLastContactMono[roverId] = + Stopwatch.GetTimestamp(); + } + + private static double GetRoverContactAgeSeconds( + string roverId, + DateTime fallback) + { + long timestamp; + + if (_roverLastContactMono.TryGetValue( + roverId, + out timestamp) && + timestamp > 0) + { + long delta = + Stopwatch.GetTimestamp() - + timestamp; + + if (delta <= 0) + return 0; + + return delta / + (double)Stopwatch.Frequency; + } + + if (fallback == DateTime.MinValue) + return double.PositiveInfinity; + + return Math.Max( + 0, + (DateTime.Now - fallback) + .TotalSeconds + ); + } + + private static void + AgendarAtualizacaoTelemetriaUi( + OperacaoParametrosModel rover) + { + if (rover == null || + string.IsNullOrWhiteSpace(rover.RoverId)) + { + return; + } + + if (!_telemetryUiScheduled.TryAdd( + rover.RoverId, + 0)) + { + return; + } + + Application.Current? + .Dispatcher? + .BeginInvoke( + new Action(() => + { + try + { + Variaveis.Dock? + ._vm? + .AtualizarDadosTela(rover); + } + finally + { + byte ignored; + + _telemetryUiScheduled + .TryRemove( + rover.RoverId, + out ignored + ); + } + }) + ); + } + + private static void + AgendarAtualizacaoParametrosUi( + OperacaoParametrosModel parametros) + { + if (parametros == null || + string.IsNullOrWhiteSpace( + parametros.RoverId)) + { + return; + } + + if (!_parameterUiScheduled.TryAdd( + parametros.RoverId, + 0)) + { + return; + } + + Application.Current? + .Dispatcher? + .BeginInvoke( + new Action(() => + { + try + { + Variaveis.Dock? + ._vm? + .AtualizarParametrosRover( + parametros + ); + } + finally + { + byte ignored; + + _parameterUiScheduled + .TryRemove( + parametros.RoverId, + out ignored + ); + } + }) + ); + } + + private static void + AgendarAtualizacaoListaRovers() + { + Application.Current? + .Dispatcher? + .BeginInvoke( + new Action(() => + { + Variaveis.Shell? + .Dock? + ._vm? + .AtualizarListaRovers( + GetRoversSnapshot() + ); + }) + ); + } + + private static void Forget( + Task task, + string context) + { + if (task == null) + return; + + _ = task.ContinueWith( + completed => + { + Exception ex = + completed.Exception? + .GetBaseException(); + + if (ex != null) + { + Variaveis.MostrarLog( + context + ": " + ex.Message + ); + } + }, + CancellationToken.None, + TaskContinuationOptions + .OnlyOnFaulted | + TaskContinuationOptions + .ExecuteSynchronously, + TaskScheduler.Default + ); } #region Controle @@ -563,4 +1865,732 @@ namespace OperationControl.Models #endregion } + + /// + /// Publicador exclusivo de RTCM. + /// + /// Mantém somente a mensagem pendente mais recente por tipo RTCM, + /// publica sequencialmente com QoS 0 e nunca retransmite correção velha. + /// + public sealed class RtcmPublisherService : IDisposable + { + private readonly Func _serviceProvider; + private readonly Func _topicProvider; + private readonly Action _log; + + private readonly object _queueLock = new object(); + private readonly object _metricsLock = new object(); + private readonly Dictionary + _latestByType = + new Dictionary(); + + private readonly SemaphoreSlim _signal = + new SemaphoreSlim(0, int.MaxValue); + + private CancellationTokenSource _cts; + private Task _worker; + private int _started; + private int _disposed; + + private long _sequence; + private long _received; + private long _queued; + private long _replaced; + private long _published; + private long _publishedBytes; + private long _droppedInvalid; + private long _droppedStale; + private long _droppedDisconnected; + private long _publishTimeouts; + private long _publishErrors; + private long _consecutiveFailures; + + private long _lastReceivedMono; + private long _lastPublishedMono; + private double _lastPublishDurationMs; + private double _averagePublishDurationMs; + private int _lastMessageType = -1; + private string _lastError; + + private const int MaxMessageBytes = 64 * 1024; + private const int MaxPendingTypes = 32; + private const int MaxAgeMs = 2500; + + public RtcmPublisherService( + Func serviceProvider, + Func + topicProvider, + Action log) + { + _serviceProvider = + serviceProvider ?? + throw new ArgumentNullException( + nameof(serviceProvider) + ); + + _topicProvider = + topicProvider ?? + throw new ArgumentNullException( + nameof(topicProvider) + ); + + _log = log ?? (_ => { }); + } + + public void Start() + { + ThrowIfDisposed(); + + if (Interlocked.Exchange( + ref _started, + 1) == 1) + { + return; + } + + _cts = new CancellationTokenSource(); + + _worker = Task.Run( + () => WorkerLoopAsync(_cts.Token), + _cts.Token + ); + } + + public bool TryEnqueue(byte[] frame) + { + if (Volatile.Read(ref _started) != 1 || + Volatile.Read(ref _disposed) == 1 || + frame == null || + frame.Length < 6 || + frame.Length > MaxMessageBytes || + frame[0] != 0xD3) + { + Interlocked.Increment( + ref _droppedInvalid + ); + + return false; + } + + int type = GetMessageType(frame); + + if (type < 0) + { + Interlocked.Increment( + ref _droppedInvalid + ); + + return false; + } + + byte[] copy = new byte[frame.Length]; + + Buffer.BlockCopy( + frame, + 0, + copy, + 0, + frame.Length + ); + + var envelope = new RtcmPublishEnvelope + { + Data = copy, + Type = type, + Sequence = Interlocked.Increment( + ref _sequence + ), + ReceivedMono = + Stopwatch.GetTimestamp(), + ReceivedUtc = DateTime.UtcNow + }; + + Interlocked.Increment(ref _received); + Interlocked.Exchange( + ref _lastReceivedMono, + envelope.ReceivedMono + ); + + bool signal; + + lock (_queueLock) + { + signal = _latestByType.Count == 0; + + if (_latestByType.ContainsKey(type)) + { + Interlocked.Increment( + ref _replaced + ); + } + else if (_latestByType.Count >= + MaxPendingTypes) + { + RtcmPublishEnvelope oldest = + _latestByType.Values + .OrderBy(x => x.Sequence) + .First(); + + _latestByType.Remove(oldest.Type); + + Interlocked.Increment( + ref _droppedStale + ); + } + + _latestByType[type] = envelope; + Interlocked.Increment(ref _queued); + } + + if (signal) + { + try { _signal.Release(); } + catch (SemaphoreFullException) { } + } + + return true; + } + + private async Task WorkerLoopAsync( + CancellationToken cancellationToken) + { + while (!cancellationToken + .IsCancellationRequested) + { + try + { + await _signal.WaitAsync( + cancellationToken + ).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + while (!cancellationToken + .IsCancellationRequested) + { + List batch; + + lock (_queueLock) + { + if (_latestByType.Count == 0) + break; + + batch = _latestByType.Values + .OrderBy(x => x.Sequence) + .ToList(); + + _latestByType.Clear(); + } + + foreach (RtcmPublishEnvelope envelope + in batch) + { + if (cancellationToken + .IsCancellationRequested) + { + return; + } + + if (AgeMs(envelope.ReceivedMono) > + MaxAgeMs) + { + Interlocked.Increment( + ref _droppedStale + ); + + continue; + } + + MqttService service = + _serviceProvider(); + + MqttService.MqttTopicosModel topic = + _topicProvider(); + + if (service == null || + topic == null || + !service.StatusConexao()) + { + Interlocked.Increment( + ref _droppedDisconnected + ); + + Interlocked.Increment( + ref _consecutiveFailures + ); + + SetLastError( + "MQTT crítico desconectado." + ); + + continue; + } + + MqttPublishResult result; + + try + { + result = + await service + .PublishWithResultAsync( + topic, + envelope.Data, + cancellationToken: + cancellationToken + ) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + Interlocked.Increment( + ref _publishErrors + ); + + Interlocked.Increment( + ref _consecutiveFailures + ); + + SetLastError(ex.Message); + continue; + } + + if (result.Succeeded) + { + Interlocked.Increment( + ref _published + ); + + Interlocked.Add( + ref _publishedBytes, + envelope.Data.LongLength + ); + + Interlocked.Exchange( + ref _lastPublishedMono, + Stopwatch.GetTimestamp() + ); + + Interlocked.Exchange( + ref _consecutiveFailures, + 0 + ); + + lock (_metricsLock) + { + _lastPublishDurationMs = + result.DurationMs; + + long count = + Interlocked.Read( + ref _published + ); + + if (count <= 1) + { + _averagePublishDurationMs = + result.DurationMs; + } + else + { + _averagePublishDurationMs += + ( + result.DurationMs - + _averagePublishDurationMs + ) / count; + } + + _lastMessageType = + envelope.Type; + + _lastError = null; + } + } + else + { + Interlocked.Increment( + ref _consecutiveFailures + ); + + switch (result.Status) + { + case MqttPublishStatus + .Disconnected: + case MqttPublishStatus + .ServiceStopping: + Interlocked.Increment( + ref _droppedDisconnected + ); + break; + + case MqttPublishStatus + .Timeout: + Interlocked.Increment( + ref _publishTimeouts + ); + break; + + default: + Interlocked.Increment( + ref _publishErrors + ); + break; + } + + SetLastError( + result.Error ?? + result.Status.ToString() + ); + } + } + } + } + } + + public async Task StopAsync() + { + if (Interlocked.Exchange( + ref _started, + 0) == 0) + { + return; + } + + CancellationTokenSource cts = _cts; + Task worker = _worker; + + _cts = null; + _worker = null; + + if (cts != null) + { + try { cts.Cancel(); } + catch { } + } + + try { _signal.Release(); } + catch (SemaphoreFullException) { } + + if (worker != null) + { + try + { + await worker.ConfigureAwait(false); + } + catch (OperationCanceledException) { } + } + + if (cts != null) + cts.Dispose(); + + lock (_queueLock) + _latestByType.Clear(); + } + + public RtcmPublisherMetrics GetMetrics() + { + int queueDepth; + + lock (_queueLock) + queueDepth = _latestByType.Count; + + double lastDuration; + double avgDuration; + int lastType; + string lastError; + + lock (_metricsLock) + { + lastDuration = + _lastPublishDurationMs; + + avgDuration = + _averagePublishDurationMs; + + lastType = _lastMessageType; + lastError = _lastError; + } + + return new RtcmPublisherMetrics + { + Running = + Volatile.Read(ref _started) == 1, + + Received = + Interlocked.Read(ref _received), + + Queued = + Interlocked.Read(ref _queued), + + Replaced = + Interlocked.Read(ref _replaced), + + Published = + Interlocked.Read(ref _published), + + PublishedBytes = + Interlocked.Read( + ref _publishedBytes + ), + + DroppedInvalid = + Interlocked.Read( + ref _droppedInvalid + ), + + DroppedStale = + Interlocked.Read( + ref _droppedStale + ), + + DroppedDisconnected = + Interlocked.Read( + ref _droppedDisconnected + ), + + PublishTimeouts = + Interlocked.Read( + ref _publishTimeouts + ), + + PublishErrors = + Interlocked.Read( + ref _publishErrors + ), + + ConsecutiveFailures = + Interlocked.Read( + ref _consecutiveFailures + ), + + QueueDepth = queueDepth, + LastReceivedAgeMs = + AgeMs( + Interlocked.Read( + ref _lastReceivedMono + ) + ), + + LastPublishedAgeMs = + AgeMs( + Interlocked.Read( + ref _lastPublishedMono + ) + ), + + LastPublishDurationMs = + lastDuration, + + AveragePublishDurationMs = + avgDuration, + + LastMessageType = lastType, + LastError = lastError + }; + } + + private void SetLastError(string error) + { + lock (_metricsLock) + _lastError = error; + + if (!string.IsNullOrWhiteSpace(error)) + _log("[RTCM Publisher] " + error); + } + + private static int GetMessageType( + byte[] message) + { + if (message == null || + message.Length < 5 || + message[0] != 0xD3) + { + return -1; + } + + return ( + (message[3] << 4) | + (message[4] >> 4) + ) & 0x0FFF; + } + + private static double AgeMs(long timestamp) + { + if (timestamp <= 0) + return double.PositiveInfinity; + + long delta = + Stopwatch.GetTimestamp() - + timestamp; + + if (delta <= 0) + return 0; + + return delta * 1000.0 / + Stopwatch.Frequency; + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) == 1) + throw new ObjectDisposedException( + nameof(RtcmPublisherService) + ); + } + + public void Dispose() + { + if (Interlocked.Exchange( + ref _disposed, + 1) == 1) + { + return; + } + + try + { + StopAsync() + .GetAwaiter() + .GetResult(); + } + catch { } + + _signal.Dispose(); + } + + private sealed class RtcmPublishEnvelope + { + public byte[] Data { get; set; } + public int Type { get; set; } + public long Sequence { get; set; } + public long ReceivedMono { get; set; } + public DateTime ReceivedUtc { get; set; } + } + } + + public sealed class RtcmPublisherMetrics + { + public bool Running { get; set; } + + public long Received { get; set; } + public long Queued { get; set; } + public long Replaced { get; set; } + public long Published { get; set; } + public long PublishedBytes { get; set; } + + public long DroppedInvalid { get; set; } + public long DroppedStale { get; set; } + public long DroppedDisconnected { get; set; } + public long PublishTimeouts { get; set; } + public long PublishErrors { get; set; } + public long ConsecutiveFailures { get; set; } + + public int QueueDepth { get; set; } + public double LastReceivedAgeMs { get; set; } + public double LastPublishedAgeMs { get; set; } + public double LastPublishDurationMs { get; set; } + public double AveragePublishDurationMs { get; set; } + + public int LastMessageType { get; set; } + public string LastError { get; set; } + } + + public sealed class OperationControlCommunicationMetrics + { + public MqttServiceMetrics Critical { get; set; } + public MqttServiceMetrics Monitoring { get; set; } + public RtcmPublisherMetrics Rtcm { get; set; } + public int RoverCount { get; set; } + } + + internal sealed class RoverDiscoveryInfo + { + [JsonProperty("rover_id")] + public string RoverId { get; set; } + + [JsonProperty("rover_ip")] + public string RoverIp { get; set; } + + [JsonProperty("session_id")] + public string SessionId { get; set; } + + public static RoverDiscoveryInfo Parse( + string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return null; + + string trimmed = payload.Trim(); + + if (trimmed.StartsWith( + "{", + StringComparison.Ordinal)) + { + RoverDiscoveryInfo json = + JsonConvert.DeserializeObject< + RoverDiscoveryInfo>( + trimmed + ); + + if (json == null || + string.IsNullOrWhiteSpace( + json.RoverId)) + { + return null; + } + + json.RoverId = json.RoverId.Trim(); + json.RoverIp = + (json.RoverIp ?? + string.Empty).Trim(); + + json.SessionId = + string.IsNullOrWhiteSpace( + json.SessionId) + ? null + : json.SessionId.Trim(); + + return json; + } + + string[] parts = trimmed.Split(','); + + if (parts.Length < 2 || + string.IsNullOrWhiteSpace(parts[0])) + { + return null; + } + + return new RoverDiscoveryInfo + { + RoverId = parts[0].Trim(), + RoverIp = parts[1].Trim(), + SessionId = null + }; + } + } + + internal sealed class RoverDiscoveryAck + { + [JsonProperty("rover_id")] + public string RoverId { get; set; } + + [JsonProperty("session_id")] + public string SessionId { get; set; } + + [JsonProperty("base_id")] + public string BaseId { get; set; } + + [JsonProperty("accepted")] + public bool Accepted { get; set; } + + [JsonProperty("sent_at_utc")] + public DateTime SentAtUtc { get; set; } + } } diff --git a/AgroBase/OperationControl/Services/GpsService.cs b/AgroBase/OperationControl/Services/GpsService.cs index 0f2fb7bfd..2c1211455 100644 --- a/AgroBase/OperationControl/Services/GpsService.cs +++ b/AgroBase/OperationControl/Services/GpsService.cs @@ -1,94 +1,322 @@ -using System.Text; -using System.IO.Ports; +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 AgroBase.Services; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using System.Windows; using AgroBase.Models; -using static AgroBase.Models.Enums; +using AgroBase.Services; using OperationControl.Models; -using Application = System.Windows.Application; +using static AgroBase.Models.Enums; using static OperationControl.Services.BaseFixService; +using Application = System.Windows.Application; namespace OperationControl.Services { - public class GpsService + /// + /// Serviço do UM982 da base. + /// + /// Princípios desta implementação: + /// 1) O callback serial apenas lê, separa e enfileira. Nunca publica na rede. + /// 2) NMEA e RTCM são demultiplexados por uma única máquina de estados. + /// 3) RTCM só é encaminhado após validação CRC24Q. + /// 4) A fila RTCM mantém apenas a mensagem mais nova de cada tipo. + /// 5) Mensagens RTCM antigas são descartadas antes da publicação. + /// 6) Reconectar a USB não altera silenciosamente o papel do GNSS. + /// 7) Todas as escritas no UM982 passam por um único lock. + /// 8) O cliente NTRIP possui cancelamento real e não mistura StreamReader com dados binários. + /// + public sealed class GpsService : IDisposable { + // ========================================================= + // CONFIGURAÇÃO GERAL + // ========================================================= + + private const int DefaultBaudRate = 115200; + private static readonly TimeSpan SerialSilenceReconnectTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan SerialReconnectGrace = TimeSpan.FromSeconds(10); + private static readonly TimeSpan RtcmAssemblyTimeout = TimeSpan.FromSeconds(1.5); + private static readonly TimeSpan NmeaAssemblyTimeout = TimeSpan.FromSeconds(2.0); + private static readonly TimeSpan RtcmMaxQueueAge = TimeSpan.FromSeconds(2.5); + + private readonly System.Timers.Timer _tmrCheck = new(2000) { AutoReset = true }; + private readonly SemaphoreSlim _scanGate = new(1, 1); + private readonly SemaphoreSlim _serialWriteGate = new(1, 1); + private readonly object _portLock = new(); + private readonly object _parserLock = new(); + private readonly object _modelLock = new(); + private readonly object _rtcmQueueLock = new(); + private readonly object _ntripLock = new(); + + private readonly CancellationTokenSource _lifetimeCts = new(); + private readonly SemaphoreSlim _rtcmSignal = new(0); + private readonly Dictionary _rtcmLatestByType = new(); + private readonly Task _rtcmPublisherTask; + + private int _timerRunning; + private int _disposed; + private int _uiUpdatePending; + + private double _serialConnectedMono; + private double _lastSerialRxMono; + private double _lastValidNmeaMono; + private double _lastValidGgaMono; + private double _lastValidRtcmMono; + private double _lastForwardedRtcmMono; + + private long _ggaSequence; + private long _rtcmSequence; + public GpsService() { - tmrCheck.Elapsed += (_, __) => tmrCheck_Elapsed(); - tmrCheck.Start(); + BaseFix = new BaseFixService(null, this); + + _rtcmPublisherTask = Task.Run( + () => RtcmPublisherLoopAsync(_lifetimeCts.Token), + _lifetimeCts.Token); + + _tmrCheck.Elapsed += (_, __) => _ = CheckConnectionAsync(); + _tmrCheck.Start(); } - private readonly System.Timers.Timer tmrCheck = new(5000) { AutoReset = true }; - private bool tmrCheckRunning = false; + // ========================================================= + // ESTADO PÚBLICO / COMPATIBILIDADE + // ========================================================= - // Porta que o serviço encontrou e está usando public SerialPort PortaGps { get; private set; } - public bool IsConnected => PortaGps != null && PortaGps.IsOpen; - public string PortName => PortaGps?.PortName; - // Taxa padrão do UM982 - private const int DefaultBaudRate = 115200; - - - private async void tmrCheck_Elapsed() + public bool IsConnected { - if (tmrCheckRunning) return; - tmrCheckRunning = true; - - if (!IsConnected) + get { - bool conectado = await ScanAndConnectAsync(); - if (conectado) + 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(); + + /// + /// Configure usuário e senha por variável de ambiente ou externamente. + /// Nunca mantenha a senha NTRIP no código-fonte. + /// + 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 :("); + Models.Variaveis.MostrarLog("Nenhum GPS UM982 encontrado."); } } - - tmrCheckRunning = false; + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Models.Variaveis.MostrarLog($"Erro ao verificar conexão do GNSS: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _timerRunning, 0); + } } - /// - /// Faz uma varredura nas portas COM disponíveis tentando encontrar o UM982. - /// Se encontrar, abre a porta e deixa o serviço marcado como conectado. - /// - public async Task ScanAndConnectAsync(int timeoutPorPortaMs = 1500, CancellationToken ct = default) + public async Task ScanAndConnectAsync( + int timeoutPorPortaMs = 1500, + CancellationToken ct = default) { - // Se já está conectado, não precisa procurar de novo if (IsConnected) return true; - var portas = SerialPort.GetPortNames() - .OrderBy(p => p) - .ToArray(); - - foreach (var portName in portas) + await _scanGate.WaitAsync(ct).ConfigureAwait(false); + try { - if (ct.IsCancellationRequested) - break; - - bool achou = await TryConnectOnPortAsync(portName, timeoutPorPortaMs, ct); - if (achou) - { - await ConfigurarModulo(false); + if (IsConnected) return true; - } - } - return false; + 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(); + } } - /// - /// Tenta abrir uma porta específica, ler um pedaço de dados - /// e verificar se tem "cara de GPS" (NMEA / UM982). - /// - private async Task TryConnectOnPortAsync(string portName, int timeoutMs, CancellationToken ct) + private async Task TryConnectOnPortAsync( + string portName, + int timeoutMs, + CancellationToken ct) { SerialPort porta = null; @@ -98,73 +326,91 @@ namespace OperationControl.Services { ReadTimeout = timeoutMs, WriteTimeout = timeoutMs, - NewLine = "\n" + NewLine = "\n", + DtrEnable = false, + RtsEnable = false, + ReadBufferSize = 64 * 1024, + WriteBufferSize = 16 * 1024, }; porta.Open(); - // 🔹 1. Tenta identificar via VERSION - if (await EhUm982PorVersionAsync(porta, timeoutMs, ct)) + if (!await EhUm982PorVersionAsync(porta, timeoutMs, ct).ConfigureAwait(false)) { - PortaGps = porta; - PortaGps.DataReceived -= PortaGPS_DataReceived; - PortaGps.DataReceived += PortaGPS_DataReceived; - return true; + // 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; + } } - // 🔹 2. Fallback: ativa NMEA - var nmea = $"gngga com2 1\r\n"; - porta.Write(Encoding.ASCII.GetBytes(nmea), 0, nmea.Length); - - await Task.Delay(200, ct); - - string recebido = await LerAmostraAsync(porta, timeoutMs, ct); - - if (EhGpsUm982OuNmea(recebido)) - { - PortaGps = porta; - PortaGps.DataReceived -= PortaGPS_DataReceived; - PortaGps.DataReceived += PortaGPS_DataReceived; - return true; - } - - // Não era GPS nessa porta, fecha - porta.Close(); - 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; } } - /// - /// Lê alguns bytes da porta durante um tempo máximo. - /// - private async Task LerAmostraAsync(SerialPort porta, int timeoutMs, CancellationToken ct) + private void RegisterConnectedPort(SerialPort porta) { - var inicio = DateTime.UtcNow; - var buffer = new StringBuilder(); + DisconnectInternal(); - while ((DateTime.UtcNow - inicio).TotalMilliseconds < timeoutMs && !ct.IsCancellationRequested) + lock (_portLock) { + PortaGps = porta; + PortaGps.DataReceived -= PortaGPS_DataReceived; + PortaGps.DataReceived += PortaGPS_DataReceived; + } + + ResetParsers(); + _serialConnectedMono = MonotonicNow(); + _lastSerialRxMono = 0; + } + + private async Task LerAmostraAsync( + SerialPort porta, + int timeoutMs, + CancellationToken ct) + { + double start = MonotonicNow(); + StringBuilder buffer = new(); + + while ((MonotonicNow() - start) * 1000.0 < timeoutMs) + { + ct.ThrowIfCancellationRequested(); + try { - // Quantos bytes tem disponíveis? - int bytes = porta.BytesToRead; - if (bytes > 0) + int available = porta.BytesToRead; + if (available > 0) { - byte[] temp = new byte[bytes]; - int lidos = porta.Read(temp, 0, bytes); - if (lidos > 0) + byte[] temp = new byte[available]; + int read = porta.Read(temp, 0, temp.Length); + if (read > 0) { - buffer.Append(Encoding.ASCII.GetString(temp, 0, lidos)); - // Se já tem um "$G" da vida, já é o bastante - if (buffer.ToString().Contains("$GP") || - buffer.ToString().Contains("$GN") || - buffer.ToString().Contains("$GNGGA") || - buffer.ToString().Contains("$GNTHS")) + 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; } @@ -176,126 +422,184 @@ namespace OperationControl.Services break; } - await Task.Delay(50, ct); + await Task.Delay(50, ct).ConfigureAwait(false); } return buffer.ToString(); } - /// - /// Lógica de detecção baseada no código antigo: - /// procura por sentenças NMEA típicas do módulo. - /// - private bool EhGpsUm982OuNmea(string recebido) + private static bool EhGpsUm982OuNmea(string recebido) { if (string.IsNullOrEmpty(recebido)) return false; - // Mesmos "marcadores" que você usava no SerialService.ProcurarDispositivoGPS - // ($GPTXT, $GPRMC, $GPGGA, $GPGLL, $GNGGA, $GPVTG, $GPGSV, $GNTHS etc.) - return - recebido.Contains("$GPTXT") || - recebido.Contains("$GPRMC") || - recebido.Contains("$GPGGA") || - recebido.Contains("$GPGLL") || - recebido.Contains("$GNGGA") || - recebido.Contains("$GPVTG") || - recebido.Contains("$GPGSV") || - recebido.Contains("$GNTHS"); + return recebido.Contains("UM982", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPTXT", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPRMC", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPGGA", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPGLL", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GNGGA", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPVTG", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GPGSV", StringComparison.OrdinalIgnoreCase) || + recebido.Contains("$GNTHS", StringComparison.OrdinalIgnoreCase); } - private async Task EhUm982PorVersionAsync(SerialPort porta, int timeoutMs, CancellationToken ct) + private async Task EhUm982PorVersionAsync( + SerialPort porta, + int timeoutMs, + CancellationToken ct) { try { - var cmd = "version\r\n"; porta.DiscardInBuffer(); - porta.Write(cmd); + byte[] cmd = Encoding.ASCII.GetBytes("version\r\n"); + porta.Write(cmd, 0, cmd.Length); - await Task.Delay(100, ct); + await Task.Delay(100, ct).ConfigureAwait(false); + string response = await LerAmostraAsync(porta, timeoutMs, ct).ConfigureAwait(false); - string resposta = await LerAmostraAsync(porta, timeoutMs, ct); - - if (!string.IsNullOrEmpty(resposta) && - resposta.Contains("UM982")) - { - return true; - } + return !string.IsNullOrEmpty(response) && + response.Contains("UM982", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; } - catch { } - - return false; } - /// - /// Fecha a porta do GPS (se estiver aberta). - /// 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 { - if (PortaGps != null) - { - if (PortaGps.IsOpen) - PortaGps.Close(); - PortaGps.Dispose(); - } + 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); } - catch { } finally { - PortaGps = null; + _serialWriteGate.Release(); } } + internal Task WriteSerialCommandAsync( + string command, + CancellationToken ct = default) + { + return WriteSerialAsync(Encoding.ASCII.GetBytes(command), ct); + } + internal async Task SendCommandsAsync( + IEnumerable commands, + int delayMs, + CancellationToken ct = default) + { + foreach (string command in commands) + { + ct.ThrowIfCancellationRequested(); + await WriteSerialCommandAsync(command, ct).ConfigureAwait(false); + if (delayMs > 0) + await Task.Delay(delayMs, ct).ConfigureAwait(false); + } + } + // ========================================================= + // CONFIGURAÇÃO DO MÓDULO + // ========================================================= - - - - private bool InverterHeading = false; - private bool LoopRTK_Ntrip = false; - public bool Ntrip_Conectado = false; - private bool CorrecaoRTK_Ntrip = false; - private int TempoSurveryIn = 120; - public int TaxaAmostragemHz = 5; - private int rtk_timeout = 60; - public GPSModel UltimaLeitura = new GPSModel(); - public BaseFixService BaseFix; - public GeoLeverArm LeverArm = new GeoLeverArm(); - - private readonly StringBuilder _nmeaBuffer = new(); - private bool _nmeaInSentence = false; - private bool _nmeaWaitingType = false; - private readonly object _lock = new(); - private List _rtcmMsg = new(); - private int _rtcmTotalBytes = -1; - - public async Task ConfigurarModulo(bool fixar, MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip, double? lat = null, double? lon = null, double? alt = null, bool? offset = false) + 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..."); - if (!(offset ?? false)) - { - BaseFix = new BaseFixService(PortaGps, this); - } + BaseFix ??= new BaseFixService(PortaGps, this); BaseFix.FixLiberado = fixar; - string portaUsb = "com2"; - string portaSaida = "com2"; - string portaEntrada = "com2"; - string baseId = "957"; + const string portaUsb = "com2"; + const string portaSaida = "com2"; + const string portaEntrada = "com2"; + const string baseId = "957"; if (!fixar) { - Models.Variaveis.MostrarLog("Configurando módulo como rover parado..."); - await BaseFix.ConfigurarComoRoverParadoAsync(portaUsb: portaUsb, portaEntrada: portaEntrada); + 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 = ""; + string mensagem; try { @@ -309,57 +613,80 @@ namespace OperationControl.Services switch (metodo) { case MetodoFixacaoBase.Ntrip: - BaseFix.DefinirTempos(segsFixEstavel: TempoSurveryIn, segsJanelaSegs: 600, metodo: metodo); + BaseFix.DefinirTempos( + segsFixEstavel: TempoSurveryIn, + segsJanelaSegs: 600, + metodo: metodo); + sucesso = await BaseFix.FixarBaseViaNtripAsync( portaUsb: portaUsb, portaSaida: portaSaida, portaEntrada: portaEntrada, - startNtrip: () => - { - Models.Variaveis.MostrarLog("Iniciando correção NTRIP..."); - CorrecaoRTK_Ntrip = true; - _ = Task.Run(async () => await AplicarCorrecaoRTK_Ntrip()); - return Task.CompletedTask; - }, - stopNtrip: () => - { - Models.Variaveis.MostrarLog("Parando correção NTRIP..."); - CorrecaoRTK_Ntrip = false; - return Task.CompletedTask; - } - ); + baseId: baseId, + startNtrip: StartNtripAsync, + stopNtrip: StopNtripAsync, + ct: _lifetimeCts.Token).ConfigureAwait(false); break; case MetodoFixacaoBase.SurveyIn: - BaseFix.DefinirTempos(segsFixEstavel: TempoSurveryIn, segsJanelaSegs: TempoSurveryIn + 10, metodo: metodo); - await ConfigurarModuloBase(tempo_fixacao: TempoSurveryIn, porta_usb: portaUsb, porta_saida: portaSaida); - while (UltimaLeitura.QualidadeFix != TiposCorrecaoGPS.BaseFix && BaseFix.inicioFix != null && (DateTime.UtcNow - BaseFix.inicioFix).Value.TotalSeconds < TempoSurveryIn) + 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(1000); + await Task.Delay(500, _lifetimeCts.Token).ConfigureAwait(false); } - sucesso = true; + + sucesso = GetFixQualitySnapshot() == TiposCorrecaoGPS.BaseFix; break; case MetodoFixacaoBase.Manual: if (lat.HasValue && lon.HasValue && alt.HasValue) { - BaseFix.DefinirTempos(segsFixEstavel: 10, segsJanelaSegs: 10, metodo: metodo); - await BaseFix.AplicarBaseFixAsync(portaUsb, portaSaida, baseId, lat.Value, lon.Value, alt.Value); + BaseFix.DefinirTempos(10, 10, metodo); + await BaseFix.AplicarBaseFixAsync( + portaUsb, + portaSaida, + baseId, + lat.Value, + lon.Value, + alt.Value, + _lifetimeCts.Token).ConfigureAwait(false); sucesso = true; } break; } - // Aguarda 10 segundos para ver se o UM982 pegou o BaseFix - DateTime fimFix = DateTime.UtcNow; - while (sucesso && UltimaLeitura.QualidadeFix != TiposCorrecaoGPS.BaseFix && BaseFix.inicioFix != null && (DateTime.UtcNow - fimFix).TotalSeconds < 10) + if (sucesso) { - await Task.Delay(1000); + 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) { - Models.Variaveis.MostrarLog($"Erro ao definir posicao da base: {ex.Message}"); + sucesso = false; + Models.Variaveis.MostrarLog($"Erro ao definir posição da base: {ex.Message}"); } finally { @@ -367,536 +694,800 @@ namespace OperationControl.Services BaseFix.FixLiberado = false; BaseFix.CorrecaoEmAndamento = false; - sucesso = UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.BaseFix; - 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}."; - - if (!(offset ?? false)) + if (sucesso) { - if (sucesso) + var snapshot = GetPositionSnapshot(); + _expectedRole = GnssExpectedRole.BaseFixed; + _lastBaseConfiguration = new BaseFixedConfiguration { - BaseFix.LatitudeFix = UltimaLeitura.Latitude; - BaseFix.LongitudeFix = UltimaLeitura.Longitude; - BaseFix.AltitudeElpsoidalFix = UltimaLeitura.AltitudeElipsoidal; - BaseFix.OrientacaoFix = UltimaLeitura.OrientacaoReal; - } + PortaUsb = portaUsb, + PortaSaida = portaSaida, + BaseId = baseId, + Latitude = snapshot.Latitude, + Longitude = snapshot.Longitude, + AltitudeElipsoidal = snapshot.AltitudeElipsoidal, + }; - if (AppShell.Mock && lat.HasValue && lon.HasValue && alt.HasValue) + 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; } + 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) + private async Task ConfigurarModuloBase( + string porta_usb = "com3", + string porta_saida = "com2", + int tempo_fixacao = 60, + CancellationToken ct = default) { - string base_id = "957"; - string distancia_min = "0"; - string[] comandos = { - // Bauds - $"config com1 115200\r\n", - $"config com2 115200\r\n", - $"config com3 115200\r\n", + const string baseId = "957"; + const string distanciaMin = "0"; - // limpa logs das portas - $"unlog com1\r\n", - $"unlog com2\r\n", - $"unlog com3\r\n", - - // Base com Survey-In (tempo + acurácia) - $"mode base {base_id} time {tempo_fixacao} {distancia_min}\r\n", - - // RTCM perfil (comece leve; ative mais constelações se o LoRa aguentar) + 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", // GPS MSM4 - $"RTCM1124 {porta_saida} 1\r\n", // BeiDou MSM4 - - // (Opcional) ativar mais constelações: - $"RTCM1094 {porta_saida} 1\r\n", // Galileo MSM4 - $"RTCM1084 {porta_saida} 1\r\n", // GLONASS MSM4 - //$"RTCM1230 {porta_saida} 10\r\n",// Bias GLONASS (OBRIGATÓRIO se 1084 estiver ativo) - - // NMEA mínimo para debug na USB + $"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", + "saveconfig\r\n", }; - await Task.Delay(2000); - - foreach (string cmd in comandos) - { - try - { - byte[] bytes = Encoding.ASCII.GetBytes(cmd); - PortaGps?.Write(bytes, 0, bytes.Length); - } - catch (Exception ex) - { - Models.Variaveis.MostrarLog($"Erro ao enviar comando para o GNSS: {ex.Message}"); - } - - await Task.Delay(500); - } + 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 _rtcmBuffer = new(1100); + private readonly List _nmeaBuffer = new(256); + private int _rtcmExpectedBytes = -1; + private double _frameStartedMono; + private void PortaGPS_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { - if (!PortaGps.IsOpen) + SerialPort port; + lock (_portLock) + port = PortaGps; + + if (port == null || !port.IsOpen) return; - int bytesToRead = PortaGps.BytesToRead; - if (bytesToRead <= 0) + int available = port.BytesToRead; + if (available <= 0) return; - byte[] buffer = new byte[bytesToRead]; - PortaGps.Read(buffer, 0, bytesToRead); + byte[] buffer = new byte[available]; + int bytesRead = port.Read(buffer, 0, buffer.Length); + if (bytesRead <= 0) + return; - lock (_lock) + double now = MonotonicNow(); + _lastSerialRxMono = now; + Interlocked.Add(ref _serialBytesReceived, bytesRead); + + List nmeaLines = new(); + List rtcmMessages = new(); + + lock (_parserLock) { - for (int i = 0; i < buffer.Length; i++) + for (int i = 0; i < bytesRead; i++) { - byte b = buffer[i]; - - // 1) Tratar NMEA (ASCII, linha por linha) - TratarByteNmea(b); - - // 2) Tratar RTCM (binário, mensagem por mensagem) - TratarByteRtcm(b); + ProcessIncomingByte(buffer[i], nmeaLines, rtcmMessages); } } - UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.Inicializado = true; + 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 TratarByteNmea(byte b) + private void ProcessIncomingByte( + byte b, + List nmeaLines, + List rtcmMessages) { - // 1) Detectar início potencial de NMEA - if (b == (byte)'$') + double now = MonotonicNow(); + + if (_parserMode == ParserMode.Rtcm && + (now - _frameStartedMono) > RtcmAssemblyTimeout.TotalSeconds) { - // Começa uma possível sentença - _nmeaBuffer.Clear(); - _nmeaBuffer.Append('$'); - _nmeaWaitingType = true; // próximo byte decide se é NMEA mesmo - _nmeaInSentence = false; - return; + ResetParserState(); + Interlocked.Increment(ref _rtcmResyncs); + } + else if (_parserMode == ParserMode.Nmea && + (now - _frameStartedMono) > NmeaAssemblyTimeout.TotalSeconds) + { + ResetParserState(); + Interlocked.Increment(ref _nmeaInvalid); } - // 2) Logo após o '$', validar se é realmente NMEA ($G...) - if (_nmeaWaitingType) + switch (_parserMode) { - _nmeaWaitingType = false; + case ParserMode.Idle: + StartFrameIfApplicable(b, now); + break; - if (b == (byte)'G') // Aceitamos apenas $G... - { - _nmeaBuffer.Append('G'); - _nmeaInSentence = true; - } - else - { - // Não é $G => descarta, era lixo dentro de RTCM - _nmeaBuffer.Clear(); - _nmeaInSentence = false; - } - return; - } + case ParserMode.Nmea: + // Uma sentença quebrada não pode esconder um RTCM novo. + if (b == 0xD3) + { + Interlocked.Increment(ref _nmeaInvalid); + StartRtcm(now); + return; + } - // 3) Se ainda não estamos dentro de uma sentença NMEA, ignora - if (!_nmeaInSentence) - return; + if (b == (byte)'$') + { + // Reinicia na sentença NMEA mais recente. + StartNmea(now); + return; + } - // 4) Já estamos no meio de uma sentença NMEA válida ($G...) - // Ignora CR - if (b == (byte)'\r') - 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; + } - // Fim de linha: processar sentença completa - if (b == (byte)'\n') - { - string linha = _nmeaBuffer.ToString(); - _nmeaBuffer.Clear(); - _nmeaInSentence = false; + if (b == (byte)'\r' || (b >= 0x20 && b <= 0x7E)) + { + _nmeaBuffer.Add(b); + } + else + { + ResetParserState(); + Interlocked.Increment(ref _nmeaInvalid); + StartFrameIfApplicable(b, now); + return; + } - if (!string.IsNullOrWhiteSpace(linha)) - { - ProcessarDadosNMEA(linha.Trim()); - } - return; - } + if (_nmeaBuffer.Count > 512) + { + ResetParserState(); + Interlocked.Increment(ref _nmeaInvalid); + } + break; - // Apenas caracteres ASCII “visíveis” - if (b >= 0x20 && b <= 0x7E) - { - _nmeaBuffer.Append((char)b); - } + case ParserMode.Rtcm: + _rtcmBuffer.Add(b); - // (Opcional) se der algum BO e a frase ficar gigante, reseta: - if (_nmeaBuffer.Length > 200) - { - _nmeaBuffer.Clear(); - _nmeaInSentence = false; + 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 TratarByteRtcm(byte b) + private void StartFrameIfApplicable(byte b, double now) { - // Se ainda não começamos uma mensagem, procuramos pelo preâmbulo 0xD3 - if (_rtcmMsg.Count == 0) - { - if (b != 0xD3) - return; // ignora até achar 0xD3 + if (b == 0xD3) + StartRtcm(now); + else if (b == (byte)'$') + StartNmea(now); + } - _rtcmMsg.Add(b); // adiciona preâmbulo - _rtcmTotalBytes = -1; + 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; } - _rtcmMsg.Add(b); - - // Quando tivermos 3 bytes, conseguimos saber o tamanho do payload - if (_rtcmMsg.Count == 3 && _rtcmTotalBytes < 0) + RtcmEnvelope envelope = new() { - int len = ((_rtcmMsg[1] & 0x03) << 8) | _rtcmMsg[2]; // 10 bits de tamanho - _rtcmTotalBytes = 3 + len + 3; // header (3) + payload (len) + crc (3) + 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); } - // Quando chegarmos ao tamanho esperado, fechamos a mensagem - if (_rtcmTotalBytes > 0 && _rtcmMsg.Count == _rtcmTotalBytes) + if (shouldSignal) { - byte[] msg = _rtcmMsg.ToArray(); - - // Aqui você já tem UMA mensagem RTCM completa - OnRtcmMessage(msg); - - // Reseta para esperar a próxima - _rtcmMsg.Clear(); - _rtcmTotalBytes = -1; + try { _rtcmSignal.Release(); } catch (SemaphoreFullException) { } } } - private void OnRtcmMessage(byte[] msg) + private async Task RtcmPublisherLoopAsync(CancellationToken ct) { - //Models.Variaveis.MostrarLog($"RTCM msg recebida: {msg.Length} bytes"); - VariaveisControleOperacao.EnviarDadosCorrecaoRTCM(msg); + while (!ct.IsCancellationRequested) + { + try + { + await _rtcmSignal.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + while (!ct.IsCancellationRequested) + { + List batch; + + lock (_rtcmQueueLock) + { + if (_rtcmLatestByType.Count == 0) + break; + + batch = _rtcmLatestByType.Values + .OrderBy(x => x.Sequence) + .ToList(); + + _rtcmLatestByType.Clear(); + } + + foreach (RtcmEnvelope item in batch) + { + if (ct.IsCancellationRequested) + break; + + double age = MonotonicNow() - item.ReceivedMono; + if (age > RtcmMaxQueueAge.TotalSeconds) + { + Interlocked.Increment(ref _rtcmDroppedStale); + continue; + } + + try + { + // A chamada externa fica isolada neste worker. Se MQTT bloquear, + // a serial continua lendo e a fila mantém somente o RTCM mais novo por tipo. + VariaveisControleOperacao.EnviarDadosCorrecaoRTCM(item.Data); + _lastForwardedRtcmMono = MonotonicNow(); + Interlocked.Increment(ref _rtcmForwarded); + } + catch (Exception ex) + { + Interlocked.Increment(ref _rtcmPublishErrors); + Models.Variaveis.MostrarLog( + $"Erro ao encaminhar RTCM tipo {item.Type}: {ex.Message}"); + } + } + } + } } + private static int GetRtcmMessageType(byte[] message) + { + if (message == null || message.Length < 8 || message[0] != 0xD3) + return -1; + + return (message[3] << 4) | (message[4] >> 4); + } + + private static bool ValidateRtcmCrc24Q(byte[] message) + { + if (message == null || message.Length < 6) + return false; + + uint calculated = ComputeCrc24Q(message, 0, message.Length - 3); + uint received = + ((uint)message[^3] << 16) | + ((uint)message[^2] << 8) | + message[^1]; + + return calculated == received; + } + + private static uint ComputeCrc24Q(byte[] data, int offset, int count) + { + uint crc = 0; + + for (int i = offset; i < offset + count; i++) + { + crc ^= (uint)data[i] << 16; + + for (int bit = 0; bit < 8; bit++) + { + crc <<= 1; + if ((crc & 0x1000000) != 0) + crc ^= 0x1864CFB; + } + } + + return crc & 0xFFFFFF; + } + + // ========================================================= + // NMEA + // ========================================================= + + private void ProcessNmeaSafely(string sentence) + { + if (string.IsNullOrWhiteSpace(sentence)) + return; + + try + { + if (!TryValidateNmeaChecksum(sentence, out bool checksumPresent)) + { + Interlocked.Increment(ref _nmeaChecksumErrors); + Interlocked.Increment(ref _nmeaInvalid); + return; + } + + ProcessarDadosNMEA(sentence); + _lastValidNmeaMono = MonotonicNow(); + Interlocked.Increment(ref _nmeaValid); + + if (sentence.StartsWith("$GNGGA", StringComparison.Ordinal) || + sentence.StartsWith("$GPGGA", StringComparison.Ordinal) || + sentence.StartsWith("$GLGGA", StringComparison.Ordinal)) + { + _lastValidGgaMono = MonotonicNow(); + Interlocked.Increment(ref _ggaSequence); + } + } + catch (Exception ex) + { + Interlocked.Increment(ref _nmeaInvalid); + Models.Variaveis.MostrarLog($"Sentença NMEA inválida descartada: {ex.Message}"); + } + } + + private static bool TryValidateNmeaChecksum(string sentence, out bool checksumPresent) + { + checksumPresent = false; + + if (string.IsNullOrWhiteSpace(sentence) || sentence[0] != '$') + return false; + + int star = sentence.IndexOf('*'); + if (star < 0) + { + // Alguns comandos/respostas do fabricante podem não trazer checksum. + return true; + } + + checksumPresent = true; + if (star + 2 >= sentence.Length) + return false; + + byte checksum = 0; + for (int i = 1; i < star; i++) + checksum ^= (byte)sentence[i]; + + string expectedText = sentence.Substring(star + 1, 2); + return byte.TryParse( + expectedText, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out byte expected) && + checksum == expected; + } private void ProcessarDadosNMEA(string sentenca) { - DateTime Agora = DateTime.Now; - if (string.IsNullOrWhiteSpace(sentenca)) return; + DateTime agora = DateTime.Now; - //Console.WriteLine(sentenca); - - // Identifica o tipo de sentença - - // GPS Antigo - if (sentenca.StartsWith("$GPGGA")) + if (sentenca.StartsWith("$GPGGA", StringComparison.Ordinal)) { ProcessarGPGGA(sentenca); AtualizarCoordenadasGPS(); } - // Coordenadas - else if (sentenca.StartsWith("$GNGGA") || sentenca.StartsWith("$GLGGA")) + else if (sentenca.StartsWith("$GNGGA", StringComparison.Ordinal) || + sentenca.StartsWith("$GLGGA", StringComparison.Ordinal)) { ProcessarGNGGA(sentenca); AtualizarCoordenadasGPS(); } - // Variação magnética - else if (sentenca.StartsWith("$GNRMC")) - { - ProcessarGNRMC(sentenca); - } - // Curso verdadeiro - else if (sentenca.StartsWith("$GNVTG") || sentenca.StartsWith("$GPVTG")) - { - ProcessarGNVTG(sentenca); - } - // Satélites em vista - else if (sentenca.StartsWith("$GPGSV") || sentenca.StartsWith("$GLGSV") || sentenca.StartsWith("$GBGSV") || sentenca.StartsWith("$GAGSV")) - { - ProcessarGSV(sentenca); - } - // Orientação real - else if (sentenca.StartsWith("$GNTHS") || sentenca.StartsWith("$GPTHS") || sentenca.StartsWith("$GATHS")) - { - ProcessarGNTHS(sentenca); - //AtualizarCoordenadasGPS(); - } - // GLL (GP/GN/GL/GA/BD) - else if (sentenca.Length > 6 && sentenca[3] == 'G' && sentenca[4] == 'L' && sentenca[5] == 'L') - { - ProcessarGNGLL(sentenca); - } - // GSA (GP/GN/GL/GA/BD) - 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') // RMC + else if (sentenca.StartsWith("$GNRMC", StringComparison.Ordinal)) { ProcessarGxRMC(sentenca); } - else + else if (sentenca.StartsWith("$GNVTG", StringComparison.Ordinal) || + sentenca.StartsWith("$GPVTG", StringComparison.Ordinal)) { - Models.Variaveis.MostrarLog($"Sentença desconhecida: {sentenca}"); + 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); } - UltimaLeitura.UltimoComandoRespondido = Agora; + lock (_modelLock) + UltimaLeitura.UltimoComandoRespondido = agora; } private void ProcessarGPGGA(string sentenca) { string[] parts = sentenca.Split(','); + if (parts.Length < 10) + throw new FormatException("GPGGA incompleta."); - UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; - UltimaLeitura.Momento = DateTime.Now; - if (parts[2] != "" && parts[3] != "") + lock (_modelLock) { - UltimaLeitura.Latitude = GPSUtils.ConvertToDecimalDegrees(parts[2], parts[3], 2); + 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); } - if (parts[4] != "" && parts[5] != "") - { - UltimaLeitura.Longitude = GPSUtils.ConvertToDecimalDegrees(parts[4], parts[5], 3); - } - UltimaLeitura.NumeroSatelites = int.TryParse(parts[7], out int numSat) ? numSat : 0; - UltimaLeitura.PrecisaoHorizontal = double.TryParse(parts[8], NumberStyles.Float, CultureInfo.InvariantCulture, out double hdop) ? hdop : 0; - UltimaLeitura.Altitude = double.TryParse(parts[9], NumberStyles.Float, CultureInfo.InvariantCulture, out double alt) ? alt : 0; } private void ProcessarGNGGA(string sentenca) { - var ci = CultureInfo.InvariantCulture; - var campos = sentenca.Split(','); + string[] campos = sentenca.Split(','); + if (campos.Length < 15) + throw new FormatException("GNGGA incompleta."); - string horaUTC = campos.Length > 1 ? campos[1] : ""; - string latitudeRaw = campos.Length > 2 ? campos[2] : ""; - string hemisferioLat = campos.Length > 3 ? campos[3] : ""; - string longitudeRaw = campos.Length > 4 ? campos[4] : ""; - string hemisferioLon = campos.Length > 5 ? campos[5] : ""; - string qualidade = campos.Length > 6 ? campos[6] : "0"; - string satelitesUsados = campos.Length > 7 ? campos[7] : "0"; - string hdop = campos.Length > 8 ? campos[8] : "99.9"; - string altitudeRaw = campos.Length > 9 ? campos[9] : "0"; - string geoidSepRaw = campos.Length > 11 ? campos[11] : "0"; - string idadeCorrecaoRaw = campos.Length > 13 ? campos[13] : ""; - string base_id = campos.Length > 14 ? campos[14] : ""; + 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]; - // Conversão de Latitude (ddmm.mmmm) - double latitude = 0; - if (!string.IsNullOrEmpty(latitudeRaw)) - { - // lat tem 2 dígitos de graus - var deg = double.Parse(latitudeRaw.Substring(0, 2), ci); - var min = double.Parse(latitudeRaw.Substring(2), ci); - latitude = deg + (min / 60.0); - if (hemisferioLat.Equals("S", StringComparison.OrdinalIgnoreCase)) latitude *= -1; - } - - - // Conversão de Longitude (dddmm.mmmm) - double longitude = 0; - if (!string.IsNullOrEmpty(longitudeRaw)) - { - // lon tem 3 dígitos de graus - var deg = double.Parse(longitudeRaw.Substring(0, 3), ci); - var min = double.Parse(longitudeRaw.Substring(3), ci); - longitude = deg + (min / 60.0); - if (hemisferioLon.Equals("W", StringComparison.OrdinalIgnoreCase)) longitude *= -1; - } - - // Altitude MSL (campo 9) - double altMSL = 0; - double.TryParse(altitudeRaw, NumberStyles.Float, ci, out altMSL); - - // Geoid separation (campo 11) - double geoidSep = 0; - double.TryParse(geoidSepRaw, NumberStyles.Float, ci, out geoidSep); - - // Altura elipsoidal = MSL + geoid separation - double altElipsoidal = altMSL + geoidSep; - - // HDOP (adimensional) - double.TryParse(hdop, NumberStyles.Float, ci, out double hdopVal); - - int.TryParse(satelitesUsados, out int nsatelites); - int.TryParse(qualidade, out int fixCode); + double latitude = ParseDmm(latitudeRaw, hemisferioLat, 2); + double longitude = ParseDmm(longitudeRaw, hemisferioLon, 3); double idadeCorrecao = -1; - if (!string.IsNullOrWhiteSpace(idadeCorrecaoRaw)) - double.TryParse(idadeCorrecaoRaw, NumberStyles.Float, ci, out idadeCorrecao); - - //Console.WriteLine($"GNGGA: Hora={horaUTC}, Latitude={latitude}, Longitude={longitude}, Qualidade={qualidade}, Satélites={satelitesUsados}, HDOP={hdop}, Altitude={altitude}"); - - // Armazenar os valores na última leitura - UltimaLeitura.TimestampPos.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; - UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.LatitudeAnt = latitude; - UltimaLeitura.Latitude = latitude; - UltimaLeitura.LongitudeAnt = longitude; - UltimaLeitura.Longitude = longitude; - UltimaLeitura.Altitude = altMSL; - UltimaLeitura.AltitudeElipsoidal = altElipsoidal; - UltimaLeitura.PrecisaoHorizontal = hdopVal; - UltimaLeitura.NumeroSatelites = nsatelites; - UltimaLeitura.QualidadeFix = (AgroBase.Models.Enums.TiposCorrecaoGPS)fixCode; - UltimaLeitura.IdadeCorrecao = idadeCorrecao; - UltimaLeitura.BaseID = base_id; - - // Hora UTC no formato HHmmss.ss (fração opcional) - if (!string.IsNullOrEmpty(horaUTC) && horaUTC.Length >= 6) + if (!string.IsNullOrWhiteSpace(idadeRaw) && + double.TryParse( + idadeRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double parsedAge)) { - // Pega HHmmss e, se houver, fração: - var hh = int.Parse(horaUTC.Substring(0, 2), ci); - var mm = int.Parse(horaUTC.Substring(2, 2), ci); - var ssStr = horaUTC.Substring(4); // "ss" ou "ss.ss" - double ss = double.Parse(ssStr, ci); - var ts = new TimeSpan(0, hh, mm, (int)Math.Floor(ss), (int)Math.Round((ss - Math.Floor(ss)) * 1000.0)); - var currentDateUtc = DateTime.UtcNow.Date; - UltimaLeitura.DataHora = currentDateUtc.Add(ts).ToLocalTime(); + idadeCorrecao = parsedAge; } - // 1) define origem ENU na primeira leitura válida - if (!UltimaLeitura.EnuOriginSet && UltimaLeitura.QualidadeFix == AgroBase.Models.Enums.TiposCorrecaoGPS.RTKFixo) // tem fix + double altElipsoidal = altMsl + geoidSep; + + lock (_modelLock) { - UltimaLeitura.Lat0 = latitude; - UltimaLeitura.Lon0 = longitude; - UltimaLeitura.EnuOriginSet = true; + 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; + } } - - // 2) se já temos origem e um heading válido, aplica lever arm - if (UltimaLeitura.EnuOriginSet) - { - (double latCor, double lonCorr) = LeverArm.FixLeverArmLatLon_Fast(latitude, longitude, UltimaLeitura.OrientacaoReal, UltimaLeitura.TimestampOri.frequencia); - UltimaLeitura.Latitude = latCor; - UltimaLeitura.Longitude = lonCorr; - } - - } - - private void ProcessarGNRMC(string sentenca) - { - var campos = sentenca.Split(','); - - string horaUTC = campos[1].Replace(".", ","); - string status = campos[2].Replace(".", ","); - string latitudeRaw = campos[3].Replace(".", ","); - string hemisferioLat = campos[4].Replace(".", ","); - string longitudeRaw = campos[5].Replace(".", ","); - string hemisferioLon = campos[6].Replace(".", ","); - string velocidadeSobreSolo = campos[7].Replace(".", ","); - string curso = campos[8].Replace(".", ","); - string data = campos[9].Replace(".", ","); - string variaçãoMagnetica = campos[10].Replace(".", ","); - - double.TryParse(variaçãoMagnetica, out double variacaoMag); - - UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.VariacaoMagnetica = variacaoMag; - - //Console.WriteLine($"GNRMC: Hora={horaUTC}, Status={status}, Latitude={latitudeRaw}{hemisferioLat}, Longitude={longitudeRaw}{hemisferioLon}, Velocidade={velocidadeSobreSolo}, Curso={curso}, Data={data}, Variação Magnética={variaçãoMagnetica}"); } private void ProcessarGNVTG(string sentenca) { - var campos = sentenca.Split(','); + string[] campos = sentenca.Split(','); + if (campos.Length < 8) + throw new FormatException("VTG incompleta."); - string cursoVerdadeiro = campos[1].Replace(".", ","); - string referenciaCurso = campos[2].Replace(".", ","); // T = Verdadeiro, M = Magnético - string velocidadeSobreSoloKnots = campos[5].Replace(".", ","); - string velocidadeSobreSoloKmh = campos[7].Replace(".", ","); + double curso = ParseDouble(campos[1], 0); + double velocidadeKmh = ParseDouble(campos[7].Split('*')[0], 0); - //Console.WriteLine($"GNVTG: Curso Verdadeiro={cursoVerdadeiro}{referenciaCurso}, Velocidade (nós)={velocidadeSobreSoloKnots}, Velocidade (km/h)={velocidadeSobreSoloKmh}"); - - UltimaLeitura.Momento = DateTime.Now; - - double.TryParse(cursoVerdadeiro, out double curso); - UltimaLeitura.CursoVerdadeiro = curso; - - double.TryParse(velocidadeSobreSoloKmh, out double velocidade); - UltimaLeitura.Velocidade = velocidade; + lock (_modelLock) + { + UltimaLeitura.Momento = DateTime.Now; + UltimaLeitura.CursoVerdadeiro = curso; + UltimaLeitura.Velocidade = velocidadeKmh; + } } private void ProcessarGSV(string sentenca) { - var campos = sentenca.Split(','); + string[] campos = sentenca.Split(','); + if (campos.Length < 4 || sentenca.Length < 3) + throw new FormatException("GSV incompleta."); - string tipoSistema = sentenca.Substring(1, 2); // GP = GPS, GL = GLONASS, etc. - string totalSentencas = campos[1]; - string sentencaAtual = campos[2]; - string satelitesVisiveis = campos[3]; + string sistema = sentenca.Substring(1, 2); + int total = ParseInt(campos[1], 0); + int atual = ParseInt(campos[2], 0); + int visiveis = ParseInt(campos[3], 0); - UltimaLeitura.Momento = DateTime.Now; - - int.TryParse(sentencaAtual, out int sentAtual); - int.TryParse(totalSentencas, out int sentTotal); - int.TryParse(satelitesVisiveis, out int visiveis); - - if (!UltimaLeitura.SatelitesEmVista.Any(x => x.TipoSistema == tipoSistema)) + lock (_modelLock) { - UltimaLeitura.SatelitesEmVista.Add(new GPSSatelitesEmVistaModel() + UltimaLeitura.Momento = DateTime.Now; + + var leitura = UltimaLeitura.SatelitesEmVista + .FirstOrDefault(x => x.TipoSistema == sistema); + + if (leitura == null) { - TipoSistema = tipoSistema, - Sentencas = new List() - }); - } - - var leitura = UltimaLeitura.SatelitesEmVista.First(x => x.TipoSistema == tipoSistema); - - //Console.WriteLine($"GSV: Sistema={tipoSistema}, Sentença {sentencaAtual}/{totalSentencas}, Satélites Visíveis={satelitesVisiveis}"); - - if (!leitura.Sentencas.Any(x => x.SentencaAtual == sentAtual)) - { - leitura.Sentencas.Add(new GPSSatelitesEmVistaSentencaModel() - { - SentencaAtual = sentAtual, - SentencasTotal = sentTotal, - QuantidadeSatelites = visiveis, - Dados = new List() - }); - } - - var _sentenca = leitura.Sentencas.First(x => x.SentencaAtual == sentAtual); - - _sentenca.Dados = new List(); - - for (int i = 4; i < campos.Length; i += 4) - { - if (i + 3 < campos.Length) - { - string prn = campos[i].Replace(".", ","); - string elevacaoRaw = campos[i + 1].Replace(".", ","); - string azimuteRaw = campos[i + 2].Replace(".", ","); - string snrRaw = campos[i + 3].Replace(".", ","); - - //Console.WriteLine($" Satélite PRN={prn}, Elevação={elevacaoRaw}, Azimute={azimuteRaw}, SNR={snrRaw}"); - - double.TryParse(elevacaoRaw, out double elevacao); - double.TryParse(azimuteRaw, out double azimute); - double.TryParse(snrRaw, out double snr); - - _sentenca.Dados.Add(new GPSSatelitesEmVistaDadosModel() + leitura = new GPSSatelitesEmVistaModel { - PRN = prn, - Elevacao = elevacao, - Azimute = azimute, - QualidadeSinal = snr + TipoSistema = sistema, + Sentencas = new List(), + }; + UltimaLeitura.SatelitesEmVista.Add(leitura); + } + + var item = leitura.Sentencas.FirstOrDefault(x => x.SentencaAtual == atual); + if (item == null) + { + item = new GPSSatelitesEmVistaSentencaModel + { + SentencaAtual = atual, + SentencasTotal = total, + QuantidadeSatelites = visiveis, + Dados = new List(), + }; + leitura.Sentencas.Add(item); + } + + item.SentencasTotal = total; + item.QuantidadeSatelites = visiveis; + item.Dados = new List(); + + for (int i = 4; i + 3 < campos.Length; i += 4) + { + item.Dados.Add(new GPSSatelitesEmVistaDadosModel + { + PRN = campos[i], + Elevacao = ParseDouble(campos[i + 1], 0), + Azimute = ParseDouble(campos[i + 2], 0), + QualidadeSinal = ParseDouble(campos[i + 3].Split('*')[0], 0), }); } } @@ -904,97 +1495,61 @@ namespace OperationControl.Services private void ProcessarGNTHS(string sentenca) { - //Console.WriteLine(sentenca); - // Remove o caractere de início '$' e divide os campos - var campos = sentenca.TrimStart('$').Split(','); + string[] campos = sentenca.TrimStart('$').Split(','); + if (campos.Length < 3) + throw new FormatException("THS incompleta."); - if (campos.Length < 2) + string status = campos[2].Split('*')[0]; + bool headingValido = double.TryParse( + campos[1], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double heading); + + lock (_modelLock) { - Models.Variaveis.MostrarLog("Sentença incompleta."); - return; - } - - if (campos.Length > 2 && string.IsNullOrEmpty(campos[1])) - { - return; - } - - try - { - // Parsing dos campos - double headingTrue = double.Parse(campos[1].Replace(".", ",")); // Campo - string status = campos[2].Split('*')[0]; // Campo - string checksum = campos[2].Split('*')[1]; // Campo - UltimaLeitura.TimestampOri.valor = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency; UltimaLeitura.Momento = DateTime.Now; - UltimaLeitura.OrientacaoReal = InverterHeading ? GPSUtils.NormalizarAngulo(headingTrue - 180.0) : headingTrue; UltimaLeitura.TipoOrientacao = status; - } - catch (Exception ex) - { - Models.Variaveis.MostrarLog($"Erro ao processar a sentença: {ex.Message}"); + + 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) { - // Aceita GP/GL/GN… qualquer “G?GLL” - var campos = sentenca.Split(','); - if (campos.Length < 7) return; + string[] campos = sentenca.Split(','); + if (campos.Length < 7) + throw new FormatException("GLL incompleta."); - // lat/lon - string latRaw = campos[1]; - string latHem = campos[2]; - string lonRaw = campos[3]; - string lonHem = campos[4]; - string horaUTC = campos[5]; // hhmmss.ss - string status = campos[6]; // A/V - string mode = campos.Length > 7 ? campos[7].Split('*')[0] : ""; // pode não existir + string mode = campos.Length > 7 ? campos[7].Split('*')[0] : string.Empty; + bool valido = campos[6] == "A" && mode != "N"; - bool valido = status == "A" && mode != "N"; - - if (valido && !string.IsNullOrWhiteSpace(latRaw) && !string.IsNullOrWhiteSpace(lonRaw)) + lock (_modelLock) { - double lat = GPSUtils.DmmToDecimal(latRaw, 2); - double lon = GPSUtils.DmmToDecimal(lonRaw, 3); - if (!double.IsInfinity(lat) && !double.IsNaN(lat) && !double.IsInfinity(lon) && !double.IsNaN(lon)) + if (valido) { - if (latHem == "S") lat = -lat; - if (lonHem == "W") lon = -lon; + double lat = ParseDmm(campos[1], campos[2], 2); + double lon = ParseDmm(campos[3], campos[4], 3); - UltimaLeitura.Latitude = lat; - UltimaLeitura.Longitude = lon; + if (!double.IsNaN(lat)) UltimaLeitura.Latitude = lat; + if (!double.IsNaN(lon)) UltimaLeitura.Longitude = lon; } - } - // atualiza hora se veio no frame - if (!string.IsNullOrEmpty(horaUTC) && horaUTC.Length >= 6) - { - // hhmmss(.ss) - var hh = horaUTC.Substring(0, 2); - var mm = horaUTC.Substring(2, 2); - var ss = horaUTC.Substring(4); - if (TimeSpan.TryParseExact($"{hh}:{mm}:{ss}", @"hh\:mm\:ss\.ff", - CultureInfo.InvariantCulture, out var tod) - || TimeSpan.TryParseExact($"{hh}:{mm}:{ss}", @"hh\:mm\:ss", - CultureInfo.InvariantCulture, out tod)) - { + if (TryParseNmeaTime(campos[5], out TimeSpan tod)) UltimaLeitura.DataHora = DateTime.UtcNow.Date.Add(tod).ToLocalTime(); - } - } - // mapeia “mode” (se existir) para sua enum, sem sobrepor GGA melhor - // Ex.: A=Autonomous(1), D=DGPS(2), R=RTK Fix(4), F=RTK Float(5) - if (!string.IsNullOrEmpty(mode)) - { - if (mode == "R") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.RTKFixo; - else if (mode == "F") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.RTKFlutuante; - else if (mode == "D") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.DGPS; - else if (mode == "E") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.DeadReckoing; - else if (mode == "A") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.Autonomo; - else if (mode == "N") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.SemCorrecao; - else UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.SemCorrecao; + ApplyModeToFixQuality(mode); } AtualizarCoordenadasGPS(); @@ -1002,333 +1557,704 @@ namespace OperationControl.Services private void ProcessarGxGSA(string sentenca) { - // aceita $GPGSA, $GNGSA, $GLGSA, $GAGSA, $BDGSA… - var campos = sentenca.Split(','); - if (campos.Length < 17) return; + string[] campos = sentenca.Split(','); + if (campos.Length < 17) + throw new FormatException("GSA incompleta."); - string modoSelecao = campos[1]; // M/A - AgroBase.Models.Enums.TiposDimensaoCorrecaoGPS modoSolucao = (AgroBase.Models.Enums.TiposDimensaoCorrecaoGPS)GPSUtils.ParseInt(campos[2]); // 1/2/3 - // satélites usados: campos[3]..campos[14] int satsUsados = 0; for (int i = 3; i <= 14 && i < campos.Length; i++) if (!string.IsNullOrWhiteSpace(campos[i])) satsUsados++; - double pdop = GPSUtils.ParseDouble(campos[15]); - double hdop = GPSUtils.ParseDouble(campos[16]); - double vdop = (campos.Length > 17) ? GPSUtils.ParseDouble(campos[17].Split('*')[0]) : double.NaN; + 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; - // Atualiza apenas o que faz sentido complementar - if (!double.IsInfinity(hdop) && !double.IsNaN(hdop)) UltimaLeitura.PrecisaoHorizontal = hdop; - if (satsUsados > 0) UltimaLeitura.NumeroSatelites = Math.Max(UltimaLeitura.NumeroSatelites, satsUsados); + var fixDim = (TiposDimensaoCorrecaoGPS)ParseInt(campos[2], 1); - // Se você quiser guardar os DOPs: - UltimaLeitura.PDOP = !double.IsInfinity(pdop) && !double.IsNaN(pdop) ? pdop : UltimaLeitura.PDOP; - UltimaLeitura.VDOP = !double.IsInfinity(vdop) && !double.IsNaN(vdop) ? vdop : UltimaLeitura.VDOP; - - // Mapeia modoSolucao (não é igual ao “fix quality” do GGA!) - // 1=NoFix, 2=Fix2D, 3=Fix3D — pode guardar num campo próprio se tiver - UltimaLeitura.FixDimensao = modoSolucao; // crie int FixDimensao na sua struct, se não existir + 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) { - // Aceita $GPRMC, $GNRMC, $GLRMC, $GARMC, $BDRMC... - var raw = sentenca; - var campos = raw.Split(','); + string[] campos = sentenca.Split(','); + if (campos.Length < 12) + throw new FormatException("RMC incompleta."); - if (campos.Length < 12) return; - - string timeUTC = campos[1]; // hhmmss.ss - string status = campos[2]; // A=ativo, V=inválido - string latRaw = campos[3]; - string latHem = campos[4]; - string lonRaw = campos[5]; - string lonHem = campos[6]; - string spdKtsS = campos[7]; // knots - string cogS = campos[8]; // course over ground (graus) - string date = campos[9]; // ddmmyy - string magVarS = campos[10]; // pode estar vazio - string magHem = campos.Length > 11 ? campos[11] : ""; - // mode pode vir no campo 12 (sem checksum) ou 12 com outro e 13 com checksum, depende do firmware - string mode = ""; + string mode = string.Empty; if (campos.Length > 12) + mode = campos[12].Split('*')[0].Trim(); + + bool valido = campos[2] == "A"; + + lock (_modelLock) { - var tmp = campos[12]; - // tira checksum se estiver grudado - int asterix = tmp.IndexOf('*'); - mode = (asterix >= 0 ? tmp.Substring(0, asterix) : tmp).Trim(); - // alguns mandam mais um campo (navStatus) e o checksum só no final - if (mode.Length == 0 && campos.Length > 13) + if (valido) { - tmp = campos[13]; - asterix = tmp.IndexOf('*'); - mode = (asterix >= 0 ? tmp.Substring(0, asterix) : tmp).Trim(); + 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; } - } - bool valido = status == "A"; + if (double.TryParse(campos[7], NumberStyles.Float, CultureInfo.InvariantCulture, out double speedKnots)) + UltimaLeitura.Velocidade = speedKnots * 1.852; // km/h - // Converte lat/lon se válidos - if (valido && !string.IsNullOrWhiteSpace(latRaw) && !string.IsNullOrWhiteSpace(lonRaw)) - { - if (latRaw.Length >= 4 && lonRaw.Length >= 5) + 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)) { - double lat = GPSUtils.DmmToDecimal(latRaw, 2); - double lon = GPSUtils.DmmToDecimal(lonRaw, 3); - if (!double.IsNaN(lat) && !double.IsInfinity(lat) && !double.IsNaN(lon) && !double.IsInfinity(lon)) - { - if (latHem == "S") lat = -lat; - if (lonHem == "W") lon = -lon; - UltimaLeitura.Latitude = lat; - UltimaLeitura.Longitude = lon; - } + if (campos[11].StartsWith("W", StringComparison.OrdinalIgnoreCase)) + magVar = -magVar; + UltimaLeitura.VariacaoMagnetica = magVar; } - } - // Velocidade (knots -> m/s e km/h, se quiser guardar) - if (double.TryParse(spdKtsS, NumberStyles.Float, CultureInfo.InvariantCulture, out double spdKts)) - { - UltimaLeitura.Velocidade = spdKts; - } - - // Course over ground (graus) - if (double.TryParse(cogS, NumberStyles.Float, CultureInfo.InvariantCulture, out double cog)) - UltimaLeitura.CursoVerdadeiro = cog; - - // Data/hora (UTC) - // timeUTC: hhmmss(.ss), date: ddmmyy - DateTime? dt = null; - if (!string.IsNullOrEmpty(timeUTC) && timeUTC.Length >= 6 && !string.IsNullOrEmpty(date) && date.Length == 6) - { - string hh = timeUTC.Substring(0, 2); - string mm = timeUTC.Substring(2, 2); - string ss = timeUTC.Substring(4, 2); - - string dd = date.Substring(0, 2); - string MM = date.Substring(2, 2); - string yy = date.Substring(4, 2); - - // yy -> 20yy (assumindo 2000+; ajuste se precisar 19xx) - int year = 2000 + int.Parse(yy, CultureInfo.InvariantCulture); - if (int.TryParse(dd, out int d) && int.TryParse(MM, out int M) && - int.TryParse(hh, out int H) && int.TryParse(mm, out int m) && int.TryParse(ss, out int s)) - { - try - { - dt = new DateTime(year, M, d, H, m, s, DateTimeKind.Utc); - } - catch { /* ignora datas inválidas */ } - } - } - if (dt.HasValue) UltimaLeitura.DataHora = dt.Value.ToLocalTime(); - - // Variação magnética (se quiser armazenar) - if (double.TryParse(magVarS, NumberStyles.Float, CultureInfo.InvariantCulture, out double magVar)) - { - if (magHem == "W") magVar = -magVar; - UltimaLeitura.VariacaoMagnetica = magVar; - } - - // Mode → promove QualidadeFix (não rebaixa) - // A=Autônomo, D=DGPS, R=RTK Fix, F=RTK Float, N=No Fix - if (!string.IsNullOrEmpty(mode)) - { - if (mode == "R") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.RTKFixo; - else if (mode == "F") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.RTKFlutuante; - else if (mode == "D") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.DGPS; - else if (mode == "E") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.DeadReckoing; - else if (mode == "A") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.Autonomo; - else if (mode == "N") UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.SemCorrecao; - else UltimaLeitura.QualidadeFix = AgroBase.Models.Enums.TiposCorrecaoGPS.SemCorrecao; + ApplyModeToFixQuality(mode); } AtualizarCoordenadasGPS(); } - - private async Task AplicarCorrecaoRTK_Ntrip() + private void ApplyModeToFixQuality(string mode) { - if (!IsConnected || LoopRTK_Ntrip || !APIService.HasInternet) + if (string.IsNullOrWhiteSpace(mode)) return; - LoopRTK_Ntrip = true; - - // Configurações do NTRIP caster para RTK2Go - string host = "gps-ntrip.ibge.gov.br"; - int port = 2101; - string mountpoint = "EESC0"; - string username = "Zendion"; // Geralmente vazio para RTK2Go - string password = "QD&m1p60"; // Geralmente vazio para RTK2Go - - while (CorrecaoRTK_Ntrip && APIService.HasInternet) // Loop para reconectar em caso de falha + 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 { - // Construa o cabeçalho da solicitação - string credentials = string.IsNullOrEmpty(username) - ? "" - : Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); + client = new TcpClient(); + lock (_ntripLock) + _ntripClient = client; - string request = $"GET /{mountpoint} HTTP/1.0\r\n" + - $"User-Agent: NTRIP Client/2.0\r\n" + - $"Accept: */*\r\n" + - $"Connection: keep-alive\r\n" + - (!string.IsNullOrEmpty(credentials) ? $"Authorization: Basic {credentials}\r\n" : "") + - "\r\n"; + await ConnectTcpWithTimeoutAsync( + client, + NtripHost, + NtripPort, + TimeSpan.FromSeconds(8), + ct).ConfigureAwait(false); - // Estabeleça a conexão - using (TcpClient client = new TcpClient(host, port)) - using (NetworkStream stream = client.GetStream()) - using (StreamWriter writer = new StreamWriter(stream, Encoding.ASCII)) + 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) { - writer.Write(request); - writer.Flush(); - - // Leia a resposta - using (StreamReader reader = new StreamReader(stream, Encoding.ASCII)) - { - string response = await reader.ReadLineAsync(); - if (CorrecaoRTK_Ntrip) - { - if ((response ?? "").Contains("200 OK")) - { - Ntrip_Conectado = true; - Models.Variaveis.MostrarLog("Conexão bem-sucedida ao mountpoint!"); - - byte[] buffer = new byte[4096]; - int bytesRead; - while (CorrecaoRTK_Ntrip && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - try - { - if (PortaGps?.IsOpen ?? false) - { - PortaGps.Write(buffer, 0, bytesRead); // Envia os dados RTCM para o GPS - //Console.WriteLine($"Enviando {bytesRead} bytes de correção RTCM para o GPS"); - } - } - catch (Exception ex) - { - Models.Variaveis.MostrarLog($"Erro ao enviar correção RTCM para o módulo GNSS: {ex.Message}"); - } - } - } - else - { - Ntrip_Conectado = false; - Models.Variaveis.MostrarLog($"Falha na conexão com NTRIP: {response}"); - await Task.Delay(5000); // Aguarde antes de tentar novamente - break; - } - } - } + 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; - Models.Variaveis.MostrarLog($"Erro na conexão RTK: {ex.Message}"); - await Task.Delay(5000); // Aguarde antes de tentar novamente + 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); } } - LoopRTK_Ntrip = false; 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 data = new(1024); + byte[] temp = new byte[512]; + int headerEnd = -1; + int delimiterLength = 0; + + while (headerEnd < 0) + { + int read = await stream.ReadAsync(temp, 0, temp.Length, ct).ConfigureAwait(false); + if (read <= 0) + throw new IOException("Conexão encerrada antes do cabeçalho NTRIP."); + + for (int i = 0; i < read; i++) + data.Add(temp[i]); + + if (data.Count > maxHeaderBytes) + throw new InvalidDataException("Cabeçalho NTRIP excedeu o limite."); + + (headerEnd, delimiterLength) = FindNtripHeaderEnd(data); + } + + byte[] all = data.ToArray(); + string header = Encoding.ASCII.GetString(all, 0, headerEnd); + int bodyStart = headerEnd + delimiterLength; + byte[] body = bodyStart < all.Length + ? all.Skip(bodyStart).ToArray() + : Array.Empty(); + + return (header, body); + } + + private static (int HeaderEnd, int DelimiterLength) FindNtripHeaderEnd(List data) + { + // 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() { - UltimaLeitura.AnguloCarroDefinido = UltimaLeitura.OrientacaoReal; - UltimaLeitura.Ntrip_ativado = CorrecaoRTK_Ntrip; - UltimaLeitura.Heartbeat = (UltimaLeitura.Heartbeat + 1) % 10; + 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; - // Daqui pra baixo: jogar pra UI Application.Current?.Dispatcher?.BeginInvoke(new Action(() => { try { - //((App)Application.Current).Shell.Main?.AtualizarDadosTela_Telemetria(VariaveisControleOperacao.BaseMarkerID); - Models.Variaveis.Dock?._vm?.AtualizarDadosGnss(UltimaLeitura); } - catch (Exception exUi) + catch (Exception ex) { - Models.Variaveis.MostrarLog($"Erro ao atualizar UI GNSS: {exUi.Message}"); + 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) + public BaseFixService(SerialPort porta, GpsService service) { - _Porta = 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 SerialPort _Porta; private readonly GpsService gpsService; - private int _lastReadHeartbeat = -1; - public List amostras_pos = new List(1000); + private long _lastGgaSequence; + + public List amostras_pos = new(1000); public DateTime? inicioProcesso = null; public DateTime? inicioFix = null; public DateTime? fimProcesso = null; + private int segundosFixEstavel = 120; private int maxJanelaSegundos = 120; private MetodoFixacaoBase metodo = MetodoFixacaoBase.Ntrip; + public double Progresso { get { - double progresso = inicioFix is null ? 0 : fimProcesso != null ? 100 : (DateTime.UtcNow - inicioFix.Value).TotalSeconds / segundosFixEstavel * 100.0; - if (fimProcesso == null && progresso >= 100) - { - fimProcesso = DateTime.UtcNow; - } - return progresso; + 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 { - double progresso = inicioProcesso is null ? 0 : (DateTime.UtcNow - inicioProcesso.Value).TotalSeconds / maxJanelaSegundos * 100.0; - return progresso; + 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 { - string progresso = ""; if (CorrecaoEmAndamento) - { - progresso = $"Recebendo correção RTK via {metodo}. Progresso geral: {ProgressoGeral:F2}%, Progresso correção: {Progresso:F2}%"; - } - else if (PosicaoBaseFixada && CorrecaoAbsoluta && inicioProcesso.HasValue && fimProcesso.HasValue) - { - progresso = $"Correção absoluta concluída com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos"; - } - else if (PosicaoBaseFixada && !CorrecaoAbsoluta) - { - progresso = $"Correção relativa concluída em {segundosFixEstavel} segundos"; - } - else if (inicioProcesso.HasValue && fimProcesso.HasValue) - { - progresso = $"Correção absoluta falhou com {amostras_pos.Count} amostras em {(fimProcesso.Value - inicioProcesso.Value).TotalSeconds:F2} segundos"; - } - else - { - progresso = $"Correção absoluta não realizada"; - } - return progresso; + 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 PosicaoBaseFixada => + gpsService.UltimaLeitura.QualidadeFix == TiposCorrecaoGPS.BaseFix; + public bool CorrecaoAbsoluta = false; public bool CorrecaoEmAndamento = false; public bool FixLiberado = false; @@ -1337,157 +2263,171 @@ namespace OperationControl.Services public double AltitudeElpsoidalFix { get; set; } public double OrientacaoFix { get; set; } - public void DefinirTempos(int segsFixEstavel, int segsJanelaSegs, MetodoFixacaoBase metodo) + public void DefinirTempos( + int segsFixEstavel, + int segsJanelaSegs, + MetodoFixacaoBase metodo) { - segundosFixEstavel = segsFixEstavel; - maxJanelaSegundos = segsJanelaSegs; + 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(); } - // ===== 1) Função principal ===== - public async Task FixarBaseViaNtripAsync(string portaUsb = "com3", string portaEntrada = "com2", string portaSaida = "com2", string baseId = "957", double madK = 3.5, Func startNtrip = null, Func stopNtrip = null) + public async Task FixarBaseViaNtripAsync( + string portaUsb = "com3", + string portaEntrada = "com2", + string portaSaida = "com2", + string baseId = "957", + double madK = 3.5, + Func startNtrip = null, + Func stopNtrip = null, + CancellationToken ct = default) { if (!APIService.HasInternet) return false; - // 1.1 Config temporária como rover parado + NMEA - await ConfigurarComoRoverParadoAsync(portaUsb, portaEntrada); + await ConfigurarComoRoverParadoAsync(portaUsb, portaEntrada, ct) + .ConfigureAwait(false); - // 1.2 Ligar NTRIP (injeta RTCM na portaEntrada) - if (startNtrip != null) await startNtrip(); + if (startNtrip != null) + await startNtrip().ConfigureAwait(false); try { - //if (!gpsService.Ntrip_Conectado) - //{ - // Models.Variaveis.MostrarLog("Ntrip não conectado."); - // CorrecaoAbsoluta = false; - // FixLiberado = false; - // return CorrecaoAbsoluta; - //} + List samples = await EsperarFixEAmostrarAsync(ct).ConfigureAwait(false); - // 2) Esperar FIX sustentado e coletar GNGGA - var amostras = await EsperarFixEAmostrarAsync(); - - if (amostras.Count < 10) + if (samples.Count < 10) { - Models.Variaveis.MostrarLog("Poucas amostras de RTK FIX coletadas. Tente aumentar o tempo ou verificar sinais."); + Models.Variaveis.MostrarLog( + "Poucas amostras de RTK FIX coletadas. Verifique NTRIP e sinais GNSS."); CorrecaoAbsoluta = false; - return CorrecaoAbsoluta; + return false; } - // 3) Filtro robusto (MAD) + média final - var (lat, lon, h, heading, nAmostras) = FiltrarEAgrupar(amostras, madK); + 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; + } - // 4) Alternar para base FIX + perfil RTCM - await AplicarBaseFixAsync(portaUsb, portaSaida, baseId, lat, lon, h); + // 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}"); - Models.Variaveis.MostrarLog($"[BASE/FIX] Coordenadas aplicadas (n={nAmostras}): lat = {lat:0.000000000}, lon = {lon:0.000000000}, h = {h:0.000}"); CorrecaoAbsoluta = true; - return CorrecaoAbsoluta; + return true; } finally { - if (stopNtrip != null) await stopNtrip(); + if (stopNtrip != null) + { + try { await stopNtrip().ConfigureAwait(false); } + catch { } + } } } - // ===== 1.1 Rover parado + NMEA + limpar logs ===== - public async Task ConfigurarComoRoverParadoAsync(string portaUsb, string portaEntrada) + public async Task ConfigurarComoRoverParadoAsync( + string portaUsb, + string portaEntrada, + CancellationToken ct = default) { - Models.Variaveis.MostrarLog("Configurando base como modo rover parado..."); - string freq = "1.0"; - string[] cmds = { - // Ajuste de bauds - $"config {portaUsb} 115200\r\n", - $"config {portaEntrada} 115200\r\n", + Models.Variaveis.MostrarLog("Configurando GNSS temporariamente como rover parado..."); - // Limpa logs - $"unlog com1\r\n", - $"unlog com2\r\n", - $"unlog com3\r\n", - - // Rover parado (vamos usar NTRIP p/ obter FIX) - $"mode rover uav\r\n", - - // NMEA na USB - $"gngga {portaUsb} {freq}\r\n", - $"gpths {portaUsb} {freq}\r\n", - - $"saveconfig\r\n" - }; - - await Task.Delay(1000); - foreach (var c in cmds) + string[] commands = { - try - { - var b = Encoding.ASCII.GetBytes(c); - _Porta?.Write(b, 0, b.Length); - } - catch (Exception ex) - { - Models.Variaveis.MostrarLog($"Erro ao enviar comando de configuração GNSS: {ex.Message}"); - } - await Task.Delay(250); - } + $"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); } - // ===== 2) Coleta GNGGA com FIX sustentado ===== - private async Task> EsperarFixEAmostrarAsync() + private async Task> EsperarFixEAmostrarAsync(CancellationToken ct) { - Models.Variaveis.MostrarLog("Inciando coleta de dados..."); + Models.Variaveis.MostrarLog("Iniciando coleta de dados para fixação da base..."); amostras_pos = new List(1000); inicioProcesso = DateTime.UtcNow; - DateTime? inicioTentativaFix = DateTime.UtcNow; inicioFix = null; + _lastGgaSequence = gpsService.GetGgaSequence(); + + DateTime noFixDeadline = DateTime.UtcNow.AddMinutes(2); - // Você já deve ter um leitor da COM que devolve linhas NMEA. - // Abaixo, vamos supor um método async que lê GGA parseado. while (ProgressoGeral < 100 && FixLiberado) { - // Verifica se o timeout tentando aplicar fix ja excedeu - if (inicioTentativaFix.Value.AddMinutes(2) < DateTime.UtcNow && inicioFix is null) + ct.ThrowIfCancellationRequested(); + + if (inicioFix is null && DateTime.UtcNow >= noFixDeadline) { - Models.Variaveis.MostrarLog("Timeout ao tentar receber fix atraves do Ntrip..."); + Models.Variaveis.MostrarLog("Timeout aguardando RTK FIX via NTRIP."); break; } - // Lê próxima sentença (bloqueante/assíncrono) - var gga = await LerProximoGgaAsync(); // implemente no seu stack + GgaFix gga = await LerProximoGgaAsync(5000, ct).ConfigureAwait(false); + if (gga is null) + continue; - if (gga is null) continue; - - // Considera "RTK FIX" como qualidade válida - var modosValidos = new List() { TiposCorrecaoGPS.RTKFixo }; - if ((DateTime.UtcNow - inicioProcesso.Value).TotalSeconds > 120) modosValidos.Add(TiposCorrecaoGPS.RTKFlutuante); - if (!FixLiberado || !modosValidos.Contains(gga.FixQuality)) + bool valid = gga.FixQuality == TiposCorrecaoGPS.RTKFixo; + if (!valid && + inicioProcesso.HasValue && + (DateTime.UtcNow - inicioProcesso.Value).TotalSeconds > 120) { - inicioFix = null; // reset - inicioTentativaFix = DateTime.UtcNow; + valid = gga.FixQuality == TiposCorrecaoGPS.RTKFlutuante; + } + + if (!FixLiberado || !valid) + { + inicioFix = null; + amostras_pos.Clear(); continue; } - // Marca início da janela de FIX estável if (inicioFix is null) { - Models.Variaveis.MostrarLog("RTK Fixo definido! Iniciando coleta de dados com precisão..."); + Models.Variaveis.MostrarLog("RTK válido definido. Iniciando janela de coleta estável..."); inicioFix = DateTime.UtcNow; + amostras_pos.Clear(); } amostras_pos.Add(gga); - //Models.Variaveis.MostrarLog("Nova coordenada registrada!"); - // Verifica se já temos FIX estável pelo período necessário if (Progresso >= 100) break; } @@ -1495,168 +2435,209 @@ namespace OperationControl.Services return amostras_pos; } - // ===== 2.1) Ajuste a assinatura se quiser passar timeout e CT de fora - private async Task LerProximoGgaAsync(int timeoutMs = 5000, CancellationToken ct = default) + private async Task LerProximoGgaAsync( + int timeoutMs = 5000, + CancellationToken ct = default) { - var sw = System.Diagnostics.Stopwatch.StartNew(); - int startHb = System.Threading.Volatile.Read(ref _lastReadHeartbeat); + Stopwatch sw = Stopwatch.StartNew(); - // 1) Espera um novo heartbeat - while (!ct.IsCancellationRequested) + while (!ct.IsCancellationRequested && sw.ElapsedMilliseconds < timeoutMs) { - int currentHb = gpsService.UltimaLeitura.Heartbeat; // <- leitura normal da propriedade - if (currentHb != startHb) break; - - if (sw.ElapsedMilliseconds >= timeoutMs) - //throw new TimeoutException("Timeout aguardando nova leitura GGA."); - return null; - - await Task.Delay(75, ct).ConfigureAwait(false); - } - ct.ThrowIfCancellationRequested(); - - // 2) Snapshot consistente - while (true) - { - var ultimaLeitura = gpsService.UltimaLeitura; - int hbBefore = ultimaLeitura.Heartbeat; - - // Captura TODOS os campos que você precisa em variáveis locais - DateTime tsUtc = ultimaLeitura.DataHora.ToUniversalTime(); - double lat = ultimaLeitura.Latitude; - double lon = ultimaLeitura.Longitude; - double altElips = ultimaLeitura.AltitudeElipsoidal; // garanta que já é elipsoidal no parser - double heading = ultimaLeitura.OrientacaoReal; - var fixQual = ultimaLeitura.QualidadeFix; // enum? ok. - - int hbAfter = ultimaLeitura.Heartbeat; - - // Se o heartbeat não mudou durante o snapshot, temos dados coerentes - if (hbBefore == hbAfter) + if (gpsService.TryGetGgaSnapshot( + _lastGgaSequence, + out long sequence, + out GgaFix snapshot)) { - // marca como lido - System.Threading.Volatile.Write(ref _lastReadHeartbeat, hbAfter); - - // monta o DTO - return new GgaFix( - tsUtc: tsUtc, - latDeg: lat, - lonDeg: lon, - altElipsoidalM: altElips, - headingDeg: heading, - fixQuality: fixQual - ); + _lastGgaSequence = sequence; + return snapshot; } - // caso contrário, alguém atualizou no meio — tenta de novo rápido - await Task.Yield(); + await Task.Delay(50, ct).ConfigureAwait(false); } + + return null; } - // ===== 3) Filtro robusto (MAD) + média ===== - private (double lat, double lon, double h, double hdg, int n) FiltrarEAgrupar(List amostras, double madK = 3.5) + private (double lat, double lon, double h, double hdg, int n) FiltrarEAgrupar( + List samples, + double madK = 3.5) { Models.Variaveis.MostrarLog("Filtrando dados aferidos..."); - // Medianas - var lats = amostras.Select(a => a.LatDeg).OrderBy(x => x).ToArray(); - var lons = amostras.Select(a => a.LonDeg).OrderBy(x => x).ToArray(); - var hs = amostras.Select(a => a.AltElipsoidalM).OrderBy(x => x).ToArray(); - var hdgs = amostras.Select(a => a.HeadingDeg).OrderBy(x => x).ToArray(); - double medLat = Mediana(lats); - double medLon = Mediana(lons); - double medH = Mediana(hs); - double medHdg = Mediana(hdgs); + if (samples == null || samples.Count == 0) + return (double.NaN, double.NaN, double.NaN, double.NaN, 0); - // Desvios absolutos da mediana (MAD) - var dLat = amostras.Select(a => Math.Abs(a.LatDeg - medLat)).OrderBy(x => x).ToArray(); - var dLon = amostras.Select(a => Math.Abs(a.LonDeg - medLon)).OrderBy(x => x).ToArray(); - var dH = amostras.Select(a => Math.Abs(a.AltElipsoidalM - medH)).OrderBy(x => x).ToArray(); - var dHdg = amostras.Select(a => Math.Abs(a.HeadingDeg - medH)).OrderBy(x => x).ToArray(); + 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 madLat = Mediana(dLat) + 1e-12; - double madLon = Mediana(dLon) + 1e-12; - double madHgt = Mediana(dH) + 1e-12; - double madHdg = Mediana(dHdg) + 1e-12; + double[] validHeadings = samples + .Select(x => x.HeadingDeg) + .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) + .ToArray(); - // Filtra outliers (|x - med| / MAD <= madK) - var filtradas = amostras.Where(a => - (Math.Abs(a.LatDeg - medLat) / madLat) <= madK && - (Math.Abs(a.LonDeg - medLon) / madLon) <= madK && - (Math.Abs(a.AltElipsoidalM - medH) / madHgt) <= madK && - (Math.Abs(a.HeadingDeg - medHdg) / madHdg) <= madK - ).ToList(); + bool useHeadingFilter = validHeadings.Length >= Math.Max(5, samples.Count / 2); + double medHeading = useHeadingFilter + ? CircularMedian(validHeadings) + : double.NaN; - // Média final - double lat = filtradas.Average(a => a.LatDeg); - double lon = filtradas.Average(a => a.LonDeg); - double h = filtradas.Average(a => a.AltElipsoidalM); - double hdg = filtradas.Average(a => a.HeadingDeg); + 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; - return (lat, lon, h, hdg, filtradas.Count); + List filtered = samples.Where(x => + Math.Abs(x.LatDeg - medLat) / madLat <= madK && + Math.Abs(x.LonDeg - medLon) / madLon <= madK && + Math.Abs(x.AltElipsoidalM - medH) / madH <= madK && + (!useHeadingFilter || + AngularDistanceDegrees(x.HeadingDeg, medHeading) / madHeading <= madK)) + .ToList(); - double Mediana(double[] arr) - { - int n = arr.Length; - if (n == 0) return double.NaN; - return (n % 2 == 1) ? arr[n / 2] : 0.5 * (arr[n / 2 - 1] + arr[n / 2]); - } + 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); } - // ===== 4) Aplicar base FIX + RTCM + save ===== - public async Task AplicarBaseFixAsync(string portaUsb, string portaSaida, string baseId, double latDeg, double lonDeg, double hEllipsM) + 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..."); - var ci = System.Globalization.CultureInfo.InvariantCulture; - // Desliga logs antes de trocar modo - string[] pre = { - $"unlog com1\r\n", - $"unlog com2\r\n", - $"unlog com3\r\n" - }; - foreach (var c in pre) { _Porta?.Write(Encoding.ASCII.GetBytes(c), 0, c.Length); await Task.Delay(150); } + Models.Variaveis.MostrarLog("Aplicando dados de correção da base..."); - var latStr = latDeg.ToString("0.000000000", ci); - var lonStr = lonDeg.ToString("0.000000000", ci); - var hStr = hEllipsM.ToString("0.000", ci); + string lat = latDeg.ToString("0.000000000", CultureInfo.InvariantCulture); + string lon = lonDeg.ToString("0.000000000", CultureInfo.InvariantCulture); + string h = hEllipsM.ToString("0.000", CultureInfo.InvariantCulture); - var fix = Encoding.ASCII.GetBytes($"mode base {baseId} {latStr} {lonStr} {hStr}\r\n"); - _Porta?.Write(fix, 0, fix.Length); - await Task.Delay(250); + 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", + }; - // Reativar RTCM no canal de saída para o LoRa - string[] rtcmCmds = { - // RTCM perfil (comece leve; ative mais constelações se o LoRa aguentar) - $"RTCM1006 {portaSaida} 10\r\n", - $"RTCM1033 {portaSaida} 30\r\n", - $"RTCM1074 {portaSaida} 1\r\n", // GPS MSM4 - $"RTCM1124 {portaSaida} 1\r\n", // BeiDou MSM4 - - // (Opcional) ativar mais constelações: - $"RTCM1094 {portaSaida} 1\r\n", // Galileo MSM4 - $"RTCM1084 {portaSaida} 1\r\n", // GLONASS MSM4 - //$"RTCM1230 {portaSaida} 10\r\n",// Bias GLONASS (OBRIGATÓRIO se 1084 estiver ativo) - }; - - foreach (var c in rtcmCmds) { var b = Encoding.ASCII.GetBytes(c); _Porta?.Write(b, 0, b.Length); await Task.Delay(200); } - - // NMEA mínimo na USB p/ debug - var nmea = $"gngga {portaUsb} 1\r\n"; - _Porta?.Write(Encoding.ASCII.GetBytes(nmea), 0, nmea.Length); - await Task.Delay(150); - - // Persistir - var save = "saveconfig\r\n"; - _Porta?.Write(Encoding.ASCII.GetBytes(save), 0, save.Length); + await gpsService.SendCommandsAsync(commands, 200, ct).ConfigureAwait(false); } + private static double Median(IEnumerable values) + { + double[] ordered = values + .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) + .OrderBy(x => x) + .ToArray(); + + if (ordered.Length == 0) + return double.NaN; + + int middle = ordered.Length / 2; + return ordered.Length % 2 == 1 + ? ordered[middle] + : (ordered[middle - 1] + ordered[middle]) / 2.0; + } + + private static double CircularMean(IEnumerable headings) + { + double[] valid = headings + .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) + .ToArray(); + + if (valid.Length == 0) + return double.NaN; + + double sin = valid.Sum(x => Math.Sin(x * Math.PI / 180.0)); + double cos = valid.Sum(x => Math.Cos(x * Math.PI / 180.0)); + double angle = Math.Atan2(sin, cos) * 180.0 / Math.PI; + return (angle + 360.0) % 360.0; + } + + private static double CircularMedian(IEnumerable headings) + { + double[] valid = headings + .Where(x => !double.IsNaN(x) && !double.IsInfinity(x)) + .Select(x => (x % 360.0 + 360.0) % 360.0) + .ToArray(); + + if (valid.Length == 0) + return double.NaN; + + return valid + .OrderBy(candidate => valid.Sum(x => AngularDistanceDegrees(x, candidate))) + .First(); + } + + private static double AngularDistanceDegrees(double a, double b) + { + double diff = Math.Abs(((a - b + 540.0) % 360.0) - 180.0); + return diff; + } public enum MetodoFixacaoBase { Ntrip = 0, SurveyIn = 1, - Manual = 2 + 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; } + } }