From c9f5af184b301c03f34cd8990ed9019977e57114 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 1 May 2026 14:29:54 -0700 Subject: [PATCH 1/7] 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: From b83af26b774e95b1e4042fc7ced1118b85c34470 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:01:08 -0700 Subject: [PATCH 2/7] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 65 ++++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 95954eea..ec7f0e7f 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -225,30 +225,71 @@ namespace PepperDash.Core return; } + var hostname = Hostname; + var port = Port; + var bufferSize = BufferSize; + UdpClient newClient = null; + CancellationTokenSource newReceiveCancellationTokenSource = null; + CancellationToken startReceiveToken = default(CancellationToken); + var shouldStartReceive = false; + lock (stateLock) { connectEnabled = true; if (client != null) return; + } - try + try + { + newReceiveCancellationTokenSource = new CancellationTokenSource(); + newClient = new UdpClient(); + newClient.Client.ReceiveBufferSize = bufferSize; + newClient.Client.SendBufferSize = bufferSize; + newClient.Connect(hostname, port); + + lock (stateLock) { - receiveCancellationTokenSource = new CancellationTokenSource(); - client = new UdpClient(); - client.Client.ReceiveBufferSize = BufferSize; - client.Client.SendBufferSize = BufferSize; - client.Connect(Hostname, Port); + if (!connectEnabled || client != null) + { + newClient.Close(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + return; + } + + receiveCancellationTokenSource = newReceiveCancellationTokenSource; + client = newClient; ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); - StartReceive(receiveCancellationTokenSource.Token); + startReceiveToken = receiveCancellationTokenSource.Token; + shouldStartReceive = true; } - catch (Exception ex) + + if (shouldStartReceive) + StartReceive(startReceiveToken); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + + if (newClient != null) + newClient.Close(); + + if (newReceiveCancellationTokenSource != null) { - Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); - CleanupClient(); - ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; - StartReconnectTimer(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + } + + lock (stateLock) + { + if (connectEnabled && client == null) + { + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } } } } From e57bc43a109166575a93d0c8f64fe9af2e5331f7 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:04:28 -0700 Subject: [PATCH 3/7] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index ec7f0e7f..c9a54505 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -313,6 +313,8 @@ namespace PepperDash.Core /// public void SendText(string text) { + this.PrintSentText(text); + var bytes = Encoding.GetEncoding(28591).GetBytes(text); SendBytes(bytes); } From e6583f7824350699eb7caebdc61026cd8bb3694d Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:06:25 -0700 Subject: [PATCH 4/7] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index c9a54505..3d93bada 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -383,14 +383,26 @@ namespace PepperDash.Core catch (NetSocketException ex) { Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } catch (Exception ex) { Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } } }, token); From 4f2d2ca746b23329065be95cb7bfcd890e5bac1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:09:51 +0000 Subject: [PATCH 5/7] fix: log udp client send failures when disconnected Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/761a7a78-c51f-474b-9000-baa9a232c0d0 Co-authored-by: jonnyarndt <21110580+jonnyarndt@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 3d93bada..2c2d0f1c 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -336,7 +336,10 @@ namespace PepperDash.Core var udpClient = client; if (!IsConnected || udpClient == null) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Cannot send bytes because the client is not connected", Key); return; + } udpClient.Send(bytes, bytes.Length); } @@ -440,4 +443,4 @@ namespace PepperDash.Core } } } -} \ No newline at end of file +} From d47cfd5e62b864dccd47d8575c732e57fc980fe5 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:13:24 -0700 Subject: [PATCH 6/7] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/docs/usage/GenericComm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 1c1642a8..16f467e2 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -188,7 +188,7 @@ namespace PepperDash.Core } ``` - 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```. +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 From 18f7000d76df69210298ba13544a233c6e52f98e Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:45:50 -0700 Subject: [PATCH 7/7] fix: add udpClient behavior to throttle receive errors and reset upon valid traffic arrival --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 2c2d0f1c..41792537 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -23,6 +23,7 @@ namespace PepperDash.Core private UdpClient client; private CancellationTokenSource receiveCancellationTokenSource; private bool connectEnabled; + private bool connectionRefusedLogged; private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; /// @@ -367,6 +368,8 @@ namespace PepperDash.Core if (bytes == null || bytes.Length == 0) continue; + connectionRefusedLogged = false; + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintReceivedBytes(bytes); @@ -385,6 +388,20 @@ namespace PepperDash.Core } catch (NetSocketException ex) { + if (ex.SocketErrorCode == SocketError.ConnectionRefused) + { + if (!connectionRefusedLogged) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, + "GenericUdpClient '{0}': Remote endpoint refused UDP traffic or is no longer listening", + Key); + connectionRefusedLogged = true; + } + + HandleDisconnected(); + return; + } + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); if (AutoReconnect)