diff --git a/AgroBase/AgroBase/AgroBase.csproj b/AgroBase/AgroBase/AgroBase.csproj
index 313ddf3dd..4272cbe06 100644
--- a/AgroBase/AgroBase/AgroBase.csproj
+++ b/AgroBase/AgroBase/AgroBase.csproj
@@ -563,6 +563,7 @@
+
@@ -620,6 +621,7 @@
+
diff --git a/AgroBase/AgroBase/Forms/Atuador/frmAtuCamera.cs b/AgroBase/AgroBase/Forms/Atuador/frmAtuCamera.cs
index d56318e7b..906e3c990 100644
--- a/AgroBase/AgroBase/Forms/Atuador/frmAtuCamera.cs
+++ b/AgroBase/AgroBase/Forms/Atuador/frmAtuCamera.cs
@@ -34,7 +34,7 @@ namespace AgroBase.Forms
DeviceID = "",
combo = cmbCameras,
MaxLeituras = 10,
- VideoPorta = (VariaveisPortas.CameraSolo + Variaveis.OperacaoEmAndamento.DispSen.Dados.CamerasSolo.Count()).ToString(),
+ VideoPorta = "", //(VariaveisPortas.CameraSolo + Variaveis.OperacaoEmAndamento.DispSen.Dados.CamerasSolo.Count()).ToString(),
VideoUrl = "green_objects_T",
MqttTopico = "green_objects_T",
panel = pnlCamera,
@@ -98,7 +98,7 @@ namespace AgroBase.Forms
DeviceID = "123",
combo = new ComboBox(),
MaxLeituras = 1,
- VideoPorta = (VariaveisPortas.CameraSolo + 0).ToString(),
+ VideoPorta = "", // (VariaveisPortas.CameraSolo + 0).ToString(),
VideoUrl = "green_objects_" + CamNome,
MqttTopico = "green_objects_" + CamNome,
};
diff --git a/AgroBase/AgroBase/Forms/Movimentacao/frmMovCamera.cs b/AgroBase/AgroBase/Forms/Movimentacao/frmMovCamera.cs
index 4feb09450..e4719d6a4 100644
--- a/AgroBase/AgroBase/Forms/Movimentacao/frmMovCamera.cs
+++ b/AgroBase/AgroBase/Forms/Movimentacao/frmMovCamera.cs
@@ -32,7 +32,7 @@ namespace AgroBase.Forms.Movimentacao
private Timer tmrLeitura;
private int MaxLeituras = 1;
- private string VideoPorta = VariaveisPortas.CameraCaminho.ToString();
+ private string VideoPorta = ""; // VariaveisPortas.CameraCaminho.ToString();
private string VideoUrl = "street_detector";
private bool MostrarDebug = true;
private string ArquivoLeitura = "leitura_solo.json";
diff --git a/AgroBase/AgroBase/Forms/Sensoriamento/frmSenCamera.cs b/AgroBase/AgroBase/Forms/Sensoriamento/frmSenCamera.cs
index 85af5b1c0..2857a2d69 100644
--- a/AgroBase/AgroBase/Forms/Sensoriamento/frmSenCamera.cs
+++ b/AgroBase/AgroBase/Forms/Sensoriamento/frmSenCamera.cs
@@ -15,7 +15,7 @@ namespace AgroBase.Forms.Sensoriamento
private Timer tmrLeitura;
private int MaxLeituras = 10;
- private string VideoPorta = VariaveisPortas.CameraCaminho.ToString();
+ private string VideoPorta = ""; // VariaveisPortas.CameraCaminho.ToString();
private string VideoUrl = "line_angle";
private bool MostrarDebug = true;
private string ArquivoLeitura = "leitura_angulo";
diff --git a/AgroBase/AgroBase/Forms/frmInstancial.cs b/AgroBase/AgroBase/Forms/frmInstancial.cs
index 82070ee9f..16b3ec5cf 100644
--- a/AgroBase/AgroBase/Forms/frmInstancial.cs
+++ b/AgroBase/AgroBase/Forms/frmInstancial.cs
@@ -29,6 +29,8 @@ namespace AgroBase.Forms
Variaveis.IniciarMQTT();
+ Variaveis.IniciarUDP();
+
APIService.IniciarRotinas();
GeneralJoystick.IniciarRotinas();
diff --git a/AgroBase/AgroBase/Models/UdpCtrlMessage.cs b/AgroBase/AgroBase/Models/UdpCtrlMessage.cs
new file mode 100644
index 000000000..569c9733a
--- /dev/null
+++ b/AgroBase/AgroBase/Models/UdpCtrlMessage.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Buffers.Binary; // se seu .NET não tiver, eu te passo versão sem isso
+using System.Windows.Forms;
+
+[Flags]
+public enum UdpCtrlFlags : byte
+{
+ None = 0,
+ Emergencia = 1 << 0,
+ Pausa = 1 << 1,
+ HasKey = 1 << 2,
+ HasPayload = 1 << 3,
+}
+
+public sealed class UdpCtrlMessage
+{
+ public byte Version = 1;
+ public byte Device;
+ public UdpCtrlFlags Flags;
+ public Keys Key;
+ public short P1;
+ public short P2;
+
+ public byte[] ToBytes()
+ {
+ // 10 bytes fixos
+ var buf = new byte[10];
+ buf[0] = Version;
+ buf[1] = Device;
+ buf[2] = (byte)Flags;
+
+ // key UInt16 BE
+ ushort k = (ushort)Key;
+ buf[3] = (byte)(k >> 8);
+ buf[4] = (byte)(k & 0xFF);
+
+ // p1 int16 BE
+ buf[5] = (byte)((ushort)P1 >> 8);
+ buf[6] = (byte)((ushort)P1 & 0xFF);
+
+ // p2 int16 BE
+ buf[7] = (byte)((ushort)P2 >> 8);
+ buf[8] = (byte)((ushort)P2 & 0xFF);
+
+ // reservado (checksum simples futuro / seq local etc)
+ buf[9] = 0;
+
+ return buf;
+ }
+
+ public static bool TryParse(byte[] payload, out UdpCtrlMessage msg)
+ {
+ msg = null;
+ if (payload == null || payload.Length < 10) return false;
+
+ var m = new UdpCtrlMessage();
+ m.Version = payload[0];
+ if (m.Version != 1) return false;
+
+ m.Device = payload[1];
+ m.Flags = (UdpCtrlFlags)payload[2];
+
+ ushort k = (ushort)((payload[3] << 8) | payload[4]);
+ m.Key = (Keys)k;
+
+ m.P1 = (short)((payload[5] << 8) | payload[6]);
+ m.P2 = (short)((payload[7] << 8) | payload[8]);
+
+ msg = m;
+ return true;
+ }
+}
+
+public static class UdpKeyPayload
+{
+ // [0] device
+ // [1..2] key (UInt16 BE)
+ public static byte[] Build(byte device, Keys key)
+ {
+ byte[] buf = new byte[3];
+ buf[0] = device;
+ BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(1, 2), (ushort)key);
+ return buf;
+ }
+
+ public static bool TryParse(byte[] payload, out byte device, out Keys key)
+ {
+ device = 0;
+ key = Keys.None;
+ if (payload == null || payload.Length < 3) return false;
+
+ device = payload[0];
+ ushort raw = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(1, 2));
+ key = (Keys)raw;
+ return true;
+ }
+}
diff --git a/AgroBase/AgroBase/Models/Variaveis.cs b/AgroBase/AgroBase/Models/Variaveis.cs
index 2a4aa203b..6997085b4 100644
--- a/AgroBase/AgroBase/Models/Variaveis.cs
+++ b/AgroBase/AgroBase/Models/Variaveis.cs
@@ -174,14 +174,123 @@ namespace AgroBase.Models
}
});
}
+
+
+
+
+ public static UdpReliableChannel UdpChannel;
+
+ // Thread-safe: guarda o último comando UDP recebido (UTC ticks). 0 = nunca/armado off
+ private static long _lastUdpTicksUtc = 0;
+
+ // Evita reentrância do tick (caso seu timer possa chamar antes do anterior terminar)
+ private static int _failSafeTickRunning = 0;
+
+ private static AsyncTaskTimerModel _tmrFailSafe;
+
+ private const int FAILSAFE_MS = 400;
+
+ public static void IniciarUDP()
+ {
+ 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;
+
+ Interlocked.Exchange(ref _lastUdpTicksUtc, DateTime.UtcNow.Ticks);
+
+ // flags globais
+ bool emergencia = (msg.Flags & UdpCtrlFlags.Emergencia) != 0;
+ bool pausa = (msg.Flags & UdpCtrlFlags.Pausa) != 0;
+
+ if (emergencia)
+ {
+ Variaveis.OperacaoEmAndamento.Emergencia = true;
+ return;
+ }
+
+ if (pausa)
+ {
+ Variaveis.OperacaoEmAndamento.Pausa = true;
+ return;
+ }
+
+ var disp = (T_Code)msg.Device;
+
+ // 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)
+ Variaveis.OperacaoEmAndamento.Controle.Angulo = angulo;
+ Variaveis.OperacaoEmAndamento.Controle.TipoMovimento = tipoMov;
+ }
+ else if (disp == T_Code.Mov)
+ {
+ double velPct = msg.P1 / 100.0;
+ Variaveis.OperacaoEmAndamento.Controle.PercentualVelocidadeSP = velPct;
+ }
+ }
+
+ // aplica tecla (se tiver)
+ if ((msg.Flags & UdpCtrlFlags.HasKey) != 0)
+ {
+ GeneralJoystick.EnviaComandoMotor(msg.Key, disp, ForcarComando: true, ID: null);
+ }
+ };
+
+ // Tick 100ms (ok). Se quiser mais responsivo, use 50ms.
+ _tmrFailSafe = new AsyncTaskTimerModel("tmrFailSafe", tmrFailSafe_Tick, 100);
+ _tmrFailSafe.Start();
+ }
+
+ 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);
+ if (ticks == 0) return;
+
+ var last = new DateTime(ticks, DateTimeKind.Utc);
+ double ms = (DateTime.UtcNow - last).TotalMilliseconds;
+
+ if (ms > FAILSAFE_MS)
+ {
+ // Para movimento e direção 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);
+ }
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _failSafeTickRunning, 0);
+ }
+
+ await Task.CompletedTask;
+ }
+
}
public static class VariaveisPortas
{
- public static int CameraSolo { get; } = 6522;
- public static int CameraCaminho { get; } = 6544;
public static int Ethernet_AT { get; } = 8899;
public static int Ethernet_TCP { get; } = 8899;
+ public static int Ethernet_UDP_TX { get; } = 5005;
+ public static int Ethernet_UDP_RX { get; } = 5006;
}
public static class VariaveisEquipamento
diff --git a/AgroBase/AgroBase/Services/UdpReliableChannel.cs b/AgroBase/AgroBase/Services/UdpReliableChannel.cs
new file mode 100644
index 000000000..e78b4ebfb
--- /dev/null
+++ b/AgroBase/AgroBase/Services/UdpReliableChannel.cs
@@ -0,0 +1,233 @@
+using AgroBase.Models;
+using System;
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+public sealed class UdpReliableChannel : IDisposable
+{
+ public const ushort MAGIC = 0xA711;
+
+ public int AckTimeoutMs { get; set; } = 120;
+ public int MaxPayloadBytes { get; set; } = 1200; // evita fragmentação
+
+ public IPEndPoint Remote { get; private set; }
+
+ // type, payload, from
+ public event Action OnPacket;
+ public event Action OnAck;
+
+ private UdpClient _udp;
+ private Thread _rxThread;
+ private volatile bool _running;
+
+ private uint _seqTx = 0;
+ private uint _lastSeqRx = 0;
+
+ private readonly ConcurrentDictionary> _pendingAck =
+ new ConcurrentDictionary>();
+
+ public void SetRemote(string ip, int port)
+ {
+ var addr = IPAddress.Parse(ip);
+
+ if (Remote == null || !Remote.Address.Equals(addr) || Remote.Port != port)
+ {
+ Remote = new IPEndPoint(addr, port);
+ }
+ }
+
+ public void Start(int localPort)
+ {
+ if (_udp != null) return;
+
+ _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();
+ }
+
+ public uint NextSeq()
+ {
+ unchecked { return ++_seqTx; }
+ }
+
+ public Task SendAsync(byte type, byte[] payload, bool requestAck = false)
+ {
+ uint seq = NextSeq();
+ uint tsMs = (uint)Environment.TickCount;
+ return SendInternalAsync(type, payload ?? new byte[0], seq, tsMs, requestAck);
+ }
+
+ public async Task SendBurstAsync(byte type, byte[] payload, int count, int intervalMs, bool requestAck = false)
+ {
+ if (count < 1) count = 1;
+ if (intervalMs < 0) intervalMs = 0;
+
+ for (int i = 0; i < count; i++)
+ {
+ await SendAsync(type, payload, requestAck).ConfigureAwait(false);
+ if (intervalMs > 0 && i < count - 1)
+ await Task.Delay(intervalMs).ConfigureAwait(false);
+ }
+ }
+
+ public async Task SendAndWaitAckAsync(byte type, byte[] payload)
+ {
+ uint seq = NextSeq();
+ uint tsMs = (uint)Environment.TickCount;
+
+ var tcs = new TaskCompletionSource();
+ _pendingAck[seq] = tcs;
+
+ await SendInternalAsync(type, payload ?? new byte[0], seq, tsMs, requestAck: true).ConfigureAwait(false);
+
+ // timeout manual (framework-friendly)
+ var delay = Task.Delay(AckTimeoutMs);
+ var done = await Task.WhenAny(tcs.Task, delay).ConfigureAwait(false);
+
+ bool ok = (done == tcs.Task) && tcs.Task.Result;
+ TaskCompletionSource _;
+ _pendingAck.TryRemove(seq, out _);
+ return ok;
+ }
+
+ private Task SendInternalAsync(byte type, byte[] payload, uint seq, uint tsMs, bool requestAck)
+ {
+ if (_udp == null) throw new InvalidOperationException("Channel not started.");
+ if (Remote == null) throw new InvalidOperationException("Remote not set.");
+
+ if (payload.Length > MaxPayloadBytes)
+ throw new ArgumentException("Payload too big (" + payload.Length + " > " + MaxPayloadBytes + ").");
+
+ byte flags = (byte)(requestAck ? 1 : 0);
+
+ byte[] buf = new byte[12 + payload.Length];
+ // magic BE
+ buf[0] = (byte)(MAGIC >> 8);
+ buf[1] = (byte)(MAGIC & 0xFF);
+ buf[2] = type;
+ buf[3] = flags;
+
+ WriteU32BE(buf, 4, seq);
+ WriteU32BE(buf, 8, tsMs);
+
+ if (payload.Length > 0)
+ Buffer.BlockCopy(payload, 0, buf, 12, payload.Length);
+
+ // UdpClient.SendAsync existe no .NET Framework 4.6+, ok.
+ return _udp.SendAsync(buf, buf.Length, Remote);
+ }
+
+ private void RxLoop()
+ {
+ var from = new IPEndPoint(IPAddress.Any, 0);
+
+ while (_running)
+ {
+ byte[] data = null;
+ try
+ {
+ data = _udp.Receive(ref from); // bloqueia
+ }
+ catch
+ {
+ if (!_running) break;
+ continue;
+ }
+
+ if (data == null || data.Length < 12) continue;
+
+ ushort magic = (ushort)((data[0] << 8) | data[1]);
+ if (magic != MAGIC) continue;
+
+ byte type = data[2];
+ byte flags = data[3];
+ uint seq = ReadU32BE(data, 4);
+
+ int payloadLen = data.Length - 12;
+ byte[] payload = new byte[payloadLen];
+ if (payloadLen > 0)
+ Buffer.BlockCopy(data, 12, payload, 0, payloadLen);
+
+ // ACK
+ if (type == 0xFF)
+ {
+ if (payloadLen >= 4)
+ {
+ uint ackSeq = ReadU32BE(payload, 0);
+ var h = OnAck;
+ if (h != null) h(ackSeq);
+
+ TaskCompletionSource tcs;
+ if (_pendingAck.TryGetValue(ackSeq, out tcs))
+ tcs.TrySetResult(true);
+ }
+ continue;
+ }
+
+ // newest wins
+ if (seq <= _lastSeqRx) continue;
+ _lastSeqRx = seq;
+
+ var handler = OnPacket;
+ if (handler != null)
+ handler(type, payload, from);
+
+ // responde ACK se pedido
+ if ((flags & 1) != 0)
+ {
+ try { SendAckAsync(from, seq); }
+ catch { }
+ }
+ }
+ }
+
+ private void SendAckAsync(IPEndPoint to, uint seqAck)
+ {
+ if (_udp == null) return;
+
+ byte[] buf = new byte[12 + 4];
+ buf[0] = (byte)(MAGIC >> 8);
+ buf[1] = (byte)(MAGIC & 0xFF);
+ buf[2] = 0xFF; // ACK
+ buf[3] = 0;
+
+ WriteU32BE(buf, 4, 0); // seq do ack msg (opcional)
+ WriteU32BE(buf, 8, (uint)Environment.TickCount);
+ WriteU32BE(buf, 12, seqAck);
+
+ _udp.Send(buf, buf.Length, to);
+ }
+
+ private static void WriteU32BE(byte[] buf, int offset, uint v)
+ {
+ buf[offset + 0] = (byte)(v >> 24);
+ buf[offset + 1] = (byte)(v >> 16);
+ buf[offset + 2] = (byte)(v >> 8);
+ buf[offset + 3] = (byte)(v & 0xFF);
+ }
+
+ private static uint ReadU32BE(byte[] buf, int offset)
+ {
+ return (uint)(
+ (buf[offset + 0] << 24) |
+ (buf[offset + 1] << 16) |
+ (buf[offset + 2] << 8) |
+ (buf[offset + 3])
+ );
+ }
+
+ public void Dispose()
+ {
+ _running = false;
+
+ try { _udp?.Close(); } catch { }
+ _udp = null;
+ }
+}
\ No newline at end of file
diff --git a/AgroBase/OperationControl/Models/AppShell.cs b/AgroBase/OperationControl/Models/AppShell.cs
index 7ed704196..1278e170f 100644
--- a/AgroBase/OperationControl/Models/AppShell.cs
+++ b/AgroBase/OperationControl/Models/AppShell.cs
@@ -21,6 +21,7 @@ namespace OperationControl.Models
APIService.IniciarRotinas();
Variaveis.IniciarMQTT();
+ Variaveis.IniciarUDP();
tmrComunicacao.Elapsed += (_, __) => tmrComunicacao_Elapsed();
tmrComunicacao.Start();
diff --git a/AgroBase/OperationControl/Models/Variaveis.cs b/AgroBase/OperationControl/Models/Variaveis.cs
index dcd3fb378..9ed17fa07 100644
--- a/AgroBase/OperationControl/Models/Variaveis.cs
+++ b/AgroBase/OperationControl/Models/Variaveis.cs
@@ -12,6 +12,7 @@ namespace OperationControl.Models
{
public static MqttService MqttService;
public static GpsService GpsService;
+ public static UdpReliableChannel UdpChannel;
public static void MostrarLog(string message)
{
@@ -48,7 +49,13 @@ namespace OperationControl.Models
GpsService = new GpsService();
}
-
+
+ public static void IniciarUDP()
+ {
+ UdpChannel = new UdpReliableChannel();
+ UdpChannel.Start(VariaveisPortas.Ethernet_UDP_TX);
+ }
+
}
public class VariaveisControleOperacao
@@ -131,6 +138,8 @@ namespace OperationControl.Models
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;
}
@@ -294,6 +303,19 @@ namespace OperationControl.Models
EnviarDadosControle(SelectedRoverId, cmd);
}
+ public static Task EnviarComandoParadaUDP(string ipRover, bool emergencia = false, bool pausa = false)
+ {
+ Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
+
+ var msg = new UdpCtrlMessage
+ {
+ Device = (byte)AgroBase.Models.Enums.T_Code.Mod,
+ 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()
@@ -394,6 +416,22 @@ namespace OperationControl.Models
});
}
+ public static Task EnviarComandoDirecionalUDP(string ipRover, Keys key, double angulo, AgroBase.Models.Enums.TipoMovimentoDirecional tipoMov)
+ {
+ Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
+
+ var msg = new UdpCtrlMessage
+ {
+ Device = (byte)AgroBase.Models.Enums.T_Code.Dir,
+ Flags = UdpCtrlFlags.HasKey | UdpCtrlFlags.HasPayload,
+ Key = key,
+ 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);
+ }
+
public static void EnviarComandoMovimentacao(AgroBase.Models.Enums.Direcao direcao, double velocidade)
{
EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel()
@@ -408,6 +446,22 @@ namespace OperationControl.Models
});
}
+ public static Task EnviarComandoMovimentacaoUDP(string ipRover, Keys key, double velPct)
+ {
+ Variaveis.UdpChannel.SetRemote(ipRover, VariaveisPortas.Ethernet_UDP_RX);
+
+ var msg = new UdpCtrlMessage
+ {
+ Device = (byte)AgroBase.Models.Enums.T_Code.Mov,
+ Flags = UdpCtrlFlags.HasKey | UdpCtrlFlags.HasPayload,
+ Key = key,
+ P1 = (short)Math.Max(short.MinValue, Math.Min(short.MaxValue, (int)Math.Round(velPct * 100.0))),
+ P2 = 0
+ };
+
+ return Variaveis.UdpChannel.SendBurstAsync(0x01, msg.ToBytes(), count: 6, intervalMs: 25, requestAck: false);
+ }
+
public static void EnviarComandoAtuador(string componente_id, bool? status = null, int? angulo_controle = null, double? angulo_abertura = null, double? altura = null)
{
EnviarDadosControle(SelectedRoverId, new OperacaoComandoBaseModel()
diff --git a/AgroBase/OperationControl/Services/ManualControlSender.cs b/AgroBase/OperationControl/Services/ManualControlSender.cs
new file mode 100644
index 000000000..3eaf5b8fe
--- /dev/null
+++ b/AgroBase/OperationControl/Services/ManualControlSender.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+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 _pressed = new();
+ private readonly object _lock = new();
+
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+
+ // Ajuste fino
+ public int RepeatIntervalMs { get; set; } = 250; // 4 Hz
+ public int BurstDownCount { get; set; } = 6;
+ public int BurstDownIntervalMs { 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;
+
+ public ManualControlSender(UdpReliableChannel udp, byte device)
+ {
+ _udp = udp;
+ _device = device;
+ }
+
+ public void Start()
+ {
+ _cts = new CancellationTokenSource();
+ _loop = Task.Run(() => Loop(_cts.Token));
+ }
+
+ public void KeyDown(Keys key)
+ {
+ if (!IsManualKey(key)) return;
+
+ lock (_lock) _pressed.Add(key);
+
+ // burst imediato pra resposta rápida
+ _ = SendKeyBurstAsync(key, BurstDownCount, BurstDownIntervalMs);
+ }
+
+ public void KeyUp(Keys key)
+ {
+ if (!IsManualKey(key)) return;
+
+ lock (_lock) _pressed.Remove(key);
+
+ // sempre que soltar algo, manda STOP (Esc) burst
+ _ = SendKeyBurstAsync(Keys.Escape, BurstStopCount, BurstStopIntervalMs);
+ }
+
+ private async Task Loop(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ Keys? active = null;
+ lock (_lock)
+ {
+ if (_pressed.Count > 0)
+ active = ChooseActiveKey(_pressed);
+ }
+
+ if (active.HasValue)
+ {
+ // reenviar leve enquanto segurando
+ await SendKeyOnceAsync(active.Value);
+ }
+
+ try { await Task.Delay(RepeatIntervalMs, ct); }
+ catch (TaskCanceledException) { }
+ }
+ }
+
+ private static bool IsManualKey(Keys k) =>
+ k == Keys.Up || k == Keys.Down || k == Keys.Left || k == Keys.Right ||
+ k == Keys.Escape;
+
+ // Prioridade: se tiver Up/Down e Left/Right junto, você pode escolher uma regra
+ private static Keys ChooseActiveKey(IEnumerable keys)
+ {
+ // 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;
+ }
+
+ 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 Task SendKeyOnceAsync(Keys key)
+ {
+ var payload = UdpKeyPayload.Build(_device, key);
+ return _udp.SendAsync(TYPE_KEY_CMD, payload, requestAck: false);
+ }
+
+ public void Dispose()
+ {
+ try { _cts?.Cancel(); } catch { }
+ }
+}
\ No newline at end of file