From c9f5af184b301c03f34cd8990ed9019977e57114 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 1 May 2026 14:29:54 -0700 Subject: [PATCH] feat: add support for UdpClient in communication methods and implement GenericUdpClient class --- docs/docs/usage/GenericComm.md | 7 +- src/PepperDash.Core/Comm/GenericUdpClient.cs | 388 ++++++++++++++++++ src/PepperDash.Core/Comm/eControlMethods.cs | 4 + .../Comm and IR/CommFactory.cs | 11 + 4 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 src/PepperDash.Core/Comm/GenericUdpClient.cs diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 243536e5..1c1642a8 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -183,11 +183,12 @@ namespace PepperDash.Core Cresnet = 8, Cec = 9, Udp = 10, + UdpClient = 11, } } ``` -These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, and ```Udp```. + These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. ##### ComParams @@ -287,7 +288,7 @@ This property maps to the number of the port on the device you have mapped the r ##### TcpSshParams -A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. +A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. ```Json { @@ -304,7 +305,7 @@ A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` **```address```** -This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. In the case of a UDP device, you can set either a single whitelist address with this data, or an appropriate broadcast address. +This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. Use ```UdpClient``` for outbound UDP to a remote endpoint. Use ```Udp``` when you need Essentials to bind a local UDP listener. **```port```** diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs new file mode 100644 index 00000000..95954eea --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -0,0 +1,388 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using ThreadingTimeout = System.Threading.Timeout; +using NetSocketException = System.Net.Sockets.SocketException; + +namespace PepperDash.Core +{ + /// + /// A class to handle basic UDP communications to a remote endpoint + /// + public class GenericUdpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized UdpClient"; + + private readonly object stateLock = new object(); + private readonly Timer reconnectTimer; + + private UdpClient client; + private CancellationTokenSource receiveCancellationTokenSource; + private bool connectEnabled; + private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the remote endpoint and returns it as a byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the remote endpoint and returns it as text + /// + public event EventHandler TextReceived; + + /// + /// Fires when the socket status changes + /// + public event EventHandler ConnectionChange; + + /// + /// Address of remote endpoint + /// + public string Hostname { get; set; } + + /// + /// Port on remote endpoint + /// + public int Port { get; set; } + + /// + /// Another S+ helper because large port numbers can be treated as signed ints + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// True when the local socket is created and associated with the configured remote endpoint + /// + public bool IsConnected + { + get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// The current socket status of the client + /// + public SocketStatus ClientStatus + { + get + { + lock (stateLock) + { + return clientStatus; + } + } + private set + { + var shouldFireEvent = false; + + lock (stateLock) + { + if (clientStatus != value) + { + clientStatus = value; + shouldFireEvent = true; + } + } + + if (shouldFireEvent) + ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this)); + } + } + + /// + /// Ushort representation of client status + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Gets or sets the AutoReconnect + /// + public bool AutoReconnect { get; set; } + + /// + /// S+ helper for AutoReconnect + /// + public ushort UAutoReconnect + { + get { return (ushort)(AutoReconnect ? 1 : 0); } + set { AutoReconnect = value == 1; } + } + + /// + /// Milliseconds to wait before attempting to reconnect. Defaults to 5000 + /// + public int AutoReconnectIntervalMs { get; set; } + + /// + /// Constructor + /// + public GenericUdpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + Hostname = address; + Port = port; + BufferSize = bufferSize; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Constructor for S+ + /// + public GenericUdpClient() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Initialize method + /// + public void Initialize(string key) + { + Key = key; + } + + private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + Debug.Console(1, this, "Program stopping. Closing connection"); + Deactivate(); + } + } + + /// + /// Deactivate method + /// + public override bool Deactivate() + { + Disconnect(); + return true; + } + + /// + /// Connect method + /// + public void Connect() + { + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': No address set", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + if (Port < 1 || Port > 65535) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Invalid port", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + lock (stateLock) + { + connectEnabled = true; + + if (client != null) + return; + + try + { + receiveCancellationTokenSource = new CancellationTokenSource(); + client = new UdpClient(); + client.Client.ReceiveBufferSize = BufferSize; + client.Client.SendBufferSize = BufferSize; + client.Connect(Hostname, Port); + ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + StartReceive(receiveCancellationTokenSource.Token); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + } + + /// + /// Disconnect method + /// + public void Disconnect() + { + lock (stateLock) + { + connectEnabled = false; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// SendText method + /// + public void SendText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + SendBytes(bytes); + } + + /// + /// SendBytes method + /// + public void SendBytes(byte[] bytes) + { + if (bytes == null) + return; + + try + { + this.PrintSentBytes(bytes); + + if (!IsConnected || client == null) + Connect(); + + var udpClient = client; + if (!IsConnected || udpClient == null) + return; + + udpClient.Send(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error sending UDP bytes for {0}", this, Key); + HandleDisconnected(); + } + } + + private void StartReceive(CancellationToken token) + { + Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + var udpClient = client; + if (udpClient == null) + return; + + var result = await udpClient.ReceiveAsync().ConfigureAwait(false); + var bytes = result.Buffer; + if (bytes == null || bytes.Length == 0) + continue; + + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + + this.PrintReceivedBytes(bytes); + this.PrintReceivedText(text); + + BytesReceived?.Invoke(this, new GenericCommMethodReceiveBytesArgs(bytes)); + TextReceived?.Invoke(this, new GenericCommMethodReceiveTextArgs(text)); + } + catch (ObjectDisposedException) + { + return; + } + catch (InvalidOperationException) + { + return; + } + catch (NetSocketException ex) + { + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + } + }, token); + } + + private void HandleDisconnected() + { + lock (stateLock) + { + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + + private void StartReconnectTimer() + { + if (AutoReconnect && connectEnabled) + reconnectTimer.Change(AutoReconnectIntervalMs, ThreadingTimeout.Infinite); + } + + private void CleanupClient() + { + if (receiveCancellationTokenSource != null) + { + receiveCancellationTokenSource.Cancel(); + receiveCancellationTokenSource.Dispose(); + receiveCancellationTokenSource = null; + } + + if (client != null) + { + client.Close(); + client = null; + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs index b807fdc5..006bb987 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -56,6 +56,10 @@ namespace PepperDash.Core /// Udp, /// + /// UDP client + /// + UdpClient, + /// /// HTTP client /// Http, diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs index 9318e04b..b28331a0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs @@ -96,6 +96,17 @@ namespace PepperDash.Essentials.Core comm = udp; break; } + case eControlMethod.UdpClient: + { + var udpClient = new GenericUdpClient(deviceConfig.Key + "-udpClient", c.Address, c.Port, c.BufferSize) + { + AutoReconnect = c.AutoReconnect + }; + if (udpClient.AutoReconnect) + udpClient.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs; + comm = udpClient; + break; + } case eControlMethod.Telnet: break; case eControlMethod.SecureTcpIp: