diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 243536e5b..bb2dc11ed 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, + UdpServer = 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```, ```Udp```, and ```UdpServer```. ##### 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```, ```Udp```, or ```UdpServer``` device requires a ```tcpSshProperties``` object to set the properties 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 ```Udp``` for outbound UDP to a remote endpoint. Use ```UdpServer``` 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 000000000..2918e0060 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -0,0 +1,415 @@ +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 bool connectionRefusedLogged; + 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; } + } + + /// + /// Will be set and unset by Connect and Disconnect only + /// + public bool ConnectEnabled + { + get { lock (stateLock) { return _connectEnabled; } } + private set { lock (stateLock) { _connectEnabled = value; } } + } + + /// + /// 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; + } + + ConnectEnabled = true; + + lock (stateLock) + { + 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() + { + ConnectEnabled = false; + + lock (stateLock) + { + 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; + + connectionRefusedLogged = false; + + 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) + { + 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); + 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 b807fdc53..65ae03fa0 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -52,10 +52,14 @@ public enum eControlMethod /// Cec, /// - /// UDP Server + /// UDP client /// Udp, /// + /// UDP server + /// + UdpServer, + /// /// 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 9318e04b2..f2c3fb0aa 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs @@ -92,10 +92,21 @@ public static IBasicCommunication CreateCommForDevice(DeviceConfig deviceConfig) } case eControlMethod.Udp: { - var udp = new GenericUdpServer(deviceConfig.Key + "-udp", c.Address, c.Port, c.BufferSize); + var udp = new GenericUdpClient(deviceConfig.Key + "-udp", c.Address, c.Port, c.BufferSize) + { + AutoReconnect = c.AutoReconnect + }; + if (udp.AutoReconnect) + udp.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs; comm = udp; break; } + case eControlMethod.UdpServer: + { + var udpServer = new GenericUdpServer(deviceConfig.Key + "-udpServer", c.Address, c.Port, c.BufferSize); + comm = udpServer; + break; + } case eControlMethod.Telnet: break; case eControlMethod.SecureTcpIp: