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 { 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; public static void MostrarLog(string message) { Debug.WriteLine(message); } /// /// Compatibilidade com chamadas antigas. O startup deve preferir: /// await Variaveis.IniciarMqttAsync(); /// public static Task IniciarMQTT() { return IniciarMqttAsync(); } public static async Task IniciarMqttAsync( CancellationToken cancellationToken = default(CancellationToken)) { await _mqttLifecycleLock .WaitAsync(cancellationToken) .ConfigureAwait(false); try { 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); throw; } finally { _mqttLifecycleLock.Release(); } } 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 { await rtcm.StopAsync() .ConfigureAwait(false); } catch (Exception ex) { MostrarLog( "Erro ao encerrar publisher RTCM: " + ex.Message ); } 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() { lock (_udpLifecycleLock) { /* * Garante idempotência. * Se a base reiniciar a comunicação, não deixa canal UDP * e senders antigos rodando no fundo. */ StopUdpChannelInterno(); 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; public static double LeverArmLateral { get; } = 0.0; public static readonly string BaseMarkerID = "BASE"; 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; 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) { 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 { 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 { bool novoRover = false; OperacaoParametrosModel rover; lock (_RoversLock) { rover = RoversNaRede.FirstOrDefault( x => string.Equals( x.RoverId, device_id, StringComparison.OrdinalIgnoreCase ) ); if (rover == null) { rover = new OperacaoParametrosModel { RoverId = device_id, IP = device_ip, Configurado = false, Alive = true, UltimoContato = DateTime.Now, DadosLeitura = new OperacaoParametrosDadosModel { Momento = DateTime.Now } }; RoversNaRede.Add(rover); novoRover = true; } else { if (!string.IsNullOrWhiteSpace(device_ip)) rover.IP = device_ip; rover.Alive = true; rover.UltimoContato = DateTime.Now; } } MarcarContatoRover(device_id); await GarantirTopicosRoverAsync(device_id) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(sessionId)) { await EnviarDiscoveryAckAsync( device_id, sessionId ).ConfigureAwait(false); } if (novoRover) { await RequisitarParametrosOperacaoAsync( device_id ).ConfigureAwait(false); } AgendarAtualizacaoListaRovers(); } finally { gate.Release(); } } private static async Task GarantirTopicosRoverAsync( string roverId) { 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)) { 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 Task ProcessarTelemetriaAsync( string roverId, MqttService.MqttTopicosMensagensModel message) { if (string.IsNullOrWhiteSpace(message.Mensagem)) return Task.CompletedTask; try { 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 ); } } } private static void EnviarDadosControle( string rover_id, OperacaoComandoBaseModel controle) { 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) { 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 bool TentarEnfileirarCorrecaoRTCM( byte[] correcao) { 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)) { 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 public static void EnviarComandoIniciarUDP(string ipRover, bool iniciar) { if (iniciar) Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX); else Variaveis.UdpChannel.SetRemote(null, VariaveisPortas.Ethernet_UDP_RX); EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Ipb, _comp_value = iniciar }); } public static void EnviarComandoIniciarOperacao(bool iniciar, bool simulador = false) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Mod, Tecla = simulador ? AgroBase.Models.Enums.BotoesJoystick.L1 : iniciar ? AgroBase.Models.Enums.BotoesJoystick.L2 : AgroBase.Models.Enums.BotoesJoystick.L3 }); } public static void EnviarComandoTransmissaoVideo(AgroBase.Models.Enums.T_Code disp, bool ligar, AgroBase.Models.Enums.TipoFrameCamera tipoFrame) { if (string.IsNullOrEmpty(SelectedRoverId) || SelectedRoverId == BaseMarkerID) return; EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = disp, Tecla = ligar ? AgroBase.Models.Enums.BotoesJoystick.L1 : AgroBase.Models.Enums.BotoesJoystick.Vazio, _comp_value = tipoFrame }); } public static void EnviarComandoParada(bool? emergencia = null, bool? pausa = null) { var cmd = new OperacaoComandoBaseModel() { Momento = DateTime.Now }; if (emergencia != null) cmd.Emergencia = (bool)emergencia; if (pausa != null) cmd.Pausa = (bool)pausa; if (emergencia != null || pausa != null) EnviarDadosControle(SelectedRoverId, cmd); } public static Task EnviarComandoParadaUDP(bool emergencia = false, bool pausa = false) { var msg = new UdpCtrlMessage { Device = (byte)AgroBase.Models.Enums.T_Code.Vzo, Flags = (emergencia ? UdpCtrlFlags.Emergencia : 0) | (pausa ? UdpCtrlFlags.Pausa : 0) }; return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false); } public static void EnviarComandoReferenciamento(string? mod_id = null) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Dir, Tecla = AgroBase.Models.Enums.BotoesJoystick.R3, _comp_id = mod_id }); } public static void EnviarComandoLeverArm(double? lateral = null, double? frontal = null) { var cmd = new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Gps, _comp_value = (lateral, frontal) }; EnviarDadosControle(SelectedRoverId, cmd); } public static void EnviarParametrosOperacao(OperacaoParametrosModel parametros) { bool operacao_iniciada = (RoverEmFoco?.DadosLeitura?.Operacao?.Iniciada ?? false); bool parametros_parciais = parametros.Modo == null; EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Mod, Parametros = parametros, Tecla = (operacao_iniciada || parametros_parciais ? AgroBase.Models.Enums.BotoesJoystick.Touchpad : AgroBase.Models.Enums.BotoesJoystick.Options) }); } public static void EnviarConfirmacaoHumanaTrajetoria(int idx_corredor, bool bat_liberada, bool herb_liberado) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Mod, Tecla = AgroBase.Models.Enums.BotoesJoystick.R1, _comp_value = (idx_corredor, bat_liberada, herb_liberado) }); } public static void EnviarComandoCoolerControl(AgroBase.Services.CoolerControlService.CoolerMode modo, AgroBase.Models.Enums.Estado? entrada = null, AgroBase.Models.Enums.Estado? saida = null, double? temp_on = null, double? temp_turbo = null) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Npc, _comp_value = (modo, entrada, saida, temp_on, temp_turbo) }); } public static void RequisitarParametrosOperacao() { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Mod, Tecla = AgroBase.Models.Enums.BotoesJoystick.Share }); } private static AgroBase.Models.Enums.BotoesJoystick DeParaDirecaoTecla(AgroBase.Models.Enums.Direcao direcao) { switch (direcao) { case AgroBase.Models.Enums.Direcao.Esquerda: return AgroBase.GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.key == Keys.Left)?.botaoJoy ?? AgroBase.Models.Enums.BotoesJoystick.Vazio; case AgroBase.Models.Enums.Direcao.Direita: return AgroBase.GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.key == Keys.Right)?.botaoJoy ?? AgroBase.Models.Enums.BotoesJoystick.Vazio; case AgroBase.Models.Enums.Direcao.Cima: return AgroBase.GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.key == Keys.Up)?.botaoJoy ?? AgroBase.Models.Enums.BotoesJoystick.Vazio; case AgroBase.Models.Enums.Direcao.Baixo: return AgroBase.GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.key == Keys.Down)?.botaoJoy ?? AgroBase.Models.Enums.BotoesJoystick.Vazio; default: return AgroBase.Models.Enums.BotoesJoystick.Vazio; } } public static void EnviarComandoDirecional(AgroBase.Models.Enums.BotoesJoystick botao, bool solto, double angulo, AgroBase.Models.Enums.TipoMovimentoDirecional movimento, string mod_id = null) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Dir, Tecla = botao, Solto = solto, _comp_id = mod_id, Controle = new OperacaoComandoBaseControleModel() { AnguloSP = angulo, TipoMovimentoDirecional = movimento }, }); } public static void EnviarComandoDirecionalUDP(AgroBase.Models.Enums.BotoesJoystick botao, bool solto, double angulo, AgroBase.Models.Enums.TipoMovimentoDirecional tipoMov) { var msg = new UdpCtrlMessage { Device = (byte)AgroBase.Models.Enums.T_Code.Dir, Flags = UdpCtrlFlags.HasKey | UdpCtrlFlags.HasPayload, Key = botao, released = solto, P1 = (short)Math.Max(short.MinValue, Math.Min(short.MaxValue, (int)Math.Round(angulo * 100.0))), P2 = (short)tipoMov }; //return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false); Variaveis.ControlSenderDir.Update(msg); } public static void EnviarComandoMovimentacao(AgroBase.Models.Enums.BotoesJoystick botao, bool solto, double velocidade) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Mov, Tecla = botao, Solto = solto, Controle = new OperacaoComandoBaseControleModel() { PercentualVelocidadeSP = velocidade } }); } public static void EnviarComandoMovimentacaoUDP(AgroBase.Models.Enums.BotoesJoystick botao, bool solto, double velPct, bool emfreio) { var msg = new UdpCtrlMessage { Device = (byte)AgroBase.Models.Enums.T_Code.Mov, Flags = UdpCtrlFlags.HasKey | UdpCtrlFlags.HasPayload, Key = botao, released = solto, P1 = (short)Math.Max(short.MinValue, Math.Min(short.MaxValue, (int)Math.Round(velPct * 100.0))), P2 = (short)(emfreio ? 1 : 0) }; //return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false); Variaveis.ControlSenderMov.Update(msg); } public static void EnviarComandoAtuador(string componente_id, bool? status = null, int? angulo_controle = null, double? angulo_abertura = null, double? altura = null, double? potencia = null, double? pressao = null) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Atu, _comp_id = componente_id, _comp_value = angulo_abertura, Controle = new OperacaoComandoBaseControleModel() { AnguloSP = angulo_controle, Estado = status, Altura = altura, Potencia = potencia, Pressao = pressao } }); } public static void EnviarComandoSensoriamento(string componente_id, dynamic status) { EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Sen, _comp_id = componente_id, _comp_value = status }); } public static void EnviarComandoRetornoBase(double? lat = null, double? lon = null, List pontosRetorno = null) { var cmd = new OperacaoComandoBaseModel() { Momento = DateTime.Now, Dispositivo = AgroBase.Models.Enums.T_Code.Trj, _comp_value = (lat, lon, pontosRetorno) }; EnviarDadosControle(SelectedRoverId, cmd); } #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; } } }