Adicionado heartbeat comunicação UDP

This commit is contained in:
Diego Freitas 2026-03-05 10:50:01 -03:00
parent cf3056c44d
commit c03c5b1736
7 changed files with 420 additions and 125 deletions

View File

@ -51,7 +51,7 @@
public enum T_Code
{
Vzo = -1,
Vzo = 0,
Imu = 53,
Mov = 100,
Dir = 101,

View File

@ -20,6 +20,7 @@ using AgroBase.Services.Operadores;
using System.Diagnostics;
using Newtonsoft.Json;
using AgroMonitor;
using System.Net;
namespace AgroBase.Models
{
@ -178,93 +179,130 @@ namespace AgroBase.Models
public static UdpReliableChannel UdpChannel;
private static Action<byte, byte[], IPEndPoint> _onPacketHandler;
// Thread-safe: guarda o último comando UDP recebido (UTC ticks). 0 = nunca/armado off
private static long _lastUdpTicksUtc = 0;
// Último pacote UDP recebido (qualquer type, inclusive heartbeat)
private static long _lastPacketTicksUtc = 0;
// Evita reentrância do tick (caso seu timer possa chamar antes do anterior terminar)
// Último comando válido recebido (somente type 0x01 + parse ok)
private static long _lastValidCmdTicksUtc = 0;
// Guards anti-reentrância
private static int _failSafeTickRunning = 0;
private static int _udpWatchdogTickRunning = 0;
private static int _udpRestarting = 0;
private static AsyncTaskTimerModel _tmrFailSafe;
private static AsyncTaskTimerModel _tmrUdpWatchdog;
private const int FAILSAFE_MS = 400;
private const int UDP_RESTART_MS = 2000;
public static void IniciarUDP()
{
StartUdpChannel();
_tmrFailSafe = new AsyncTaskTimerModel("tmrFailSafe", tmrFailSafe_Tick, 100);
_tmrFailSafe.Start();
_tmrUdpWatchdog = new AsyncTaskTimerModel("tmrUdpWatchdog", tmrUdpWatchdog_Tick, 250);
_tmrUdpWatchdog.Start();
}
private static void StartUdpChannel()
{
try
{
UdpChannel?.Stop();
UdpChannel?.Dispose();
}
catch { }
UdpChannel = new UdpReliableChannel();
UdpChannel.Start(VariaveisPortas.Ethernet_UDP_RX);
UdpChannel.OnPacket += (type, payload, from) =>
{
if (type != 0x01) return;
if (!UdpCtrlMessage.TryParse(payload, out var msg)) return;
// cria 1 vez e reutiliza a MESMA referência
_onPacketHandler = OnUdpPacket;
UdpChannel.OnPacket += _onPacketHandler;
Interlocked.Exchange(ref _lastUdpTicksUtc, DateTime.UtcNow.Ticks);
// flags globais
bool emergencia = (msg.Flags & UdpCtrlFlags.Emergencia) != 0;
bool pausa = (msg.Flags & UdpCtrlFlags.Pausa) != 0;
var disp = (T_Code)msg.Device;
OperacaoComandoBaseModel controle = new OperacaoComandoBaseModel()
{
Emergencia = emergencia,
Pausa = pausa,
Tecla = msg.Key,
Dispositivo = disp,
};
// aplica parâmetros por dispositivo (alinhado ao MQTT)
if ((msg.Flags & UdpCtrlFlags.HasPayload) != 0)
{
if (disp == T_Code.Dir)
{
double angulo = msg.P1 / 100.0;
var tipoMov = (TipoMovimentoDirecional)(byte)msg.P2;
// atualiza seu contexto de controle (igual mqtt faz)
controle.Controle.AnguloSP = angulo;
controle.Controle.TipoMovimentoDirecional = tipoMov;
}
else if (disp == T_Code.Mov)
{
double velPct = msg.P1 / 100.0;
controle.Controle.PercentualVelocidadeSP = velPct;
}
}
Variaveis.OperacaoEmAndamento.ExecutaComandoDaBase(controle);
};
// Tick 100ms (ok). Se quiser mais responsivo, use 50ms.
_tmrFailSafe = new AsyncTaskTimerModel("tmrFailSafe", tmrFailSafe_Tick, 100);
_tmrFailSafe.Start();
Interlocked.Exchange(ref _lastPacketTicksUtc, DateTime.UtcNow.Ticks);
}
private static void OnUdpPacket(byte type, byte[] payload, IPEndPoint from)
{
// Qualquer pacote marca link vivo (inclusive heartbeat)
Interlocked.Exchange(ref _lastPacketTicksUtc, DateTime.UtcNow.Ticks);
// Heartbeat: não faz nada além de manter vivo.
if (type == UdpReliableChannel.TYPE_HEARTBEAT)
{
Console.WriteLine("[UDP] Heartbeat");
return;
}
// Só controle entra daqui
if (type != UdpReliableChannel.TYPE_COMMAND) return;
if (!UdpCtrlMessage.TryParse(payload, out var msg)) return;
// Comando válido (controle) atualiza o failsafe
Interlocked.Exchange(ref _lastValidCmdTicksUtc, DateTime.UtcNow.Ticks);
bool emergencia = (msg.Flags & UdpCtrlFlags.Emergencia) != 0;
bool pausa = (msg.Flags & UdpCtrlFlags.Pausa) != 0;
var disp = (T_Code)msg.Device;
var controle = new OperacaoComandoBaseModel()
{
Emergencia = emergencia,
Pausa = pausa,
Tecla = msg.Key,
Dispositivo = disp,
Controle = new OperacaoComandoBaseControleModel(),
};
if ((msg.Flags & UdpCtrlFlags.HasPayload) != 0)
{
if (disp == T_Code.Dir)
{
controle.Controle.AnguloSP = msg.P1 / 100.0;
controle.Controle.TipoMovimentoDirecional = (TipoMovimentoDirecional)(byte)msg.P2;
}
else if (disp == T_Code.Mov)
{
controle.Controle.PercentualVelocidadeSP = msg.P1 / 100.0;
}
}
Variaveis.OperacaoEmAndamento.ExecutaComandoDaBase(controle);
}
// ===== FAILSAFE: só considera comando de controle válido (não heartbeat) =====
public static async Task tmrFailSafe_Tick()
{
// Guard anti-reentrância
if (Interlocked.Exchange(ref _failSafeTickRunning, 1) == 1)
return;
try
{
long ticks = Interlocked.Read(ref _lastUdpTicksUtc);
long ticks = Interlocked.Read(ref _lastValidCmdTicksUtc);
if (ticks == 0) return;
var last = new DateTime(ticks, DateTimeKind.Utc);
double ms = (DateTime.UtcNow - last).TotalMilliseconds;
var lastCmd = new DateTime(ticks, DateTimeKind.Utc);
double ms = (DateTime.UtcNow - lastCmd).TotalMilliseconds;
if (ms > FAILSAFE_MS)
{
// Para movimento e direção por segurança
// Para por segurança
GeneralJoystick.EnviaComandoMotor(Keys.Escape, T_Code.Mov, ForcarComando: true, ID: null);
GeneralJoystick.EnviaComandoMotor(Keys.Escape, T_Code.Dir, ForcarComando: true, ID: null);
// desarma para não ficar repetindo ESC pra sempre
Interlocked.Exchange(ref _lastUdpTicksUtc, 0);
// desarma até chegar novo comando válido
Interlocked.Exchange(ref _lastValidCmdTicksUtc, 0);
}
}
finally
@ -275,6 +313,63 @@ namespace AgroBase.Models
await Task.CompletedTask;
}
// ===== WATCHDOG: se parou de chegar qualquer pacote, reinicia o socket =====
public static async Task tmrUdpWatchdog_Tick()
{
if (Interlocked.Exchange(ref _udpWatchdogTickRunning, 1) == 1)
return;
try
{
long ticks = Interlocked.Read(ref _lastPacketTicksUtc);
if (ticks == 0) return;
var lastPkt = new DateTime(ticks, DateTimeKind.Utc);
double ms = (DateTime.UtcNow - lastPkt).TotalMilliseconds;
if (ms > UDP_RESTART_MS)
{
await RestartUdpChannelSafe();
}
}
finally
{
Interlocked.Exchange(ref _udpWatchdogTickRunning, 0);
}
await Task.CompletedTask;
}
private static async Task RestartUdpChannelSafe()
{
if (Interlocked.Exchange(ref _udpRestarting, 1) == 1)
return;
try
{
try
{
if (_onPacketHandler != null)
UdpChannel.OnPacket -= _onPacketHandler;
}
catch { }
// Reinicia o canal
StartUdpChannel();
// Importante: zera comando válido, porque “socket caiu” geralmente quebra o fluxo de comando
Interlocked.Exchange(ref _lastValidCmdTicksUtc, 0);
}
finally
{
Interlocked.Exchange(ref _udpRestarting, 0);
}
await Task.CompletedTask;
}
}
public static class VariaveisPortas

View File

@ -1,4 +1,5 @@
using AgroBase.Models;
using AgroMonitor;
using System;
using System.Collections.Concurrent;
using System.Net;
@ -9,6 +10,8 @@ using System.Threading.Tasks;
public sealed class UdpReliableChannel : IDisposable
{
public const ushort MAGIC = 0xA711;
public const byte TYPE_COMMAND = 0x7F;
public const byte TYPE_HEARTBEAT = 0x7F;
public int AckTimeoutMs { get; set; } = 120;
public int MaxPayloadBytes { get; set; } = 1200; // evita fragmentação
@ -29,6 +32,14 @@ public sealed class UdpReliableChannel : IDisposable
private readonly ConcurrentDictionary<uint, TaskCompletionSource<bool>> _pendingAck =
new ConcurrentDictionary<uint, TaskCompletionSource<bool>>();
public bool EnableHeartbeat { get; set; } = false;
public int HeartbeatIntervalMs { get; set; } = 350;
public byte[] HeartbeatPayload { get; set; } = new byte[] { 0 };
private CancellationTokenSource _hbCts;
private Task _hbTask;
private int _hbRunning = 0;
public void SetRemote(string ip, int port)
{
var addr = IPAddress.Parse(ip);
@ -43,15 +54,119 @@ public sealed class UdpReliableChannel : IDisposable
{
if (_udp != null) return;
_seqTx = 0; // opcional
_lastSeqRx = 0; // importante
_running = true;
_udp = new UdpClient(localPort);
_udp.Client.ReceiveBufferSize = 1 << 20;
_udp.Client.SendBufferSize = 1 << 20;
_running = true;
_rxThread = new Thread(RxLoop) { IsBackground = true, Name = "UdpReliableChannel.Rx" };
_rxThread.Start();
if (EnableHeartbeat)
StartHeartbeat();
}
public void Stop()
{
_running = false;
StopHeartbeat();
try { _udp?.Close(); } catch { } // força o Receive quebrar
try { _udp?.Dispose(); } catch { } // garante liberar socket
// espera thread encerrar (pra não ficar thread velha viva)
try
{
if (_rxThread != null && _rxThread.IsAlive)
{
if (!_rxThread.Join(500))
{
// Se travar, não aborta (Thread.Abort é treta), só segue.
// Mas normalmente Close() já libera.
}
}
}
catch { }
_udp = null;
_rxThread = null;
// reseta sequência RX pra evitar descartar tudo após restart
_lastSeqRx = 0;
// opcional: limpa ACKs pendentes pra não vazar
foreach (var kv in _pendingAck)
kv.Value.TrySetResult(false);
_pendingAck.Clear();
}
public void Dispose()
{
Stop();
}
private void StartHeartbeat()
{
if (_hbCts != null) return;
_hbCts = new CancellationTokenSource();
_hbTask = Task.Run(async () =>
{
while (!_hbCts.IsCancellationRequested)
{
// evita reentrância se o intervalo for curto e enviar demorar
if (Interlocked.Exchange(ref _hbRunning, 1) == 0)
{
try
{
// Só manda se tiver started e tiver remote
var udp = _udp;
var remote = Remote; // snapshot
if (udp != null && remote != null)
{
await SendAsync(TYPE_HEARTBEAT, HeartbeatPayload, requestAck: false).ConfigureAwait(false);
}
}
catch
{
// ignora: heartbeat não pode derrubar o canal
}
finally
{
Interlocked.Exchange(ref _hbRunning, 0);
}
}
try { await Task.Delay(HeartbeatIntervalMs, _hbCts.Token).ConfigureAwait(false); }
catch (TaskCanceledException) { }
}
});
}
private void StopHeartbeat()
{
try { _hbCts?.Cancel(); } catch { }
try
{
if (_hbTask != null)
_hbTask.Wait(200);
}
catch { }
try { _hbCts?.Dispose(); } catch { }
_hbCts = null;
_hbTask = null;
Interlocked.Exchange(ref _hbRunning, 0);
}
public uint NextSeq()
{
unchecked { return ++_seqTx; }
@ -223,11 +338,5 @@ public sealed class UdpReliableChannel : IDisposable
);
}
public void Dispose()
{
_running = false;
try { _udp?.Close(); } catch { }
_udp = null;
}
}

View File

@ -296,7 +296,8 @@ namespace OperationControl.Controls
private void ManualControlPad_EmergencyClicked(object? sender, EventArgs e)
{
EmergencyActivated = !EmergencyActivated;
VariaveisControleOperacao.EnviarComandoParada(emergencia: EmergencyActivated);
//VariaveisControleOperacao.EnviarComandoParada(emergencia: EmergencyActivated);
_ = VariaveisControleOperacao.EnviarComandoParadaUDP(ipRover: VariaveisControleOperacao.RoverEmFoco?.IP, emergencia: EmergencyActivated);
}
private void ManualControlPad_PauseClicked(object? sender, EventArgs e)
@ -340,7 +341,8 @@ namespace OperationControl.Controls
AgroBase.Models.Enums.Direcao.Parado;
if (_lastDirKey != dir)
{
VariaveisControleOperacao.EnviarComandoDirecional(dir, Convert.ToInt32(AngleControl), MoveTypeControl);
//VariaveisControleOperacao.EnviarComandoDirecional(dir, Convert.ToInt32(AngleControl), MoveTypeControl);
VariaveisControleOperacao.EnviarComandoDirecionalUDP(ipRover: VariaveisControleOperacao.RoverEmFoco?.IP, dir, Convert.ToInt32(AngleControl), MoveTypeControl);
_lastDirKey = dir;
}
@ -350,7 +352,8 @@ namespace OperationControl.Controls
AgroBase.Models.Enums.Direcao.Parado;
if (_lastMovKey != mov)
{
VariaveisControleOperacao.EnviarComandoMovimentacao(mov, Convert.ToInt32(SpeedControl));
//VariaveisControleOperacao.EnviarComandoMovimentacao(mov, Convert.ToInt32(SpeedControl));
VariaveisControleOperacao.EnviarComandoMovimentacaoUDP(ipRover: VariaveisControleOperacao.RoverEmFoco?.IP, mov, Convert.ToInt32(SpeedControl));
_lastMovKey = mov;
}
}

View File

@ -13,6 +13,8 @@ namespace OperationControl.Models
public static MqttService MqttService;
public static GpsService GpsService;
public static UdpReliableChannel UdpChannel;
public static ManualControlSender ControlSenderDir;
public static ManualControlSender ControlSenderMov;
public static void MostrarLog(string message)
{
@ -38,8 +40,10 @@ namespace OperationControl.Models
{
try
{
string device_id = message.Mensagem;
VariaveisControleOperacao.AdicionarNovoRoverNaRede(message.Mensagem);
var p = message.Mensagem.Split(',');
string device_id = p[0];
string device_ip = p[1];
VariaveisControleOperacao.AdicionarNovoRoverNaRede(device_id, device_ip);
}
catch (Exception ex)
{
@ -52,8 +56,18 @@ namespace OperationControl.Models
public static void IniciarUDP()
{
UdpChannel = new UdpReliableChannel();
UdpChannel = new UdpReliableChannel
{
EnableHeartbeat = true,
HeartbeatIntervalMs = 1000,
HeartbeatPayload = new byte[] { 0 }
};
UdpChannel.Start(VariaveisPortas.Ethernet_UDP_TX);
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();
}
}
@ -84,7 +98,7 @@ namespace OperationControl.Models
public static OperacaoParametrosModel? RoverEmFoco => RoversNaRede.FirstOrDefault(x => x.RoverId == SelectedRoverId);
public static bool BaseEmFoco => SelectedRoverId == BaseMarkerID;
public static async void AdicionarNovoRoverNaRede(string device_id)
public static async void AdicionarNovoRoverNaRede(string device_id, string device_ip)
{
if (AdicionandoNovoRover) return;
AdicionandoNovoRover = true;
@ -107,6 +121,7 @@ namespace OperationControl.Models
RoversNaRede.Add(new OperacaoParametrosModel()
{
RoverId = device_id,
IP = device_ip,
Configurado = false,
Alive = true,
DadosLeitura = new OperacaoParametrosDadosModel()
@ -166,6 +181,7 @@ namespace OperationControl.Models
if (rover != null)
{
obj.UltimoContato = DateTime.Now;
obj.IP = rover.IP;
RoversNaRede.Remove(rover);
RoversNaRede.Add(obj);
}
@ -305,11 +321,12 @@ namespace OperationControl.Models
public static Task EnviarComandoParadaUDP(string ipRover, bool emergencia = false, bool pausa = false)
{
if (string.IsNullOrEmpty(ipRover)) return Task.CompletedTask;
Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
var msg = new UdpCtrlMessage
{
Device = (byte)AgroBase.Models.Enums.T_Code.Mod,
Device = (byte)AgroBase.Models.Enums.T_Code.Vzo,
Flags = (emergencia ? UdpCtrlFlags.Emergencia : 0) | (pausa ? UdpCtrlFlags.Pausa : 0)
};
@ -416,9 +433,11 @@ namespace OperationControl.Models
});
}
public static Task EnviarComandoDirecionalUDP(string ipRover, Keys key, double angulo, AgroBase.Models.Enums.TipoMovimentoDirecional tipoMov)
public static void EnviarComandoDirecionalUDP(string ipRover, AgroBase.Models.Enums.Direcao direcao, double angulo, AgroBase.Models.Enums.TipoMovimentoDirecional tipoMov)
{
if (string.IsNullOrEmpty(ipRover)) return;
Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
var key = DeParaDirecaoTecla(direcao);
var msg = new UdpCtrlMessage
{
@ -429,7 +448,8 @@ namespace OperationControl.Models
P2 = (short)tipoMov
};
return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false);
//return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false);
Variaveis.ControlSenderDir.Update(msg);
}
public static void EnviarComandoMovimentacao(AgroBase.Models.Enums.Direcao direcao, double velocidade)
@ -446,9 +466,11 @@ namespace OperationControl.Models
});
}
public static Task EnviarComandoMovimentacaoUDP(string ipRover, Keys key, double velPct)
public static void EnviarComandoMovimentacaoUDP(string ipRover, AgroBase.Models.Enums.Direcao direcao, double velPct)
{
if (string.IsNullOrEmpty(ipRover)) return;
Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
var key = DeParaDirecaoTecla(direcao);
var msg = new UdpCtrlMessage
{
@ -459,7 +481,8 @@ namespace OperationControl.Models
P2 = 0
};
return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false);
//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)

View File

@ -1,77 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AgroBase;
using AgroBase.Models;
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
public sealed class ManualControlSender : IDisposable
{
private readonly UdpReliableChannel _udp;
private readonly byte _device; // ex: (byte)T_Code.Mov ou T_Code.Dir
private readonly HashSet<Keys> _pressed = new();
private readonly byte _device;
private readonly object _lock = new();
private CancellationTokenSource? _cts;
private Task? _loop;
private bool _armed;
private UdpCtrlMessage? _currentMsg;
// Ajuste fino
public int RepeatIntervalMs { get; set; } = 250; // 4 Hz
public int BurstDownCount { get; set; } = 6;
public int BurstDownIntervalMs { get; set; } = 25;
public int BurstStartCount { get; set; } = 6;
public int BurstStartIntervalMs { get; set; } = 25;
public int BurstStopCount { get; set; } = 10;
public int BurstStopIntervalMs { get; set; } = 25;
// protocolo: type 0x01 = KeyCmd
private const byte TYPE_KEY_CMD = 0x01;
// anti-spam de stop
private DateTime _lastStopSentUtc = DateTime.MinValue;
public int MinStopIntervalMs { get; set; } = 150;
// Deadman por falha de envio (não depende de Update)
// Se ficar esse tempo sem conseguir mandar nada enquanto armado, ele solta e manda STOP.
public int SendFailDeadmanMs { get; set; } = 1500; // 0 desliga
private DateTime _lastSendOkUtc = DateTime.MinValue;
public ManualControlSender(UdpReliableChannel udp, byte device)
{
_udp = udp;
_udp = udp ?? throw new ArgumentNullException(nameof(udp));
_device = device;
}
public void Start()
{
if (_cts != null) return;
_cts = new CancellationTokenSource();
_loop = Task.Run(() => Loop(_cts.Token));
}
public void KeyDown(Keys key)
/// <summary>
/// Chame apenas quando o comando mudou (apertou / soltou / trocou direção).
/// O loop cuida do reenvio contínuo.
/// </summary>
public void Update(UdpCtrlMessage msg)
{
if (!IsManualKey(key)) return;
if (msg == null) return;
lock (_lock) _pressed.Add(key);
msg.Device = _device;
// burst imediato pra resposta rápida
_ = SendKeyBurstAsync(key, BurstDownCount, BurstDownIntervalMs);
}
bool isNeutral = IsNeutral(msg);
public void KeyUp(Keys key)
{
if (!IsManualKey(key)) return;
lock (_lock)
{
if (isNeutral)
{
_armed = false;
_currentMsg = null;
}
else
{
_armed = true;
_currentMsg = msg;
}
}
lock (_lock) _pressed.Remove(key);
// sempre que soltar algo, manda STOP (Esc) burst
_ = SendKeyBurstAsync(Keys.Escape, BurstStopCount, BurstStopIntervalMs);
// fora do lock
if (isNeutral)
_ = SendStopBurstIfNeededAsync();
else
_ = SendBurstAsync(msg, BurstStartCount, BurstStartIntervalMs);
}
private async Task Loop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
Keys? active = null;
UdpCtrlMessage? toSend = null;
bool armedNow;
lock (_lock)
{
if (_pressed.Count > 0)
active = ChooseActiveKey(_pressed);
armedNow = _armed;
if (_armed && _currentMsg != null)
toSend = _currentMsg;
}
if (active.HasValue)
if (armedNow && toSend != null)
{
// reenviar leve enquanto segurando
await SendKeyOnceAsync(active.Value);
try
{
OperationControl.Models.Variaveis.MostrarLog("Enviando comando UDP Loop");
await SendOnceAsync(toSend);
_lastSendOkUtc = DateTime.UtcNow;
}
catch
{
// Se o Send der exception, não explode a task.
// Vamos deixar o deadman lidar.
}
// Deadman por falha: se ficou muito tempo sem envio OK, desarma e manda STOP
if (SendFailDeadmanMs > 0)
{
var ms = (DateTime.UtcNow - _lastSendOkUtc).TotalMilliseconds;
if (_lastSendOkUtc != DateTime.MinValue && ms > SendFailDeadmanMs)
{
lock (_lock)
{
_armed = false;
_currentMsg = null;
}
await SendStopBurstIfNeededAsync();
}
}
}
try { await Task.Delay(RepeatIntervalMs, ct); }
@ -79,36 +127,46 @@ public sealed class ManualControlSender : IDisposable
}
}
private static bool IsManualKey(Keys k) =>
k == Keys.Up || k == Keys.Down || k == Keys.Left || k == Keys.Right ||
k == Keys.Escape;
private Task SendBurstAsync(UdpCtrlMessage msg, int count, int intervalMs)
=> _udp.SendBurstAsync(UdpReliableChannel.TYPE_COMMAND, msg.ToBytes(), count: count, intervalMs: intervalMs, requestAck: false);
// Prioridade: se tiver Up/Down e Left/Right junto, você pode escolher uma regra
private static Keys ChooseActiveKey(IEnumerable<Keys> keys)
private Task SendOnceAsync(UdpCtrlMessage msg)
=> _udp.SendBurstAsync(UdpReliableChannel.TYPE_COMMAND, msg.ToBytes(), count: 1, intervalMs: 25, requestAck: false);
private async Task SendStopBurstIfNeededAsync()
{
// Exemplo: prioriza ESC, depois Up/Down, depois Left/Right
if (keys.Contains(Keys.Escape)) return Keys.Escape;
if (keys.Contains(Keys.Up)) return Keys.Up;
if (keys.Contains(Keys.Down)) return Keys.Down;
if (keys.Contains(Keys.Left)) return Keys.Left;
if (keys.Contains(Keys.Right)) return Keys.Right;
return Keys.Escape;
var now = DateTime.UtcNow;
if ((now - _lastStopSentUtc).TotalMilliseconds < MinStopIntervalMs)
return;
_lastStopSentUtc = now;
var stop = BuildStopMsg();
try { await SendBurstAsync(stop, BurstStopCount, BurstStopIntervalMs); }
catch { /* não derruba */ }
}
private Task SendKeyBurstAsync(Keys key, int count, int intervalMs)
{
var payload = UdpKeyPayload.Build(_device, key);
return _udp.SendBurstAsync(TYPE_KEY_CMD, payload, count, intervalMs, requestAck: false);
}
private static bool IsNeutral(UdpCtrlMessage msg)
=> msg.Key == AgroBase.Models.Enums.BotoesJoystick.Vazio;
private Task SendKeyOnceAsync(Keys key)
private UdpCtrlMessage BuildStopMsg()
{
var payload = UdpKeyPayload.Build(_device, key);
return _udp.SendAsync(TYPE_KEY_CMD, payload, requestAck: false);
return new UdpCtrlMessage
{
Device = _device,
Flags = UdpCtrlFlags.HasKey | UdpCtrlFlags.HasPayload,
Key = AgroBase.Models.Enums.BotoesJoystick.Vazio,
P1 = 0,
P2 = 0
};
}
public void Dispose()
{
try { _cts?.Cancel(); } catch { }
try { _loop?.Wait(200); } catch { }
try { _cts?.Dispose(); } catch { }
_cts = null;
_loop = null;
}
}

View File

@ -1020,6 +1020,7 @@ namespace OperationControl.Windows
double perda = conexao?.detalhes?["loss_pct"]?.Value<double>() ?? 0.0;
double jitter = conexao?.detalhes?["jitter"]?.Value<double>() ?? 0.0;
double bwTotal = conexao?.detalhes?["bw_total_mbps"]?.Value<double>() ?? 0;
double bwPct = conexao?.detalhes?["bw_util_pct"]?.Value<double>() ?? 0;
double errRate = conexao?.detalhes?["err_rate"]?.Value<double>() ?? 0;
graficoAcompanhamento.Atualizar(inicial, momento, new List<GraficoModel.GraficoParametrosModel>()
{
@ -1059,6 +1060,12 @@ namespace OperationControl.Windows
Unidade = "Mbps",
Valor = bwTotal
},
new GraficoModel.GraficoParametrosModel()
{
Label = "Banda",
Unidade = "%",
Valor = bwPct
},
});
break;
case AgroBase.Models.Enums.T_Code.Gps: