From 26116d04955e44a11548495b0af25c31c221d7db Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 25 Mar 2025 23:22:18 -0500 Subject: [PATCH] feat: move PD Core into Essentials --- PepperDash.Essentials.4Series.sln | 26 + src/Directory.Build.targets | 5 + .../Comm/CommunicationGather.cs | 179 +++ .../Comm/CommunicationStreamDebugging.cs | 177 +++ .../Comm/ControlPropertiesConfig.cs | 93 ++ src/PepperDash.Core/Comm/EventArgs.cs | 251 ++++ .../Comm/GenericSecureTcpIpClient.cs | 955 +++++++++++++++ .../GenericSecureTcpIpClient_ForServer.cs | 909 ++++++++++++++ .../Comm/GenericSecureTcpIpServer.cs | 1084 +++++++++++++++++ src/PepperDash.Core/Comm/GenericSshClient.cs | 592 +++++++++ .../Comm/GenericTcpIpClient.cs | 566 +++++++++ .../Comm/GenericTcpIpClient_ForServer.cs | 774 ++++++++++++ .../Comm/GenericTcpIpServer.cs | 1010 +++++++++++++++ src/PepperDash.Core/Comm/GenericUdpServer.cs | 395 ++++++ .../Comm/TcpClientConfigObject.cs | 59 + .../Comm/TcpServerConfigObject.cs | 60 + src/PepperDash.Core/Comm/eControlMethods.cs | 79 ++ src/PepperDash.Core/CommunicationExtras.cs | 247 ++++ .../Config/PortalConfigReader.cs | 235 ++++ src/PepperDash.Core/Conversion/Convert.cs | 22 + src/PepperDash.Core/CoreInterfaces.cs | 35 + src/PepperDash.Core/Device.cs | 179 +++ src/PepperDash.Core/Directory.build.targets | 43 + ...sentialsPlugins-builds-4-series-caller.yml | 21 + src/PepperDash.Core/EthernetHelper.cs | 116 ++ src/PepperDash.Core/EventArgs.cs | 172 +++ .../GenericRESTfulCommunications/Constants.cs | 39 + .../GenericRESTfulClient.cs | 256 ++++ .../EventArgs and Constants.cs | 77 ++ .../JsonStandardObjects/JsonToSimplDevice.cs | 183 +++ .../JsonToSimplDeviceConfig.cs | 257 ++++ src/PepperDash.Core/JsonToSimpl/Constants.cs | 143 +++ src/PepperDash.Core/JsonToSimpl/Global.cs | 59 + .../JsonToSimplArrayLookupChild.cs | 162 +++ .../JsonToSimpl/JsonToSimplChildObjectBase.cs | 404 ++++++ .../JsonToSimpl/JsonToSimplFileMaster.cs | 289 +++++ .../JsonToSimpl/JsonToSimplFixedPathObject.cs | 18 + .../JsonToSimpl/JsonToSimplGenericMaster.cs | 118 ++ .../JsonToSimpl/JsonToSimplMaster.cs | 247 ++++ .../JsonToSimplPortalFileMaster.cs | 191 +++ .../Logging/CrestronEnricher.cs | 37 + src/PepperDash.Core/Logging/Debug.cs | 895 ++++++++++++++ .../Logging/DebugConsoleSink.cs | 55 + src/PepperDash.Core/Logging/DebugContext.cs | 281 +++++ .../Logging/DebugCrestronLoggerSink.cs | 29 + .../Logging/DebugErrorLogSink.cs | 65 + .../Logging/DebugExtensions.cs | 44 + src/PepperDash.Core/Logging/DebugMemory.cs | 115 ++ .../Logging/DebugWebsocketSink.cs | 271 +++++ .../Network/DiscoveryThings.cs | 22 + .../PasswordManagement/Config.cs | 26 + .../PasswordManagement/Constants.cs | 57 + .../PasswordManagement/PasswordClient.cs | 187 +++ .../PasswordManagement/PasswordManager.cs | 241 ++++ src/PepperDash.Core/PepperDash.Core.csproj | 66 + .../net472/PepperDashCore.props | 18 + .../net472/PepperDashCore.targets | 57 + .../Properties/ControlSystem.cfg | 7 + .../SystemInfo/EventArgs and Constants.cs | 264 ++++ .../SystemInfo/SystemInfoConfig.cs | 204 ++++ .../SystemInfo/SystemInfoToSimpl.cs | 462 +++++++ src/PepperDash.Core/Web/BouncyCertificate.cs | 356 ++++++ .../RequestHandlers/DefaultRequestHandler.cs | 17 + .../WebApiBaseRequestAsyncHandler.cs | 163 +++ .../WebApiBaseRequestHandler.cs | 165 +++ src/PepperDash.Core/Web/WebApiServer.cs | 284 +++++ src/PepperDash.Core/WebApi/Presets/Preset.cs | 87 ++ src/PepperDash.Core/WebApi/Presets/User.cs | 93 ++ .../WebApi/Presets/WebApiPasscodeClient.cs | 273 +++++ .../Serialization/IXSigSerialization.cs | 25 + .../XSigSerializationException.cs | 28 + .../XSigUtility/Tokens/XSigAnalogToken.cs | 88 ++ .../XSigUtility/Tokens/XSigDigitalToken.cs | 85 ++ .../XSigUtility/Tokens/XSigSerialToken.cs | 81 ++ .../XSigUtility/Tokens/XSigToken.cs | 45 + .../XSigUtility/Tokens/XSigTokenType.cs | 23 + .../XSigUtility/XSigHelpers.cs | 239 ++++ .../XSigUtility/XSigTokenStreamReader.cs | 147 +++ .../XSigUtility/XSigTokenStreamWriter.cs | 136 +++ .../PepperDash.Essentials.Core.csproj | 4 +- ...epperDash.Essentials.Devices.Common.csproj | 2 +- ...Essentials.MobileControl.Messengers.csproj | 2 +- ...PepperDash.Essentials.MobileControl.csproj | 2 +- .../PepperDash.Essentials.csproj | 2 +- 84 files changed, 16472 insertions(+), 5 deletions(-) create mode 100644 src/PepperDash.Core/Comm/CommunicationGather.cs create mode 100644 src/PepperDash.Core/Comm/CommunicationStreamDebugging.cs create mode 100644 src/PepperDash.Core/Comm/ControlPropertiesConfig.cs create mode 100644 src/PepperDash.Core/Comm/EventArgs.cs create mode 100644 src/PepperDash.Core/Comm/GenericSecureTcpIpClient.cs create mode 100644 src/PepperDash.Core/Comm/GenericSecureTcpIpClient_ForServer.cs create mode 100644 src/PepperDash.Core/Comm/GenericSecureTcpIpServer.cs create mode 100644 src/PepperDash.Core/Comm/GenericSshClient.cs create mode 100644 src/PepperDash.Core/Comm/GenericTcpIpClient.cs create mode 100644 src/PepperDash.Core/Comm/GenericTcpIpClient_ForServer.cs create mode 100644 src/PepperDash.Core/Comm/GenericTcpIpServer.cs create mode 100644 src/PepperDash.Core/Comm/GenericUdpServer.cs create mode 100644 src/PepperDash.Core/Comm/TcpClientConfigObject.cs create mode 100644 src/PepperDash.Core/Comm/TcpServerConfigObject.cs create mode 100644 src/PepperDash.Core/Comm/eControlMethods.cs create mode 100644 src/PepperDash.Core/CommunicationExtras.cs create mode 100644 src/PepperDash.Core/Config/PortalConfigReader.cs create mode 100644 src/PepperDash.Core/Conversion/Convert.cs create mode 100644 src/PepperDash.Core/CoreInterfaces.cs create mode 100644 src/PepperDash.Core/Device.cs create mode 100644 src/PepperDash.Core/Directory.build.targets create mode 100644 src/PepperDash.Core/EssentialsPlugins-builds-4-series-caller.yml create mode 100644 src/PepperDash.Core/EthernetHelper.cs create mode 100644 src/PepperDash.Core/EventArgs.cs create mode 100644 src/PepperDash.Core/GenericRESTfulCommunications/Constants.cs create mode 100644 src/PepperDash.Core/GenericRESTfulCommunications/GenericRESTfulClient.cs create mode 100644 src/PepperDash.Core/JsonStandardObjects/EventArgs and Constants.cs create mode 100644 src/PepperDash.Core/JsonStandardObjects/JsonToSimplDevice.cs create mode 100644 src/PepperDash.Core/JsonStandardObjects/JsonToSimplDeviceConfig.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/Constants.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/Global.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplArrayLookupChild.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplChildObjectBase.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplFileMaster.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplFixedPathObject.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplGenericMaster.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplMaster.cs create mode 100644 src/PepperDash.Core/JsonToSimpl/JsonToSimplPortalFileMaster.cs create mode 100644 src/PepperDash.Core/Logging/CrestronEnricher.cs create mode 100644 src/PepperDash.Core/Logging/Debug.cs create mode 100644 src/PepperDash.Core/Logging/DebugConsoleSink.cs create mode 100644 src/PepperDash.Core/Logging/DebugContext.cs create mode 100644 src/PepperDash.Core/Logging/DebugCrestronLoggerSink.cs create mode 100644 src/PepperDash.Core/Logging/DebugErrorLogSink.cs create mode 100644 src/PepperDash.Core/Logging/DebugExtensions.cs create mode 100644 src/PepperDash.Core/Logging/DebugMemory.cs create mode 100644 src/PepperDash.Core/Logging/DebugWebsocketSink.cs create mode 100644 src/PepperDash.Core/Network/DiscoveryThings.cs create mode 100644 src/PepperDash.Core/PasswordManagement/Config.cs create mode 100644 src/PepperDash.Core/PasswordManagement/Constants.cs create mode 100644 src/PepperDash.Core/PasswordManagement/PasswordClient.cs create mode 100644 src/PepperDash.Core/PasswordManagement/PasswordManager.cs create mode 100644 src/PepperDash.Core/PepperDash.Core.csproj create mode 100644 src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.props create mode 100644 src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.targets create mode 100644 src/PepperDash.Core/Properties/ControlSystem.cfg create mode 100644 src/PepperDash.Core/SystemInfo/EventArgs and Constants.cs create mode 100644 src/PepperDash.Core/SystemInfo/SystemInfoConfig.cs create mode 100644 src/PepperDash.Core/SystemInfo/SystemInfoToSimpl.cs create mode 100644 src/PepperDash.Core/Web/BouncyCertificate.cs create mode 100644 src/PepperDash.Core/Web/RequestHandlers/DefaultRequestHandler.cs create mode 100644 src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestAsyncHandler.cs create mode 100644 src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestHandler.cs create mode 100644 src/PepperDash.Core/Web/WebApiServer.cs create mode 100644 src/PepperDash.Core/WebApi/Presets/Preset.cs create mode 100644 src/PepperDash.Core/WebApi/Presets/User.cs create mode 100644 src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs create mode 100644 src/PepperDash.Core/XSigUtility/Serialization/IXSigSerialization.cs create mode 100644 src/PepperDash.Core/XSigUtility/Serialization/XSigSerializationException.cs create mode 100644 src/PepperDash.Core/XSigUtility/Tokens/XSigAnalogToken.cs create mode 100644 src/PepperDash.Core/XSigUtility/Tokens/XSigDigitalToken.cs create mode 100644 src/PepperDash.Core/XSigUtility/Tokens/XSigSerialToken.cs create mode 100644 src/PepperDash.Core/XSigUtility/Tokens/XSigToken.cs create mode 100644 src/PepperDash.Core/XSigUtility/Tokens/XSigTokenType.cs create mode 100644 src/PepperDash.Core/XSigUtility/XSigHelpers.cs create mode 100644 src/PepperDash.Core/XSigUtility/XSigTokenStreamReader.cs create mode 100644 src/PepperDash.Core/XSigUtility/XSigTokenStreamWriter.cs diff --git a/PepperDash.Essentials.4Series.sln b/PepperDash.Essentials.4Series.sln index a1e57f5e..7423c50a 100644 --- a/PepperDash.Essentials.4Series.sln +++ b/PepperDash.Essentials.4Series.sln @@ -4,19 +4,38 @@ Microsoft Visual Studio Solution File, Format Version 12.00 VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PepperDash.Essentials.Devices.Common", "src\PepperDash.Essentials.Devices.Common\PepperDash.Essentials.Devices.Common.csproj", "{53E204B7-97DD-441D-A96C-721DF014DF82}" + ProjectSection(ProjectDependencies) = postProject + {E5336563-1194-501E-BC4A-79AD9283EF90} = {E5336563-1194-501E-BC4A-79AD9283EF90} + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PepperDash.Essentials", "src\PepperDash.Essentials\PepperDash.Essentials.csproj", "{CB3B11BA-625C-4D35-B663-FDC5BE9A230E}" + ProjectSection(ProjectDependencies) = postProject + {E5336563-1194-501E-BC4A-79AD9283EF90} = {E5336563-1194-501E-BC4A-79AD9283EF90} + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PepperDash.Essentials.Core", "src\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj", "{3D192FED-8FFC-4CB5-B5F7-BA307ABA254B}" + ProjectSection(ProjectDependencies) = postProject + {E5336563-1194-501E-BC4A-79AD9283EF90} = {E5336563-1194-501E-BC4A-79AD9283EF90} + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mobile Control", "Mobile Control", "{B24989D7-32B5-48D5-9AE1-5F3B17D25206}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.MobileControl", "src\PepperDash.Essentials.MobileControl\PepperDash.Essentials.MobileControl.csproj", "{F6D362DE-2256-44B1-927A-8CE4705D839A}" + ProjectSection(ProjectDependencies) = postProject + {E5336563-1194-501E-BC4A-79AD9283EF90} = {E5336563-1194-501E-BC4A-79AD9283EF90} + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.MobileControl.Messengers", "src\PepperDash.Essentials.MobileControl.Messengers\PepperDash.Essentials.MobileControl.Messengers.csproj", "{B438694F-8FF7-464A-9EC8-10427374471F}" + ProjectSection(ProjectDependencies) = postProject + {E5336563-1194-501E-BC4A-79AD9283EF90} = {E5336563-1194-501E-BC4A-79AD9283EF90} + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Essentials", "Essentials", "{AD98B742-8D85-481C-A69D-D8D8ABED39EA}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core", "src\PepperDash.Core\PepperDash.Core.csproj", "{E5336563-1194-501E-BC4A-79AD9283EF90}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU @@ -54,6 +73,12 @@ Global {B438694F-8FF7-464A-9EC8-10427374471F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B438694F-8FF7-464A-9EC8-10427374471F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B438694F-8FF7-464A-9EC8-10427374471F}.Release|Any CPU.Build.0 = Release|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,6 +89,7 @@ Global {3D192FED-8FFC-4CB5-B5F7-BA307ABA254B} = {AD98B742-8D85-481C-A69D-D8D8ABED39EA} {F6D362DE-2256-44B1-927A-8CE4705D839A} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206} {B438694F-8FF7-464A-9EC8-10427374471F} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206} + {E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3} diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 505fad59..93647372 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -33,6 +33,11 @@ + + + + + diff --git a/src/PepperDash.Core/Comm/CommunicationGather.cs b/src/PepperDash.Core/Comm/CommunicationGather.cs new file mode 100644 index 00000000..9ffe8262 --- /dev/null +++ b/src/PepperDash.Core/Comm/CommunicationGather.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; + +using PepperDash.Core; + + +namespace PepperDash.Core +{ + /// + /// Defines the string event handler for line events on the gather + /// + /// + public delegate void LineReceivedHandler(string text); + + /// + /// Attaches to IBasicCommunication as a text gather + /// + public class CommunicationGather + { + /// + /// Event that fires when a line is received from the IBasicCommunication source. + /// The event merely contains the text, not an EventArgs type class. + /// + public event EventHandler LineReceived; + + /// + /// The communication port that this gathers on + /// + public ICommunicationReceiver Port { get; private set; } + + /// + /// Default false. If true, the delimiter will be included in the line output + /// events + /// + public bool IncludeDelimiter { get; set; } + + /// + /// For receive buffer + /// + StringBuilder ReceiveBuffer = new StringBuilder(); + + /// + /// Delimiter, like it says! + /// + char Delimiter; + + string[] StringDelimiters; + + /// + /// Constructor for using a char delimiter + /// + /// + /// + public CommunicationGather(ICommunicationReceiver port, char delimiter) + { + Port = port; + Delimiter = delimiter; + port.TextReceived += new EventHandler(Port_TextReceived); + } + + /// + /// Constructor for using a single string delimiter + /// + /// + /// + public CommunicationGather(ICommunicationReceiver port, string delimiter) + :this(port, new string[] { delimiter} ) + { + } + + /// + /// Constructor for using an array of string delimiters + /// + /// + /// + public CommunicationGather(ICommunicationReceiver port, string[] delimiters) + { + Port = port; + StringDelimiters = delimiters; + port.TextReceived += Port_TextReceivedStringDelimiter; + } + + /// + /// Disconnects this gather from the Port's TextReceived event. This will not fire LineReceived + /// after the this call. + /// + public void Stop() + { + Port.TextReceived -= Port_TextReceived; + Port.TextReceived -= Port_TextReceivedStringDelimiter; + } + + /// + /// Handler for raw data coming from port + /// + void Port_TextReceived(object sender, GenericCommMethodReceiveTextArgs args) + { + var handler = LineReceived; + if (handler != null) + { + ReceiveBuffer.Append(args.Text); + var str = ReceiveBuffer.ToString(); + var lines = str.Split(Delimiter); + if (lines.Length > 0) + { + for (int i = 0; i < lines.Length - 1; i++) + { + string strToSend = null; + if (IncludeDelimiter) + strToSend = lines[i] + Delimiter; + else + strToSend = lines[i]; + handler(this, new GenericCommMethodReceiveTextArgs(strToSend)); + } + ReceiveBuffer = new StringBuilder(lines[lines.Length - 1]); + } + } + } + + /// + /// + /// + /// + /// + void Port_TextReceivedStringDelimiter(object sender, GenericCommMethodReceiveTextArgs args) + { + var handler = LineReceived; + if (handler != null) + { + // Receive buffer should either be empty or not contain the delimiter + // If the line does not have a delimiter, append the + ReceiveBuffer.Append(args.Text); + var str = ReceiveBuffer.ToString(); + + // Case: Receiving DEVICE get version\x0d\0x0a+OK "value":"1234"\x0d\x0a + + // RX: DEV + // Split: (1) "DEV" + // RX: I + // Split: (1) "DEVI" + // RX: CE get version + // Split: (1) "DEVICE get version" + // RX: \x0d\x0a+OK "value":"1234"\x0d\x0a + // Split: (2) DEVICE get version, +OK "value":"1234" + + // Iterate the delimiters and fire an event for any matching delimiter + foreach (var delimiter in StringDelimiters) + { + var lines = Regex.Split(str, delimiter); + if (lines.Length == 1) + continue; + + for (int i = 0; i < lines.Length - 1; i++) + { + string strToSend = null; + if (IncludeDelimiter) + strToSend = lines[i] + delimiter; + else + strToSend = lines[i]; + handler(this, new GenericCommMethodReceiveTextArgs(strToSend, delimiter)); + } + ReceiveBuffer = new StringBuilder(lines[lines.Length - 1]); + } + } + } + + /// + /// Deconstructor. Disconnects from port TextReceived events. + /// + ~CommunicationGather() + { + Stop(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/CommunicationStreamDebugging.cs b/src/PepperDash.Core/Comm/CommunicationStreamDebugging.cs new file mode 100644 index 00000000..0a38d826 --- /dev/null +++ b/src/PepperDash.Core/Comm/CommunicationStreamDebugging.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using PepperDash.Core; + +namespace PepperDash.Core +{ + /// + /// Controls the ability to disable/enable debugging of TX/RX data sent to/from a device with a built in timer to disable + /// + public class CommunicationStreamDebugging + { + /// + /// Device Key that this instance configures + /// + public string ParentDeviceKey { get; private set; } + + /// + /// Timer to disable automatically if not manually disabled + /// + private CTimer DebugExpiryPeriod; + + /// + /// The current debug setting + /// + public eStreamDebuggingSetting DebugSetting { get; private set; } + + private uint _DebugTimeoutInMs; + private const uint _DefaultDebugTimeoutMin = 30; + + /// + /// Timeout in Minutes + /// + public uint DebugTimeoutMinutes + { + get + { + return _DebugTimeoutInMs/60000; + } + } + + /// + /// Indicates that receive stream debugging is enabled + /// + public bool RxStreamDebuggingIsEnabled{ get; private set; } + + /// + /// Indicates that transmit stream debugging is enabled + /// + public bool TxStreamDebuggingIsEnabled { get; private set; } + + /// + /// Constructor + /// + /// + public CommunicationStreamDebugging(string parentDeviceKey) + { + ParentDeviceKey = parentDeviceKey; + } + + + /// + /// Sets the debugging setting and if not setting to off, assumes the default of 30 mintues + /// + /// + public void SetDebuggingWithDefaultTimeout(eStreamDebuggingSetting setting) + { + if (setting == eStreamDebuggingSetting.Off) + { + DisableDebugging(); + return; + } + + SetDebuggingWithSpecificTimeout(setting, _DefaultDebugTimeoutMin); + } + + /// + /// Sets the debugging setting for the specified number of minutes + /// + /// + /// + public void SetDebuggingWithSpecificTimeout(eStreamDebuggingSetting setting, uint minutes) + { + if (setting == eStreamDebuggingSetting.Off) + { + DisableDebugging(); + return; + } + + _DebugTimeoutInMs = minutes * 60000; + + StopDebugTimer(); + + DebugExpiryPeriod = new CTimer((o) => DisableDebugging(), _DebugTimeoutInMs); + + if ((setting & eStreamDebuggingSetting.Rx) == eStreamDebuggingSetting.Rx) + RxStreamDebuggingIsEnabled = true; + + if ((setting & eStreamDebuggingSetting.Tx) == eStreamDebuggingSetting.Tx) + TxStreamDebuggingIsEnabled = true; + + Debug.SetDeviceDebugSettings(ParentDeviceKey, setting); + + } + + /// + /// Disabled debugging + /// + private void DisableDebugging() + { + StopDebugTimer(); + + Debug.SetDeviceDebugSettings(ParentDeviceKey, eStreamDebuggingSetting.Off); + } + + private void StopDebugTimer() + { + RxStreamDebuggingIsEnabled = false; + TxStreamDebuggingIsEnabled = false; + + if (DebugExpiryPeriod == null) + { + return; + } + + DebugExpiryPeriod.Stop(); + DebugExpiryPeriod.Dispose(); + DebugExpiryPeriod = null; + } + } + + /// + /// The available settings for stream debugging + /// + [Flags] + public enum eStreamDebuggingSetting + { + /// + /// Debug off + /// + Off = 0, + /// + /// Debug received data + /// + Rx = 1, + /// + /// Debug transmitted data + /// + Tx = 2, + /// + /// Debug both received and transmitted data + /// + Both = Rx | Tx + } + + /// + /// The available settings for stream debugging response types + /// + [Flags] + public enum eStreamDebuggingDataTypeSettings + { + /// + /// Debug data in byte format + /// + Bytes = 0, + /// + /// Debug data in text format + /// + Text = 1, + /// + /// Debug data in both byte and text formats + /// + Both = Bytes | Text, + } +} diff --git a/src/PepperDash.Core/Comm/ControlPropertiesConfig.cs b/src/PepperDash.Core/Comm/ControlPropertiesConfig.cs new file mode 100644 index 00000000..ff869f77 --- /dev/null +++ b/src/PepperDash.Core/Comm/ControlPropertiesConfig.cs @@ -0,0 +1,93 @@ +using System; +using Crestron.SimplSharp; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace PepperDash.Core +{ + /// + /// Config properties that indicate how to communicate with a device for control + /// + public class ControlPropertiesConfig + { + /// + /// The method of control + /// + [JsonProperty("method")] + [JsonConverter(typeof(StringEnumConverter))] + public eControlMethod Method { get; set; } + + /// + /// The key of the device that contains the control port + /// + [JsonProperty("controlPortDevKey", NullValueHandling = NullValueHandling.Ignore)] + public string ControlPortDevKey { get; set; } + + /// + /// The number of the control port on the device specified by ControlPortDevKey + /// + [JsonProperty("controlPortNumber", NullValueHandling = NullValueHandling.Ignore)] // In case "null" is present in config on this value + public uint? ControlPortNumber { get; set; } + + /// + /// The name of the control port on the device specified by ControlPortDevKey + /// + [JsonProperty("controlPortName", NullValueHandling = NullValueHandling.Ignore)] // In case "null" is present in config on this value + public string ControlPortName { get; set; } + + /// + /// Properties for ethernet based communications + /// + [JsonProperty("tcpSshProperties", NullValueHandling = NullValueHandling.Ignore)] + public TcpSshPropertiesConfig TcpSshProperties { get; set; } + + /// + /// The filename and path for the IR file + /// + [JsonProperty("irFile", NullValueHandling = NullValueHandling.Ignore)] + public string IrFile { get; set; } + + /// + /// The IpId of a Crestron device + /// + [JsonProperty("ipId", NullValueHandling = NullValueHandling.Ignore)] + public string IpId { get; set; } + + /// + /// Readonly uint representation of the IpId + /// + [JsonIgnore] + public uint IpIdInt { get { return Convert.ToUInt32(IpId, 16); } } + + /// + /// Char indicating end of line + /// + [JsonProperty("endOfLineChar", NullValueHandling = NullValueHandling.Ignore)] + public char EndOfLineChar { get; set; } + + /// + /// Defaults to Environment.NewLine; + /// + [JsonProperty("endOfLineString", NullValueHandling = NullValueHandling.Ignore)] + public string EndOfLineString { get; set; } + + /// + /// Indicates + /// + [JsonProperty("deviceReadyResponsePattern", NullValueHandling = NullValueHandling.Ignore)] + public string DeviceReadyResponsePattern { get; set; } + + /// + /// Used when communcating to programs running in VC-4 + /// + [JsonProperty("roomId", NullValueHandling = NullValueHandling.Ignore)] + public string RoomId { get; set; } + + /// + /// Constructor + /// + public ControlPropertiesConfig() + { + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/EventArgs.cs b/src/PepperDash.Core/Comm/EventArgs.cs new file mode 100644 index 00000000..cf76d6b3 --- /dev/null +++ b/src/PepperDash.Core/Comm/EventArgs.cs @@ -0,0 +1,251 @@ +/*PepperDash Technology Corp. +Copyright: 2017 +------------------------------------ +***Notice of Ownership and Copyright*** +The material in which this notice appears is the property of PepperDash Technology Corporation, +which claims copyright under the laws of the United States of America in the entire body of material +and in all parts thereof, regardless of the use to which it is being put. Any use, in whole or in part, +of this material by another party without the express written permission of PepperDash Technology Corporation is prohibited. +PepperDash Technology Corporation reserves all rights under applicable laws. +------------------------------------ */ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; + + +namespace PepperDash.Core +{ + /// + /// Delegate for notifying of socket status changes + /// + /// + public delegate void GenericSocketStatusChangeEventDelegate(ISocketStatus client); + + /// + /// EventArgs class for socket status changes + /// + public class GenericSocketStatusChageEventArgs : EventArgs + { + /// + /// + /// + public ISocketStatus Client { get; private set; } + + /// + /// + /// + /// + public GenericSocketStatusChageEventArgs(ISocketStatus client) + { + Client = client; + } + /// + /// S+ Constructor + /// + public GenericSocketStatusChageEventArgs() { } + } + + /// + /// Delegate for notifying of TCP Server state changes + /// + /// + public delegate void GenericTcpServerStateChangedEventDelegate(ServerState state); + + /// + /// EventArgs class for TCP Server state changes + /// + public class GenericTcpServerStateChangedEventArgs : EventArgs + { + /// + /// + /// + public ServerState State { get; private set; } + + /// + /// + /// + /// + public GenericTcpServerStateChangedEventArgs(ServerState state) + { + State = state; + } + /// + /// S+ Constructor + /// + public GenericTcpServerStateChangedEventArgs() { } + } + + /// + /// Delegate for TCP Server socket status changes + /// + /// + /// + /// + public delegate void GenericTcpServerSocketStatusChangeEventDelegate(object socket, uint clientIndex, SocketStatus clientStatus); + /// + /// EventArgs for TCP server socket status changes + /// + public class GenericTcpServerSocketStatusChangeEventArgs : EventArgs + { + /// + /// + /// + public object Socket { get; private set; } + /// + /// + /// + public uint ReceivedFromClientIndex { get; private set; } + /// + /// + /// + public SocketStatus ClientStatus { get; set; } + + /// + /// + /// + /// + /// + public GenericTcpServerSocketStatusChangeEventArgs(object socket, SocketStatus clientStatus) + { + Socket = socket; + ClientStatus = clientStatus; + } + + /// + /// + /// + /// + /// + /// + public GenericTcpServerSocketStatusChangeEventArgs(object socket, uint clientIndex, SocketStatus clientStatus) + { + Socket = socket; + ReceivedFromClientIndex = clientIndex; + ClientStatus = clientStatus; + } + /// + /// S+ Constructor + /// + public GenericTcpServerSocketStatusChangeEventArgs() { } + } + + /// + /// EventArgs for TCP server com method receive text + /// + public class GenericTcpServerCommMethodReceiveTextArgs : EventArgs + { + /// + /// + /// + public uint ReceivedFromClientIndex { get; private set; } + + /// + /// + /// + public ushort ReceivedFromClientIndexShort + { + get + { + return (ushort)ReceivedFromClientIndex; + } + } + + /// + /// + /// + public string Text { get; private set; } + + /// + /// + /// + /// + public GenericTcpServerCommMethodReceiveTextArgs(string text) + { + Text = text; + } + + /// + /// + /// + /// + /// + public GenericTcpServerCommMethodReceiveTextArgs(string text, uint clientIndex) + { + Text = text; + ReceivedFromClientIndex = clientIndex; + } + /// + /// S+ Constructor + /// + public GenericTcpServerCommMethodReceiveTextArgs() { } + } + + /// + /// EventArgs for TCP server client ready for communication + /// + public class GenericTcpServerClientReadyForcommunicationsEventArgs : EventArgs + { + /// + /// + /// + public bool IsReady; + + /// + /// + /// + /// + public GenericTcpServerClientReadyForcommunicationsEventArgs(bool isReady) + { + IsReady = isReady; + } + /// + /// S+ Constructor + /// + public GenericTcpServerClientReadyForcommunicationsEventArgs() { } + } + + /// + /// EventArgs for UDP connected + /// + public class GenericUdpConnectedEventArgs : EventArgs + { + /// + /// + /// + public ushort UConnected; + /// + /// + /// + public bool Connected; + + /// + /// Constructor + /// + public GenericUdpConnectedEventArgs() { } + + /// + /// + /// + /// + public GenericUdpConnectedEventArgs(ushort uconnected) + { + UConnected = uconnected; + } + + /// + /// + /// + /// + public GenericUdpConnectedEventArgs(bool connected) + { + Connected = connected; + } + + } + + + +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericSecureTcpIpClient.cs b/src/PepperDash.Core/Comm/GenericSecureTcpIpClient.cs new file mode 100644 index 00000000..7531f9e9 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericSecureTcpIpClient.cs @@ -0,0 +1,955 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using PepperDash.Core.Logging; + +namespace PepperDash.Core +{ + /// + /// A class to handle secure TCP/IP communications with a server + /// + public class GenericSecureTcpIpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized Secure Tcp _client"; + /// + /// Stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the server and returns it as a Byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the server and returns it as text + /// + public event EventHandler TextReceived; + + #region GenericSecureTcpIpClient Events & Delegates + + /// + /// + /// + //public event GenericSocketStatusChangeEventDelegate SocketStatusChange; + public event EventHandler ConnectionChange; + + /// + /// Auto reconnect evant handler + /// + public event EventHandler AutoReconnectTriggered; + + /// + /// Event for Receiving text. Once subscribed to this event the receive callback will start a thread that dequeues the messages and invokes the event on a new thread. + /// It is not recommended to use both the TextReceived event and the TextReceivedQueueInvoke event. + /// + public event EventHandler TextReceivedQueueInvoke; + + /// + /// For a client with a pre shared key, this will fire after the communication is established and the key exchange is complete. If you require + /// a key and subscribe to the socket change event and try to send data on a connection the data sent will interfere with the key exchange and disconnect. + /// + public event EventHandler ClientReadyForCommunications; + + #endregion + + + #region GenricTcpIpClient properties + + private string _hostname; + + /// + /// Address of server + /// + public string Hostname + { + get { return _hostname; } + set + { + _hostname = value; + if (_client != null) + { + _client.AddressClientConnectedTo = _hostname; + } + } + } + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// S+ helper + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// Internal secure client + /// + private SecureTCPClient _client; + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get { return _client != null && _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// _client socket status Read only + /// + public SocketStatus ClientStatus + { + get + { + return _client == null ? SocketStatus.SOCKET_STATUS_NO_CONNECT : _client.ClientStatus; + } + } + + /// + /// Contains the familiar Simpl analog status values. This drives the ConnectionChange event + /// and IsConnected would be true when this == 2. + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Status text shows the message associated with socket status + /// + public string ClientStatusText { get { return ClientStatus.ToString(); } } + + /// + /// Connection failure reason + /// + public string ConnectionFailure { get { return ClientStatus.ToString(); } } + + /// + /// bool to track if auto reconnect should be set on the socket + /// + 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; } + + /// + /// Flag Set only when the disconnect method is called. + /// + bool DisconnectCalledByUser; + + /// + /// + /// + public bool Connected + { + get { return _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + // private Timer for auto reconnect + private CTimer RetryTimer; + + #endregion + + #region GenericSecureTcpIpClient properties + + /// + /// Bool to show whether the server requires a preshared key. This is used in the DynamicTCPServer class + /// + public bool SharedKeyRequired { get; set; } + + /// + /// S+ helper for requires shared key bool + /// + public ushort USharedKeyRequired + { + set + { + if (value == 1) + SharedKeyRequired = true; + else + SharedKeyRequired = false; + } + } + + /// + /// SharedKey is sent for varification to the server. Shared key can be any text (255 char limit in SIMPL+ Module), but must match the Shared Key on the Server module + /// + public string SharedKey { get; set; } + + /// + /// flag to show the client is waiting for the server to send the shared key + /// + private bool WaitingForSharedKeyResponse { get; set; } + + /// + /// Semaphore on connect method + /// + bool IsTryingToConnect; + + /// + /// Bool showing if socket is ready for communication after shared key exchange + /// + public bool IsReadyForCommunication { get; set; } + + /// + /// S+ helper for IsReadyForCommunication + /// + public ushort UIsReadyForCommunication + { + get { return (ushort)(IsReadyForCommunication ? 1 : 0); } + } + + /// + /// Bool Heartbeat Enabled flag + /// + public bool HeartbeatEnabled { get; set; } + + /// + /// S+ helper for Heartbeat Enabled + /// + public ushort UHeartbeatEnabled + { + get { return (ushort)(HeartbeatEnabled ? 1 : 0); } + set { HeartbeatEnabled = value == 1; } + } + + /// + /// Heartbeat String + /// + public string HeartbeatString { get; set; } + //public int HeartbeatInterval = 50000; + + /// + /// Milliseconds before server expects another heartbeat. Set by property HeartbeatRequiredIntervalInSeconds which is driven from S+ + /// + public int HeartbeatInterval { get; set; } + + /// + /// Simpl+ Heartbeat Analog value in seconds + /// + public ushort HeartbeatRequiredIntervalInSeconds { set { HeartbeatInterval = (value * 1000); } } + + CTimer HeartbeatSendTimer; + CTimer HeartbeatAckTimer; + + // Used to force disconnection on a dead connect attempt + CTimer ConnectFailTimer; + CTimer WaitForSharedKey; + private int ConnectionCount; + + bool ProgramIsStopping; + + /// + /// Queue lock + /// + CCriticalSection DequeueLock = new CCriticalSection(); + + /// + /// Receive Queue size. Defaults to 20. Will set to 20 if QueueSize property is less than 20. Use constructor or set queue size property before + /// calling initialize. + /// + public int ReceiveQueueSize { get; set; } + + /// + /// Queue to temporarily store received messages with the source IP and Port info. Defaults to size 20. Use constructor or set queue size property before + /// calling initialize. + /// + private CrestronQueue MessageQueue; + + #endregion + + #region Constructors + + /// + /// Constructor + /// + /// + /// + /// + /// + public GenericSecureTcpIpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + Hostname = address; + Port = port; + BufferSize = bufferSize; + AutoReconnectIntervalMs = 5000; + + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + } + + /// + /// Contstructor that sets all properties by calling the initialize method with a config object. + /// + /// + /// + public GenericSecureTcpIpClient(string key, TcpClientConfigObject clientConfigObject) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + Initialize(clientConfigObject); + } + + /// + /// Default constructor for S+ + /// + public GenericSecureTcpIpClient() + : base(SplusKey) + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + } + + /// + /// Just to help S+ set the key + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Initialize called by the constructor that accepts a client config object. Can be called later to reset properties of client. + /// + /// + public void Initialize(TcpClientConfigObject config) + { + if (config == null) + { + Debug.Console(0, this, "Could not initialize client with key: {0}", Key); + return; + } + try + { + Hostname = config.Control.TcpSshProperties.Address; + Port = config.Control.TcpSshProperties.Port > 0 && config.Control.TcpSshProperties.Port <= 65535 + ? config.Control.TcpSshProperties.Port + : 80; + + AutoReconnect = config.Control.TcpSshProperties.AutoReconnect; + AutoReconnectIntervalMs = config.Control.TcpSshProperties.AutoReconnectIntervalMs > 1000 + ? config.Control.TcpSshProperties.AutoReconnectIntervalMs + : 5000; + + SharedKey = config.SharedKey; + SharedKeyRequired = config.SharedKeyRequired; + + HeartbeatEnabled = config.HeartbeatRequired; + HeartbeatRequiredIntervalInSeconds = config.HeartbeatRequiredIntervalInSeconds > 0 + ? config.HeartbeatRequiredIntervalInSeconds + : (ushort)15; + + + HeartbeatString = string.IsNullOrEmpty(config.HeartbeatStringToMatch) + ? "heartbeat" + : config.HeartbeatStringToMatch; + + BufferSize = config.Control.TcpSshProperties.BufferSize > 2000 + ? config.Control.TcpSshProperties.BufferSize + : 2000; + + ReceiveQueueSize = config.ReceiveQueueSize > 20 + ? config.ReceiveQueueSize + : 20; + + MessageQueue = new CrestronQueue(ReceiveQueueSize); + } + catch (Exception ex) + { + Debug.Console(0, this, "Exception initializing client with key: {0}\rException: {1}", Key, ex); + } + } + + #endregion + + /// + /// Handles closing this up when the program shuts down + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping || programEventType == eProgramStatusEventType.Paused) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Program stopping. Closing _client connection"); + ProgramIsStopping = true; + Disconnect(); + } + + } + + /// + /// Deactivate the client + /// + /// + public override bool Deactivate() + { + if (_client != null) + { + _client.SocketStatusChange -= this.Client_SocketStatusChange; + DisconnectClient(); + } + return true; + } + + /// + /// Connect Method. Will return if already connected. Will write errors if missing address, port, or unique key/name. + /// + public void Connect() + { + ConnectionCount++; + Debug.Console(2, this, "Attempting connect Count:{0}", ConnectionCount); + + + if (IsConnected) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already connected. Ignoring."); + return; + } + if (IsTryingToConnect) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already trying to connect. Ignoring."); + return; + } + try + { + IsTryingToConnect = true; + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No address set"); + return; + } + if (Port < 1 || Port > 65535) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: Invalid port"); + return; + } + if (string.IsNullOrEmpty(SharedKey) && SharedKeyRequired) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No Shared Key set"); + return; + } + + // clean up previous client + if (_client != null) + { + Disconnect(); + } + DisconnectCalledByUser = false; + + _client = new SecureTCPClient(Hostname, Port, BufferSize); + _client.SocketStatusChange += Client_SocketStatusChange; + if (HeartbeatEnabled) + _client.SocketSendOrReceiveTimeOutInMs = (HeartbeatInterval * 5); + _client.AddressClientConnectedTo = Hostname; + _client.PortNumber = Port; + // SecureClient = c; + + //var timeOfConnect = DateTime.Now.ToString("HH:mm:ss.fff"); + + ConnectFailTimer = new CTimer(o => + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Connect attempt has not finished after 30sec Count:{0}", ConnectionCount); + if (IsTryingToConnect) + { + IsTryingToConnect = false; + + //if (ConnectionHasHungCallback != null) + //{ + // ConnectionHasHungCallback(); + //} + //SecureClient.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + } + }, 30000); + + Debug.Console(2, this, "Making Connection Count:{0}", ConnectionCount); + _client.ConnectToServerAsync(o => + { + Debug.Console(2, this, "ConnectToServerAsync Count:{0} Ran!", ConnectionCount); + + if (ConnectFailTimer != null) + { + ConnectFailTimer.Stop(); + } + IsTryingToConnect = false; + + if (o.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(2, this, "_client connected to {0} on port {1}", o.AddressClientConnectedTo, o.LocalPortNumberOfClient); + o.ReceiveDataAsync(Receive); + + if (SharedKeyRequired) + { + WaitingForSharedKeyResponse = true; + WaitForSharedKey = new CTimer(timer => + { + + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Shared key exchange timer expired. IsReadyForCommunication={0}", IsReadyForCommunication); + // Debug.Console(1, this, "Connect attempt failed {0}", c.ClientStatus); + // This is the only case where we should call DisconectFromServer...Event handeler will trigger the cleanup + o.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + //OnClientReadyForcommunications(false); // Should send false event + }, 15000); + } + else + { + //CLient connected and shared key is not required so just raise the ready for communication event. if Shared key + //required this is called by the shared key being negotiated + if (IsReadyForCommunication == false) + { + OnClientReadyForcommunications(true); // Key not required + } + } + } + else + { + Debug.Console(1, this, "Connect attempt failed {0}", o.ClientStatus); + CheckClosedAndTryReconnect(); + } + }); + } + catch (Exception ex) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Error, "_client connection exception: {0}", ex.Message); + IsTryingToConnect = false; + CheckClosedAndTryReconnect(); + } + } + + /// + /// + /// + public void Disconnect() + { + this.LogVerbose("Disconnect Called"); + + DisconnectCalledByUser = true; + + // stop trying reconnects, if we are + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + + if (_client != null) + { + DisconnectClient(); + this.LogDebug("Disconnected"); + } + } + + /// + /// Does the actual disconnect business + /// + public void DisconnectClient() + { + if (_client == null) return; + + Debug.Console(1, this, "Disconnecting client"); + if (IsConnected) + _client.DisconnectFromServer(); + + // close up client. ALWAYS use this when disconnecting. + IsTryingToConnect = false; + + Debug.Console(2, this, "Disconnecting _client {0}", DisconnectCalledByUser ? ", Called by user" : ""); + _client.SocketStatusChange -= Client_SocketStatusChange; + _client.Dispose(); + _client = null; + + if (ConnectFailTimer == null) return; + ConnectFailTimer.Stop(); + ConnectFailTimer.Dispose(); + ConnectFailTimer = null; + } + + #region Methods + + /// + /// Called from Connect failure or Socket Status change if + /// auto reconnect and socket disconnected (Not disconnected by user) + /// + void CheckClosedAndTryReconnect() + { + if (_client != null) + { + Debug.Console(2, this, "Cleaning up remotely closed/failed connection."); + Disconnect(); + } + if (!DisconnectCalledByUser && AutoReconnect) + { + var halfInterval = AutoReconnectIntervalMs / 2; + var rndTime = new Random().Next(-halfInterval, halfInterval) + AutoReconnectIntervalMs; + Debug.Console(2, this, "Attempting reconnect in {0} ms, randomized", rndTime); + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + if (AutoReconnectTriggered != null) + AutoReconnectTriggered(this, new EventArgs()); + RetryTimer = new CTimer(o => Connect(), rndTime); + } + } + + /// + /// Receive callback + /// + /// + /// + void Receive(SecureTCPClient client, int numBytes) + { + if (numBytes > 0) + { + string str = string.Empty; + var handler = TextReceivedQueueInvoke; + try + { + var bytes = client.IncomingDataBuffer.Take(numBytes).ToArray(); + str = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + Debug.Console(2, this, "_client Received:\r--------\r{0}\r--------", str); + if (!string.IsNullOrEmpty(checkHeartbeat(str))) + { + + if (SharedKeyRequired && str == "SharedKey:") + { + Debug.Console(2, this, "Server asking for shared key, sending"); + SendText(SharedKey + "\n"); + } + else if (SharedKeyRequired && str == "Shared Key Match") + { + StopWaitForSharedKeyTimer(); + + + Debug.Console(2, this, "Shared key confirmed. Ready for communication"); + OnClientReadyForcommunications(true); // Successful key exchange + } + else + { + //var bytesHandler = BytesReceived; + //if (bytesHandler != null) + // bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + var textHandler = TextReceived; + if (textHandler != null) + textHandler(this, new GenericCommMethodReceiveTextArgs(str)); + if (handler != null) + { + MessageQueue.TryToEnqueue(new GenericTcpServerCommMethodReceiveTextArgs(str)); + } + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error receiving data: {1}. Error: {0}", ex.Message, str); + } + if (client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + client.ReceiveDataAsync(Receive); + + //Check to see if there is a subscription to the TextReceivedQueueInvoke event. If there is start the dequeue thread. + if (handler != null) + { + var gotLock = DequeueLock.TryEnter(); + if (gotLock) + CrestronInvoke.BeginInvoke((o) => DequeueEvent()); + } + } + else //JAG added this as I believe the error return is 0 bytes like the server. See help when hover on ReceiveAsync + { + client.DisconnectFromServer(); + } + } + + /// + /// This method gets spooled up in its own thread an protected by a CCriticalSection to prevent multiple threads from running concurrently. + /// It will dequeue items as they are enqueued automatically. + /// + void DequeueEvent() + { + try + { + while (true) + { + // Pull from Queue and fire an event. Block indefinitely until an item can be removed, similar to a Gather. + var message = MessageQueue.Dequeue(); + var handler = TextReceivedQueueInvoke; + if (handler != null) + { + handler(this, message); + } + } + } + catch (Exception e) + { + this.LogException(e, "DequeueEvent error"); + } + // Make sure to leave the CCritical section in case an exception above stops this thread, or we won't be able to restart it. + if (DequeueLock != null) + { + DequeueLock.Leave(); + } + } + + void HeartbeatStart() + { + if (HeartbeatEnabled) + { + Debug.Console(2, this, "Starting Heartbeat"); + if (HeartbeatSendTimer == null) + { + + HeartbeatSendTimer = new CTimer(this.SendHeartbeat, null, HeartbeatInterval, HeartbeatInterval); + } + if (HeartbeatAckTimer == null) + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + } + + } + void HeartbeatStop() + { + + if (HeartbeatSendTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Send"); + HeartbeatSendTimer.Stop(); + HeartbeatSendTimer = null; + } + if (HeartbeatAckTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Ack"); + HeartbeatAckTimer.Stop(); + HeartbeatAckTimer = null; + } + + } + void SendHeartbeat(object notused) + { + this.SendText(HeartbeatString); + Debug.Console(2, this, "Sending Heartbeat"); + + } + + //private method to check heartbeat requirements and start or reset timer + string checkHeartbeat(string received) + { + try + { + if (HeartbeatEnabled) + { + if (!string.IsNullOrEmpty(HeartbeatString)) + { + var remainingText = received.Replace(HeartbeatString, ""); + var noDelimiter = received.Trim(new char[] { '\r', '\n' }); + if (noDelimiter.Contains(HeartbeatString)) + { + if (HeartbeatAckTimer != null) + { + HeartbeatAckTimer.Reset(HeartbeatInterval * 2); + } + else + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + Debug.Console(2, this, "Heartbeat Received: {0}, from Server", HeartbeatString); + return remainingText; + } + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error checking heartbeat: {0}", ex.Message); + } + return received; + } + + + + void HeartbeatAckTimerFail(object o) + { + try + { + + if (IsConnected) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "Heartbeat not received from Server...DISCONNECTING BECAUSE HEARTBEAT REQUIRED IS TRUE"); + SendText("Heartbeat not received by server, closing connection"); + CheckClosedAndTryReconnect(); + } + + } + catch (Exception ex) + { + ErrorLog.Error("Heartbeat timeout Error on _client: {0}, {1}", Key, ex); + } + } + + /// + /// + /// + void StopWaitForSharedKeyTimer() + { + if (WaitForSharedKey != null) + { + WaitForSharedKey.Stop(); + WaitForSharedKey = null; + } + } + + /// + /// General send method + /// + public void SendText(string text) + { + if (!string.IsNullOrEmpty(text)) + { + try + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + if (_client != null && _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + _client.SendDataAsync(bytes, bytes.Length, (c, n) => + { + // HOW IN THE HELL DO WE CATCH AN EXCEPTION IN SENDING????? + if (n <= 0) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "[{0}] Sent zero bytes. Was there an error?", this.Key); + } + }); + } + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending text: {1}. Error: {0}", ex.Message, text); + } + } + } + + /// + /// + /// + public void SendBytes(byte[] bytes) + { + if (bytes.Length > 0) + { + try + { + if (_client != null && _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + _client.SendData(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending bytes. Error: {0}", ex.Message); + } + } + } + + /// + /// SocketStatusChange Callback + /// + /// + /// + void Client_SocketStatusChange(SecureTCPClient client, SocketStatus clientSocketStatus) + { + if (ProgramIsStopping) + { + ProgramIsStopping = false; + return; + } + try + { + Debug.Console(2, this, "Socket status change: {0} ({1})", client.ClientStatus, (ushort)(client.ClientStatus)); + + OnConnectionChange(); + // The client could be null or disposed by this time... + if (_client == null || _client.ClientStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + HeartbeatStop(); + OnClientReadyForcommunications(false); // socket has gone low + CheckClosedAndTryReconnect(); + } + } + catch (Exception ex) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Error in socket status change callback. Error: {0}\r\r{1}", ex, ex.InnerException); + } + } + + /// + /// Helper for ConnectionChange event + /// + void OnConnectionChange() + { + var handler = ConnectionChange; + if (handler == null) return; + + handler(this, new GenericSocketStatusChageEventArgs(this)); + } + + /// + /// Helper to fire ClientReadyForCommunications event + /// + void OnClientReadyForcommunications(bool isReady) + { + IsReadyForCommunication = isReady; + if (IsReadyForCommunication) + HeartbeatStart(); + + var handler = ClientReadyForCommunications; + if (handler == null) return; + + handler(this, new GenericTcpServerClientReadyForcommunicationsEventArgs(IsReadyForCommunication)); + } + #endregion + } + +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericSecureTcpIpClient_ForServer.cs b/src/PepperDash.Core/Comm/GenericSecureTcpIpClient_ForServer.cs new file mode 100644 index 00000000..447632e3 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericSecureTcpIpClient_ForServer.cs @@ -0,0 +1,909 @@ +/*PepperDash Technology Corp. +JAG +Copyright: 2017 +------------------------------------ +***Notice of Ownership and Copyright*** +The material in which this notice appears is the property of PepperDash Technology Corporation, +which claims copyright under the laws of the United States of America in the entire body of material +and in all parts thereof, regardless of the use to which it is being put. Any use, in whole or in part, +of this material by another party without the express written permission of PepperDash Technology Corporation is prohibited. +PepperDash Technology Corporation reserves all rights under applicable laws. +------------------------------------ */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using PepperDash.Core.Logging; + +namespace PepperDash.Core +{ + /// + /// Generic secure TCP/IP client for server + /// + public class GenericSecureTcpIpClient_ForServer : Device, IAutoReconnect + { + /// + /// Band aid delegate for choked server + /// + internal delegate void ConnectionHasHungCallbackDelegate(); + + #region Events + + //public event EventHandler BytesReceived; + + /// + /// Notifies of text received + /// + public event EventHandler TextReceived; + + /// + /// Notifies of auto reconnect sequence triggered + /// + public event EventHandler AutoReconnectTriggered; + + /// + /// Event for Receiving text. Once subscribed to this event the receive callback will start a thread that dequeues the messages and invokes the event on a new thread. + /// It is not recommended to use both the TextReceived event and the TextReceivedQueueInvoke event. + /// + public event EventHandler TextReceivedQueueInvoke; + + /// + /// Notifies of socket status change + /// + public event EventHandler ConnectionChange; + + + /// + /// This is something of a band-aid callback. If the client times out during the connection process, because the server + /// is stuck, this will fire. It is intended to be used by the Server class monitor client, to help + /// keep a watch on the server and reset it if necessary. + /// + internal ConnectionHasHungCallbackDelegate ConnectionHasHungCallback; + + /// + /// For a client with a pre shared key, this will fire after the communication is established and the key exchange is complete. If you require + /// a key and subscribe to the socket change event and try to send data on a connection the data sent will interfere with the key exchange and disconnect. + /// + public event EventHandler ClientReadyForCommunications; + + #endregion + + #region Properties & Variables + + /// + /// Address of server + /// + public string Hostname { get; set; } + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// S+ helper + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Bool to show whether the server requires a preshared key. This is used in the DynamicTCPServer class + /// + public bool SharedKeyRequired { get; set; } + + /// + /// S+ helper for requires shared key bool + /// + public ushort USharedKeyRequired + { + set + { + if (value == 1) + SharedKeyRequired = true; + else + SharedKeyRequired = false; + } + } + + /// + /// SharedKey is sent for varification to the server. Shared key can be any text (255 char limit in SIMPL+ Module), but must match the Shared Key on the Server module + /// + public string SharedKey { get; set; } + + /// + /// flag to show the client is waiting for the server to send the shared key + /// + private bool WaitingForSharedKeyResponse { get; set; } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// Semaphore on connect method + /// + bool IsTryingToConnect; + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get + { + if (Client != null) + return Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; + else + return false; + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// Bool showing if socket is ready for communication after shared key exchange + /// + public bool IsReadyForCommunication { get; set; } + + /// + /// S+ helper for IsReadyForCommunication + /// + public ushort UIsReadyForCommunication + { + get { return (ushort)(IsReadyForCommunication ? 1 : 0); } + } + + /// + /// Client socket status Read only + /// + public SocketStatus ClientStatus + { + get + { + if (Client != null) + return Client.ClientStatus; + else + return SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// Contains the familiar Simpl analog status values. This drives the ConnectionChange event + /// and IsConnected would be true when this == 2. + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Status text shows the message associated with socket status + /// + public string ClientStatusText { get { return ClientStatus.ToString(); } } + + /// + /// bool to track if auto reconnect should be set on the socket + /// + 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; } + + /// + /// Flag Set only when the disconnect method is called. + /// + bool DisconnectCalledByUser; + + /// + /// private Timer for auto reconnect + /// + CTimer RetryTimer; + + + /// + /// + /// + public bool HeartbeatEnabled { get; set; } + /// + /// + /// + public ushort UHeartbeatEnabled + { + get { return (ushort)(HeartbeatEnabled ? 1 : 0); } + set { HeartbeatEnabled = value == 1; } + } + + /// + /// + /// + public string HeartbeatString { get; set; } + //public int HeartbeatInterval = 50000; + + /// + /// Milliseconds before server expects another heartbeat. Set by property HeartbeatRequiredIntervalInSeconds which is driven from S+ + /// + public int HeartbeatInterval { get; set; } + + /// + /// Simpl+ Heartbeat Analog value in seconds + /// + public ushort HeartbeatRequiredIntervalInSeconds { set { HeartbeatInterval = (value * 1000); } } + + CTimer HeartbeatSendTimer; + CTimer HeartbeatAckTimer; + /// + /// Used to force disconnection on a dead connect attempt + /// + CTimer ConnectFailTimer; + CTimer WaitForSharedKey; + private int ConnectionCount; + /// + /// Internal secure client + /// + SecureTCPClient Client; + + bool ProgramIsStopping; + + /// + /// Queue lock + /// + CCriticalSection DequeueLock = new CCriticalSection(); + + /// + /// Receive Queue size. Defaults to 20. Will set to 20 if QueueSize property is less than 20. Use constructor or set queue size property before + /// calling initialize. + /// + public int ReceiveQueueSize { get; set; } + + /// + /// Queue to temporarily store received messages with the source IP and Port info. Defaults to size 20. Use constructor or set queue size property before + /// calling initialize. + /// + private CrestronQueue MessageQueue; + + + #endregion + + #region Constructors + + /// + /// Constructor + /// + /// + /// + /// + /// + public GenericSecureTcpIpClient_ForServer(string key, string address, int port, int bufferSize) + : base(key) + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + Hostname = address; + Port = port; + BufferSize = bufferSize; + AutoReconnectIntervalMs = 5000; + + } + + /// + /// Constructor for S+ + /// + public GenericSecureTcpIpClient_ForServer() + : base("Uninitialized Secure Tcp Client For Server") + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + } + + /// + /// Contstructor that sets all properties by calling the initialize method with a config object. + /// + /// + /// + public GenericSecureTcpIpClient_ForServer(string key, TcpClientConfigObject clientConfigObject) + : base(key) + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + Initialize(clientConfigObject); + } + + #endregion + + #region Methods + + /// + /// Just to help S+ set the key + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Initialize called by the constructor that accepts a client config object. Can be called later to reset properties of client. + /// + /// + public void Initialize(TcpClientConfigObject clientConfigObject) + { + try + { + if (clientConfigObject != null) + { + var TcpSshProperties = clientConfigObject.Control.TcpSshProperties; + Hostname = TcpSshProperties.Address; + AutoReconnect = TcpSshProperties.AutoReconnect; + AutoReconnectIntervalMs = TcpSshProperties.AutoReconnectIntervalMs > 1000 ? + TcpSshProperties.AutoReconnectIntervalMs : 5000; + SharedKey = clientConfigObject.SharedKey; + SharedKeyRequired = clientConfigObject.SharedKeyRequired; + HeartbeatEnabled = clientConfigObject.HeartbeatRequired; + HeartbeatRequiredIntervalInSeconds = clientConfigObject.HeartbeatRequiredIntervalInSeconds > 0 ? + clientConfigObject.HeartbeatRequiredIntervalInSeconds : (ushort)15; + HeartbeatString = string.IsNullOrEmpty(clientConfigObject.HeartbeatStringToMatch) ? "heartbeat" : clientConfigObject.HeartbeatStringToMatch; + Port = TcpSshProperties.Port; + BufferSize = TcpSshProperties.BufferSize > 2000 ? TcpSshProperties.BufferSize : 2000; + ReceiveQueueSize = clientConfigObject.ReceiveQueueSize > 20 ? clientConfigObject.ReceiveQueueSize : 20; + MessageQueue = new CrestronQueue(ReceiveQueueSize); + } + else + { + ErrorLog.Error("Could not initialize client with key: {0}", Key); + } + } + catch + { + ErrorLog.Error("Could not initialize client with key: {0}", Key); + } + } + + /// + /// Handles closing this up when the program shuts down + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping || programEventType == eProgramStatusEventType.Paused) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Program stopping. Closing Client connection"); + ProgramIsStopping = true; + Disconnect(); + } + + } + + /// + /// Connect Method. Will return if already connected. Will write errors if missing address, port, or unique key/name. + /// + public void Connect() + { + ConnectionCount++; + Debug.Console(2, this, "Attempting connect Count:{0}", ConnectionCount); + + + if (IsConnected) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already connected. Ignoring."); + return; + } + if (IsTryingToConnect) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already trying to connect. Ignoring."); + return; + } + try + { + IsTryingToConnect = true; + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No address set"); + return; + } + if (Port < 1 || Port > 65535) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: Invalid port"); + return; + } + if (string.IsNullOrEmpty(SharedKey) && SharedKeyRequired) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No Shared Key set"); + return; + } + + // clean up previous client + if (Client != null) + { + Cleanup(); + } + DisconnectCalledByUser = false; + + Client = new SecureTCPClient(Hostname, Port, BufferSize); + Client.SocketStatusChange += Client_SocketStatusChange; + if (HeartbeatEnabled) + Client.SocketSendOrReceiveTimeOutInMs = (HeartbeatInterval * 5); + Client.AddressClientConnectedTo = Hostname; + Client.PortNumber = Port; + // SecureClient = c; + + //var timeOfConnect = DateTime.Now.ToString("HH:mm:ss.fff"); + + ConnectFailTimer = new CTimer(o => + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Connect attempt has not finished after 30sec Count:{0}", ConnectionCount); + if (IsTryingToConnect) + { + IsTryingToConnect = false; + + //if (ConnectionHasHungCallback != null) + //{ + // ConnectionHasHungCallback(); + //} + //SecureClient.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + } + }, 30000); + + Debug.Console(2, this, "Making Connection Count:{0}", ConnectionCount); + Client.ConnectToServerAsync(o => + { + Debug.Console(2, this, "ConnectToServerAsync Count:{0} Ran!", ConnectionCount); + + if (ConnectFailTimer != null) + { + ConnectFailTimer.Stop(); + } + IsTryingToConnect = false; + + if (o.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(2, this, "Client connected to {0} on port {1}", o.AddressClientConnectedTo, o.LocalPortNumberOfClient); + o.ReceiveDataAsync(Receive); + + if (SharedKeyRequired) + { + WaitingForSharedKeyResponse = true; + WaitForSharedKey = new CTimer(timer => + { + + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Shared key exchange timer expired. IsReadyForCommunication={0}", IsReadyForCommunication); + // Debug.Console(1, this, "Connect attempt failed {0}", c.ClientStatus); + // This is the only case where we should call DisconectFromServer...Event handeler will trigger the cleanup + o.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + //OnClientReadyForcommunications(false); // Should send false event + }, 15000); + } + else + { + //CLient connected and shared key is not required so just raise the ready for communication event. if Shared key + //required this is called by the shared key being negotiated + if (IsReadyForCommunication == false) + { + OnClientReadyForcommunications(true); // Key not required + } + } + } + else + { + Debug.Console(1, this, "Connect attempt failed {0}", o.ClientStatus); + CheckClosedAndTryReconnect(); + } + }); + } + catch (Exception ex) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Error, "Client connection exception: {0}", ex.Message); + IsTryingToConnect = false; + CheckClosedAndTryReconnect(); + } + } + + /// + /// + /// + public void Disconnect() + { + this.LogVerbose("Disconnect Called"); + + DisconnectCalledByUser = true; + if (IsConnected) + { + Client.DisconnectFromServer(); + + } + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + Cleanup(); + } + + /// + /// Internal call to close up client. ALWAYS use this when disconnecting. + /// + void Cleanup() + { + IsTryingToConnect = false; + + if (Client != null) + { + //SecureClient.DisconnectFromServer(); + Debug.Console(2, this, "Disconnecting Client {0}", DisconnectCalledByUser ? ", Called by user" : ""); + Client.SocketStatusChange -= Client_SocketStatusChange; + Client.Dispose(); + Client = null; + } + if (ConnectFailTimer != null) + { + ConnectFailTimer.Stop(); + ConnectFailTimer.Dispose(); + ConnectFailTimer = null; + } + } + + + /// ff + /// Called from Connect failure or Socket Status change if + /// auto reconnect and socket disconnected (Not disconnected by user) + /// + void CheckClosedAndTryReconnect() + { + if (Client != null) + { + Debug.Console(2, this, "Cleaning up remotely closed/failed connection."); + Cleanup(); + } + if (!DisconnectCalledByUser && AutoReconnect) + { + var halfInterval = AutoReconnectIntervalMs / 2; + var rndTime = new Random().Next(-halfInterval, halfInterval) + AutoReconnectIntervalMs; + Debug.Console(2, this, "Attempting reconnect in {0} ms, randomized", rndTime); + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + if(AutoReconnectTriggered != null) + AutoReconnectTriggered(this, new EventArgs()); + RetryTimer = new CTimer(o => Connect(), rndTime); + } + } + + /// + /// Receive callback + /// + /// + /// + void Receive(SecureTCPClient client, int numBytes) + { + if (numBytes > 0) + { + string str = string.Empty; + var handler = TextReceivedQueueInvoke; + try + { + var bytes = client.IncomingDataBuffer.Take(numBytes).ToArray(); + str = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + Debug.Console(2, this, "Client Received:\r--------\r{0}\r--------", str); + if (!string.IsNullOrEmpty(checkHeartbeat(str))) + { + + if (SharedKeyRequired && str == "SharedKey:") + { + Debug.Console(2, this, "Server asking for shared key, sending"); + SendText(SharedKey + "\n"); + } + else if (SharedKeyRequired && str == "Shared Key Match") + { + StopWaitForSharedKeyTimer(); + + + Debug.Console(2, this, "Shared key confirmed. Ready for communication"); + OnClientReadyForcommunications(true); // Successful key exchange + } + else + { + //var bytesHandler = BytesReceived; + //if (bytesHandler != null) + // bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + var textHandler = TextReceived; + if (textHandler != null) + textHandler(this, new GenericTcpServerCommMethodReceiveTextArgs(str)); + if (handler != null) + { + MessageQueue.TryToEnqueue(new GenericTcpServerCommMethodReceiveTextArgs(str)); + } + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error receiving data: {1}. Error: {0}", ex.Message, str); + } + if (client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + client.ReceiveDataAsync(Receive); + + //Check to see if there is a subscription to the TextReceivedQueueInvoke event. If there is start the dequeue thread. + if (handler != null) + { + var gotLock = DequeueLock.TryEnter(); + if (gotLock) + CrestronInvoke.BeginInvoke((o) => DequeueEvent()); + } + } + else //JAG added this as I believe the error return is 0 bytes like the server. See help when hover on ReceiveAsync + { + client.DisconnectFromServer(); + } + } + + /// + /// This method gets spooled up in its own thread an protected by a CCriticalSection to prevent multiple threads from running concurrently. + /// It will dequeue items as they are enqueued automatically. + /// + void DequeueEvent() + { + try + { + while (true) + { + // Pull from Queue and fire an event. Block indefinitely until an item can be removed, similar to a Gather. + var message = MessageQueue.Dequeue(); + var handler = TextReceivedQueueInvoke; + if (handler != null) + { + handler(this, message); + } + } + } + catch (Exception e) + { + this.LogException(ex, "DequeueEvent error"); + } + // Make sure to leave the CCritical section in case an exception above stops this thread, or we won't be able to restart it. + if (DequeueLock != null) + { + DequeueLock.Leave(); + } + } + + void HeartbeatStart() + { + if (HeartbeatEnabled) + { + Debug.Console(2, this, "Starting Heartbeat"); + if (HeartbeatSendTimer == null) + { + + HeartbeatSendTimer = new CTimer(this.SendHeartbeat, null, HeartbeatInterval, HeartbeatInterval); + } + if (HeartbeatAckTimer == null) + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + } + + } + void HeartbeatStop() + { + + if (HeartbeatSendTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Send"); + HeartbeatSendTimer.Stop(); + HeartbeatSendTimer = null; + } + if (HeartbeatAckTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Ack"); + HeartbeatAckTimer.Stop(); + HeartbeatAckTimer = null; + } + + } + void SendHeartbeat(object notused) + { + this.SendText(HeartbeatString); + Debug.Console(2, this, "Sending Heartbeat"); + + } + + //private method to check heartbeat requirements and start or reset timer + string checkHeartbeat(string received) + { + try + { + if (HeartbeatEnabled) + { + if (!string.IsNullOrEmpty(HeartbeatString)) + { + var remainingText = received.Replace(HeartbeatString, ""); + var noDelimiter = received.Trim(new char[] { '\r', '\n' }); + if (noDelimiter.Contains(HeartbeatString)) + { + if (HeartbeatAckTimer != null) + { + HeartbeatAckTimer.Reset(HeartbeatInterval * 2); + } + else + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + Debug.Console(2, this, "Heartbeat Received: {0}, from Server", HeartbeatString); + return remainingText; + } + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error checking heartbeat: {0}", ex.Message); + } + return received; + } + + + + void HeartbeatAckTimerFail(object o) + { + try + { + + if (IsConnected) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "Heartbeat not received from Server...DISCONNECTING BECAUSE HEARTBEAT REQUIRED IS TRUE"); + SendText("Heartbeat not received by server, closing connection"); + CheckClosedAndTryReconnect(); + } + + } + catch (Exception ex) + { + ErrorLog.Error("Heartbeat timeout Error on Client: {0}, {1}", Key, ex); + } + } + + /// + /// + /// + void StopWaitForSharedKeyTimer() + { + if (WaitForSharedKey != null) + { + WaitForSharedKey.Stop(); + WaitForSharedKey = null; + } + } + + /// + /// General send method + /// + public void SendText(string text) + { + if (!string.IsNullOrEmpty(text)) + { + try + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + if (Client != null && Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + Client.SendDataAsync(bytes, bytes.Length, (c, n) => + { + // HOW IN THE HELL DO WE CATCH AN EXCEPTION IN SENDING????? + if (n <= 0) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "[{0}] Sent zero bytes. Was there an error?", this.Key); + } + }); + } + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending text: {1}. Error: {0}", ex.Message, text); + } + } + } + + /// + /// + /// + public void SendBytes(byte[] bytes) + { + if (bytes.Length > 0) + { + try + { + if (Client != null && Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + Client.SendData(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending bytes. Error: {0}", ex.Message); + } + } + } + + /// + /// SocketStatusChange Callback + /// + /// + /// + void Client_SocketStatusChange(SecureTCPClient client, SocketStatus clientSocketStatus) + { + if (ProgramIsStopping) + { + ProgramIsStopping = false; + return; + } + try + { + Debug.Console(2, this, "Socket status change: {0} ({1})", client.ClientStatus, (ushort)(client.ClientStatus)); + + OnConnectionChange(); + // The client could be null or disposed by this time... + if (Client == null || Client.ClientStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + HeartbeatStop(); + OnClientReadyForcommunications(false); // socket has gone low + CheckClosedAndTryReconnect(); + } + } + catch (Exception ex) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Error in socket status change callback. Error: {0}\r\r{1}", ex, ex.InnerException); + } + } + + /// + /// Helper for ConnectionChange event + /// + void OnConnectionChange() + { + var handler = ConnectionChange; + if (handler != null) + ConnectionChange(this, new GenericTcpServerSocketStatusChangeEventArgs(this, Client.ClientStatus)); + } + + /// + /// Helper to fire ClientReadyForCommunications event + /// + void OnClientReadyForcommunications(bool isReady) + { + IsReadyForCommunication = isReady; + if (this.IsReadyForCommunication) { HeartbeatStart(); } + var handler = ClientReadyForCommunications; + if (handler != null) + handler(this, new GenericTcpServerClientReadyForcommunicationsEventArgs(IsReadyForCommunication)); + } + #endregion + } + +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericSecureTcpIpServer.cs b/src/PepperDash.Core/Comm/GenericSecureTcpIpServer.cs new file mode 100644 index 00000000..e0da068f --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericSecureTcpIpServer.cs @@ -0,0 +1,1084 @@ +/*PepperDash Technology Corp. +JAG +Copyright: 2017 +------------------------------------ +***Notice of Ownership and Copyright*** +The material in which this notice appears is the property of PepperDash Technology Corporation, +which claims copyright under the laws of the United States of America in the entire body of material +and in all parts thereof, regardless of the use to which it is being put. Any use, in whole or in part, +of this material by another party without the express written permission of PepperDash Technology Corporation is prohibited. +PepperDash Technology Corporation reserves all rights under applicable laws. +------------------------------------ */ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using PepperDash.Core.Logging; + +namespace PepperDash.Core +{ + /// + /// Generic secure TCP/IP server + /// + public class GenericSecureTcpIpServer : Device + { + #region Events + /// + /// Event for Receiving text + /// + public event EventHandler TextReceived; + + /// + /// Event for Receiving text. Once subscribed to this event the receive callback will start a thread that dequeues the messages and invokes the event on a new thread. + /// It is not recommended to use both the TextReceived event and the TextReceivedQueueInvoke event. + /// + public event EventHandler TextReceivedQueueInvoke; + + /// + /// Event for client connection socket status change + /// + public event EventHandler ClientConnectionChange; + + /// + /// Event for Server State Change + /// + public event EventHandler ServerStateChange; + + /// + /// For a server with a pre shared key, this will fire after the communication is established and the key exchange is complete. If no shared key, this will fire + /// after connection is successful. Use this event to know when the client is ready for communication to avoid stepping on shared key. + /// + public event EventHandler ServerClientReadyForCommunications; + + /// + /// A band aid event to notify user that the server has choked. + /// + public ServerHasChokedCallbackDelegate ServerHasChoked { get; set; } + + /// + /// + /// + public delegate void ServerHasChokedCallbackDelegate(); + + #endregion + + #region Properties/Variables + + /// + /// Server listen lock + /// + CCriticalSection ServerCCSection = new CCriticalSection(); + + /// + /// Queue lock + /// + CCriticalSection DequeueLock = new CCriticalSection(); + + /// + /// Receive Queue size. Defaults to 20. Will set to 20 if QueueSize property is less than 20. Use constructor or set queue size property before + /// calling initialize. + /// + public int ReceiveQueueSize { get; set; } + + /// + /// Queue to temporarily store received messages with the source IP and Port info. Defaults to size 20. Use constructor or set queue size property before + /// calling initialize. + /// + private CrestronQueue MessageQueue; + + /// + /// A bandaid client that monitors whether the server is reachable + /// + GenericSecureTcpIpClient_ForServer MonitorClient; + + /// + /// Timer to operate the bandaid monitor client in a loop. + /// + CTimer MonitorClientTimer; + + /// + /// + /// + int MonitorClientFailureCount; + + /// + /// 3 by default + /// + public int MonitorClientMaxFailureCount { get; set; } + + /// + /// Text representation of the Socket Status enum values for the server + /// + public string Status + { + get + { + if (SecureServer != null) + return SecureServer.State.ToString(); + return ServerState.SERVER_NOT_LISTENING.ToString(); + + } + + } + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get + { + if (SecureServer != null) + return (SecureServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED; + return false; + + //return (Secure ? SecureServer != null : UnsecureServer != null) && + //(Secure ? (SecureServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED : + // (UnsecureServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED); + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// Bool showing if socket is connected + /// + public bool IsListening + { + get + { + if (SecureServer != null) + return (SecureServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING; + else + return false; + //return (Secure ? SecureServer != null : UnsecureServer != null) && + //(Secure ? (SecureServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING : + // (UnsecureServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING); + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsListening + { + get { return (ushort)(IsListening ? 1 : 0); } + } + /// + /// Max number of clients this server will allow for connection. Crestron max is 64. This number should be less than 65 + /// + public ushort MaxClients { get; set; } // should be set by parameter in SIMPL+ in the MAIN method, Should not ever need to be configurable + /// + /// Number of clients currently connected. + /// + public ushort NumberOfClientsConnected + { + get + { + if (SecureServer != null) + return (ushort)SecureServer.NumberOfClientsConnected; + return 0; + } + } + + /// + /// Port Server should listen on + /// + public int Port { get; set; } + + /// + /// S+ helper for Port + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Bool to show whether the server requires a preshared key. Must be set the same in the client, and if true shared keys must be identical on server/client + /// + public bool SharedKeyRequired { get; set; } + + /// + /// S+ helper for requires shared key bool + /// + public ushort USharedKeyRequired + { + set + { + if (value == 1) + SharedKeyRequired = true; + else + SharedKeyRequired = false; + } + } + + /// + /// SharedKey is sent for varification to the server. Shared key can be any text (255 char limit in SIMPL+ Module), but must match the Shared Key on the Server module. + /// If SharedKey changes while server is listening or clients are connected, disconnect and stop listening will be called + /// + public string SharedKey { get; set; } + + /// + /// Heartbeat Required bool sets whether server disconnects client if heartbeat is not received + /// + public bool HeartbeatRequired { get; set; } + + /// + /// S+ Helper for Heartbeat Required + /// + public ushort UHeartbeatRequired + { + set + { + if (value == 1) + HeartbeatRequired = true; + else + HeartbeatRequired = false; + } + } + + /// + /// Milliseconds before server expects another heartbeat. Set by property HeartbeatRequiredIntervalInSeconds which is driven from S+ + /// + public int HeartbeatRequiredIntervalMs { get; set; } + + /// + /// Simpl+ Heartbeat Analog value in seconds + /// + public ushort HeartbeatRequiredIntervalInSeconds { set { HeartbeatRequiredIntervalMs = (value * 1000); } } + + /// + /// String to Match for heartbeat. If null or empty any string will reset heartbeat timer + /// + public string HeartbeatStringToMatch { get; set; } + + //private timers for Heartbeats per client + Dictionary HeartbeatTimerDictionary = new Dictionary(); + + //flags to show the secure server is waiting for client at index to send the shared key + List WaitingForSharedKey = new List(); + + List ClientReadyAfterKeyExchange = new List(); + + /// + /// The connected client indexes + /// + public List ConnectedClientsIndexes = new List(); + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// Private flag to note that the server has stopped intentionally + /// + private bool ServerStopped { get; set; } + + //Servers + SecureTCPServer SecureServer; + + /// + /// + /// + bool ProgramIsStopping; + + #endregion + + #region Constructors + /// + /// constructor S+ Does not accept a key. Use initialze with key to set the debug key on this device. If using with + make sure to set all properties manually. + /// + public GenericSecureTcpIpServer() + : base("Uninitialized Secure TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + } + + /// + /// constructor with debug key set at instantiation. Make sure to set all properties before listening. + /// + /// + public GenericSecureTcpIpServer(string key) + : base("Uninitialized Secure TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + Key = key; + } + + /// + /// Contstructor that sets all properties by calling the initialize method with a config object. This does set Queue size. + /// + /// + public GenericSecureTcpIpServer(TcpServerConfigObject serverConfigObject) + : base("Uninitialized Secure TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + Initialize(serverConfigObject); + } + #endregion + + #region Methods - Server Actions + /// + /// Disconnects all clients and stops the server + /// + public void KillServer() + { + ServerStopped = true; + if (MonitorClient != null) + { + MonitorClient.Disconnect(); + } + DisconnectAllClientsForShutdown(); + StopListening(); + } + + /// + /// Initialize Key for device using client name from SIMPL+. Called on Listen from SIMPL+ + /// + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Initialze the server + /// + /// + public void Initialize(TcpServerConfigObject serverConfigObject) + { + try + { + if (serverConfigObject != null || string.IsNullOrEmpty(serverConfigObject.Key)) + { + Key = serverConfigObject.Key; + MaxClients = serverConfigObject.MaxClients; + Port = serverConfigObject.Port; + SharedKeyRequired = serverConfigObject.SharedKeyRequired; + SharedKey = serverConfigObject.SharedKey; + HeartbeatRequired = serverConfigObject.HeartbeatRequired; + HeartbeatRequiredIntervalInSeconds = serverConfigObject.HeartbeatRequiredIntervalInSeconds; + HeartbeatStringToMatch = serverConfigObject.HeartbeatStringToMatch; + BufferSize = serverConfigObject.BufferSize; + ReceiveQueueSize = serverConfigObject.ReceiveQueueSize > 20 ? serverConfigObject.ReceiveQueueSize : 20; + MessageQueue = new CrestronQueue(ReceiveQueueSize); + } + else + { + ErrorLog.Error("Could not initialize server with key: {0}", serverConfigObject.Key); + } + } + catch + { + ErrorLog.Error("Could not initialize server with key: {0}", serverConfigObject.Key); + } + } + + /// + /// Start listening on the specified port + /// + public void Listen() + { + ServerCCSection.Enter(); + try + { + if (Port < 1 || Port > 65535) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Server '{0}': Invalid port", Key); + ErrorLog.Warn(string.Format("Server '{0}': Invalid port", Key)); + return; + } + if (string.IsNullOrEmpty(SharedKey) && SharedKeyRequired) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Server '{0}': No Shared Key set", Key); + ErrorLog.Warn(string.Format("Server '{0}': No Shared Key set", Key)); + return; + } + + + if (SecureServer == null) + { + SecureServer = new SecureTCPServer(Port, MaxClients); + if (HeartbeatRequired) + SecureServer.SocketSendOrReceiveTimeOutInMs = (this.HeartbeatRequiredIntervalMs * 5); + SecureServer.HandshakeTimeout = 30; + SecureServer.SocketStatusChange += new SecureTCPServerSocketStatusChangeEventHandler(SecureServer_SocketStatusChange); + } + else + { + SecureServer.PortNumber = Port; + } + ServerStopped = false; + + // Start the listner + SocketErrorCodes status = SecureServer.WaitForConnectionAsync(IPAddress.Any, SecureConnectCallback); + if (status != SocketErrorCodes.SOCKET_OPERATION_PENDING) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Error, "Error starting WaitForConnectionAsync {0}", status); + } + else + { + ServerStopped = false; + } + OnServerStateChange(SecureServer.State); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Secure Server Status: {0}, Socket Status: {1}", SecureServer.State, SecureServer.ServerSocketStatus); + ServerCCSection.Leave(); + + } + catch (Exception ex) + { + ServerCCSection.Leave(); + ErrorLog.Error("{1} Error with Dynamic Server: {0}", ex.ToString(), Key); + } + } + + /// + /// Stop Listeneing + /// + public void StopListening() + { + try + { + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Stopping Listener"); + if (SecureServer != null) + { + SecureServer.Stop(); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Server State: {0}", SecureServer.State); + OnServerStateChange(SecureServer.State); + } + ServerStopped = true; + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error stopping server. Error: {0}", ex); + } + } + + /// + /// Disconnects Client + /// + /// + public void DisconnectClient(uint client) + { + try + { + SecureServer.Disconnect(client); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected client index: {0}", client); + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Disconnecting client index: {0}. Error: {1}", client, ex); + } + } + /// + /// Disconnect All Clients + /// + public void DisconnectAllClientsForShutdown() + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Disconnecting All Clients"); + if (SecureServer != null) + { + SecureServer.SocketStatusChange -= SecureServer_SocketStatusChange; + foreach (var index in ConnectedClientsIndexes.ToList()) // copy it here so that it iterates properly + { + var i = index; + if (!SecureServer.ClientConnected(index)) + continue; + try + { + SecureServer.Disconnect(i); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected client index: {0}", i); + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Disconnecting client index: {0}. Error: {1}", i, ex); + } + } + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Server Status: {0}", SecureServer.ServerSocketStatus); + } + + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected All Clients"); + ConnectedClientsIndexes.Clear(); + + if (!ProgramIsStopping) + { + OnConnectionChange(); + OnServerStateChange(SecureServer.State); //State shows both listening and connected + } + + // var o = new { }; + } + + /// + /// Broadcast text from server to all connected clients + /// + /// + public void BroadcastText(string text) + { + CCriticalSection CCBroadcast = new CCriticalSection(); + CCBroadcast.Enter(); + try + { + if (ConnectedClientsIndexes.Count > 0) + { + byte[] b = Encoding.GetEncoding(28591).GetBytes(text); + foreach (uint i in ConnectedClientsIndexes) + { + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(i))) + { + SocketErrorCodes error = SecureServer.SendDataAsync(i, b, b.Length, (x, y, z) => { }); + if (error != SocketErrorCodes.SOCKET_OK && error != SocketErrorCodes.SOCKET_OPERATION_PENDING) + this.LogVerbose("{error}", error); + } + } + } + CCBroadcast.Leave(); + } + catch (Exception ex) + { + CCBroadcast.Leave(); + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Broadcasting messages from server. Error: {0}", ex.Message); + } + } + + /// + /// Not sure this is useful in library, maybe Pro?? + /// + /// + /// + public void SendTextToClient(string text, uint clientIndex) + { + try + { + byte[] b = Encoding.GetEncoding(28591).GetBytes(text); + if (SecureServer != null && SecureServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + { + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(clientIndex))) + SecureServer.SendDataAsync(clientIndex, b, b.Length, (x, y, z) => { }); + } + } + catch (Exception ex) + { + Debug.Console(2, this, "Error sending text to client. Text: {1}. Error: {0}", ex.Message, text); + } + } + + //private method to check heartbeat requirements and start or reset timer + string checkHeartbeat(uint clientIndex, string received) + { + try + { + if (HeartbeatRequired) + { + if (!string.IsNullOrEmpty(HeartbeatStringToMatch)) + { + var remainingText = received.Replace(HeartbeatStringToMatch, ""); + var noDelimiter = received.Trim(new char[] { '\r', '\n' }); + if (noDelimiter.Contains(HeartbeatStringToMatch)) + { + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + HeartbeatTimerDictionary[clientIndex].Reset(HeartbeatRequiredIntervalMs); + else + { + CTimer HeartbeatTimer = new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs); + HeartbeatTimerDictionary.Add(clientIndex, HeartbeatTimer); + } + Debug.Console(1, this, "Heartbeat Received: {0}, from client index: {1}", HeartbeatStringToMatch, clientIndex); + // Return Heartbeat + SendTextToClient(HeartbeatStringToMatch, clientIndex); + return remainingText; + } + } + else + { + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + HeartbeatTimerDictionary[clientIndex].Reset(HeartbeatRequiredIntervalMs); + else + { + CTimer HeartbeatTimer = new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs); + HeartbeatTimerDictionary.Add(clientIndex, HeartbeatTimer); + } + Debug.Console(1, this, "Heartbeat Received: {0}, from client index: {1}", received, clientIndex); + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error checking heartbeat: {0}", ex.Message); + } + return received; + } + + /// + /// Get the IP Address for the client at the specifed index + /// + /// + /// + public string GetClientIPAddress(uint clientIndex) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "GetClientIPAddress Index: {0}", clientIndex); + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(clientIndex))) + { + var ipa = this.SecureServer.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "GetClientIPAddress IPAddreess: {0}", ipa); + return ipa; + + } + else + { + return ""; + } + } + + #endregion + + #region Methods - HeartbeatTimer Callback + + void HeartbeatTimer_CallbackFunction(object o) + { + uint clientIndex = 99999; + string address = string.Empty; + try + { + clientIndex = (uint)o; + address = SecureServer.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex); + + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Heartbeat not received for Client index {2} IP: {0}, DISCONNECTING BECAUSE HEARTBEAT REQUIRED IS TRUE {1}", + address, string.IsNullOrEmpty(HeartbeatStringToMatch) ? "" : ("HeartbeatStringToMatch: " + HeartbeatStringToMatch), clientIndex); + + if (SecureServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + SendTextToClient("Heartbeat not received by server, closing connection", clientIndex); + + var discoResult = SecureServer.Disconnect(clientIndex); + //Debug.Console(1, this, "{0}", discoResult); + + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary[clientIndex].Stop(); + HeartbeatTimerDictionary[clientIndex].Dispose(); + HeartbeatTimerDictionary.Remove(clientIndex); + } + } + catch (Exception ex) + { + ErrorLog.Error("{3}: Heartbeat timeout Error on Client Index: {0}, at address: {1}, error: {2}", clientIndex, address, ex.Message, Key); + } + } + + #endregion + + #region Methods - Socket Status Changed Callbacks + /// + /// Secure Server Socket Status Changed Callback + /// + /// + /// + /// + void SecureServer_SocketStatusChange(SecureTCPServer server, uint clientIndex, SocketStatus serverSocketStatus) + { + try + { + + + // Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "SecureServerSocketStatusChange Index:{0} status:{1} Port:{2} IP:{3}", clientIndex, serverSocketStatus, this.SecureServer.GetPortNumberServerAcceptedConnectionFromForSpecificClient(clientIndex), this.SecureServer.GetLocalAddressServerAcceptedConnectionFromForSpecificClient(clientIndex)); + if (serverSocketStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "SecureServerSocketStatusChange ConnectedCLients: {0} ServerState: {1} Port: {2}", SecureServer.NumberOfClientsConnected, SecureServer.State, SecureServer.PortNumber); + + if (ConnectedClientsIndexes.Contains(clientIndex)) + ConnectedClientsIndexes.Remove(clientIndex); + if (HeartbeatRequired && HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary[clientIndex].Stop(); + HeartbeatTimerDictionary[clientIndex].Dispose(); + HeartbeatTimerDictionary.Remove(clientIndex); + } + if (ClientReadyAfterKeyExchange.Contains(clientIndex)) + ClientReadyAfterKeyExchange.Remove(clientIndex); + if (WaitingForSharedKey.Contains(clientIndex)) + WaitingForSharedKey.Remove(clientIndex); + if (SecureServer.MaxNumberOfClientSupported > SecureServer.NumberOfClientsConnected) + { + Listen(); + } + } + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error in Socket Status Change Callback. Error: {0}", ex); + } + //Use a thread for this event so that the server state updates to listening while this event is processed. Listening must be added to the server state + //after every client connection so that the server can check and see if it is at max clients. Due to this the event fires and server listening enum bit flag + //is not set. Putting in a thread allows the state to update before this event processes so that the subscribers to this event get accurate isListening in the event. + CrestronInvoke.BeginInvoke(o => onConnectionChange(clientIndex, server.GetServerSocketStatusForSpecificClient(clientIndex)), null); + } + + #endregion + + #region Methods Connected Callbacks + /// + /// Secure TCP Client Connected to Secure Server Callback + /// + /// + /// + void SecureConnectCallback(SecureTCPServer server, uint clientIndex) + { + try + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "ConnectCallback: IPAddress: {0}. Index: {1}. Status: {2}", + server.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex), + clientIndex, server.GetServerSocketStatusForSpecificClient(clientIndex)); + if (clientIndex != 0) + { + if (server.ClientConnected(clientIndex)) + { + + if (!ConnectedClientsIndexes.Contains(clientIndex)) + { + ConnectedClientsIndexes.Add(clientIndex); + } + if (SharedKeyRequired) + { + if (!WaitingForSharedKey.Contains(clientIndex)) + { + WaitingForSharedKey.Add(clientIndex); + } + byte[] b = Encoding.GetEncoding(28591).GetBytes("SharedKey:"); + server.SendDataAsync(clientIndex, b, b.Length, (x, y, z) => { }); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Sent Shared Key Request to client at {0}", server.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex)); + } + else + { + OnServerClientReadyForCommunications(clientIndex); + } + if (HeartbeatRequired) + { + if (!HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary.Add(clientIndex, new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs)); + } + } + + server.ReceiveDataAsync(clientIndex, SecureReceivedDataAsyncCallback); + } + } + else + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Client attempt faulty."); + } + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error in Socket Status Connect Callback. Error: {0}", ex); + } + + // Rearm the listner + SocketErrorCodes status = server.WaitForConnectionAsync(IPAddress.Any, SecureConnectCallback); + if (status != SocketErrorCodes.SOCKET_OPERATION_PENDING) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Error, "Socket status connect callback status {0}", status); + if (status == SocketErrorCodes.SOCKET_CONNECTION_IN_PROGRESS) + { + // There is an issue where on a failed negotiation we need to stop and start the server. This should still leave connected clients intact. + server.Stop(); + Listen(); + } + } + } + + #endregion + + #region Methods - Send/Receive Callbacks + /// + /// Secure Received Data Async Callback + /// + /// + /// + /// + void SecureReceivedDataAsyncCallback(SecureTCPServer mySecureTCPServer, uint clientIndex, int numberOfBytesReceived) + { + if (numberOfBytesReceived > 0) + { + + string received = "Nothing"; + var handler = TextReceivedQueueInvoke; + try + { + byte[] bytes = mySecureTCPServer.GetIncomingDataBufferForSpecificClient(clientIndex); + received = System.Text.Encoding.GetEncoding(28591).GetString(bytes, 0, numberOfBytesReceived); + if (WaitingForSharedKey.Contains(clientIndex)) + { + received = received.Replace("\r", ""); + received = received.Replace("\n", ""); + if (received != SharedKey) + { + byte[] b = Encoding.GetEncoding(28591).GetBytes("Shared key did not match server. Disconnecting"); + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Client at index {0} Shared key did not match the server, disconnecting client. Key: {1}", clientIndex, received); + mySecureTCPServer.SendData(clientIndex, b, b.Length); + mySecureTCPServer.Disconnect(clientIndex); + + return; + } + + WaitingForSharedKey.Remove(clientIndex); + byte[] success = Encoding.GetEncoding(28591).GetBytes("Shared Key Match"); + mySecureTCPServer.SendDataAsync(clientIndex, success, success.Length, null); + OnServerClientReadyForCommunications(clientIndex); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Client with index {0} provided the shared key and successfully connected to the server", clientIndex); + } + else if (!string.IsNullOrEmpty(checkHeartbeat(clientIndex, received))) + { + onTextReceived(received, clientIndex); + if (handler != null) + { + MessageQueue.TryToEnqueue(new GenericTcpServerCommMethodReceiveTextArgs(received, clientIndex)); + } + } + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Receiving data: {0}. Error: {1}", received, ex); + } + if (mySecureTCPServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + mySecureTCPServer.ReceiveDataAsync(clientIndex, SecureReceivedDataAsyncCallback); + + //Check to see if there is a subscription to the TextReceivedQueueInvoke event. If there is start the dequeue thread. + if (handler != null) + { + var gotLock = DequeueLock.TryEnter(); + if (gotLock) + CrestronInvoke.BeginInvoke((o) => DequeueEvent()); + } + } + else + { + mySecureTCPServer.Disconnect(clientIndex); + } + } + + /// + /// This method gets spooled up in its own thread an protected by a CCriticalSection to prevent multiple threads from running concurrently. + /// It will dequeue items as they are enqueued automatically. + /// + void DequeueEvent() + { + try + { + while (true) + { + // Pull from Queue and fire an event. Block indefinitely until an item can be removed, similar to a Gather. + var message = MessageQueue.Dequeue(); + var handler = TextReceivedQueueInvoke; + if (handler != null) + { + handler(this, message); + } + } + } + catch (Exception e) + { + this.LogException(e, "DequeueEvent error"); + } + // Make sure to leave the CCritical section in case an exception above stops this thread, or we won't be able to restart it. + if (DequeueLock != null) + { + DequeueLock.Leave(); + } + } + + #endregion + + #region Methods - EventHelpers/Callbacks + + //Private Helper method to call the Connection Change Event + void onConnectionChange(uint clientIndex, SocketStatus clientStatus) + { + if (clientIndex != 0) //0 is error not valid client change + { + var handler = ClientConnectionChange; + if (handler != null) + { + handler(this, new GenericTcpServerSocketStatusChangeEventArgs(SecureServer, clientIndex, clientStatus)); + } + } + } + + //Private Helper method to call the Connection Change Event + void OnConnectionChange() + { + if (ProgramIsStopping) + { + return; + } + var handler = ClientConnectionChange; + if (handler != null) + { + handler(this, new GenericTcpServerSocketStatusChangeEventArgs()); + } + } + + //Private Helper Method to call the Text Received Event + void onTextReceived(string text, uint clientIndex) + { + var handler = TextReceived; + if (handler != null) + handler(this, new GenericTcpServerCommMethodReceiveTextArgs(text, clientIndex)); + } + + //Private Helper Method to call the Server State Change Event + void OnServerStateChange(ServerState state) + { + if (ProgramIsStopping) + { + return; + } + var handler = ServerStateChange; + if (handler != null) + { + handler(this, new GenericTcpServerStateChangedEventArgs(state)); + } + } + + /// + /// Private Event Handler method to handle the closing of connections when the program stops + /// + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + ProgramIsStopping = true; + // kill bandaid things + if (MonitorClientTimer != null) + MonitorClientTimer.Stop(); + if (MonitorClient != null) + MonitorClient.Disconnect(); + + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Program stopping. Closing server"); + KillServer(); + } + } + + //Private event handler method to raise the event that the server is ready to send data after a successful client shared key negotiation + void OnServerClientReadyForCommunications(uint clientIndex) + { + ClientReadyAfterKeyExchange.Add(clientIndex); + var handler = ServerClientReadyForCommunications; + if (handler != null) + handler(this, new GenericTcpServerSocketStatusChangeEventArgs( + this, clientIndex, SecureServer.GetServerSocketStatusForSpecificClient(clientIndex))); + } + #endregion + + #region Monitor Client + /// + /// Starts the monitor client cycle. Timed wait, then call RunMonitorClient + /// + void StartMonitorClient() + { + if (MonitorClientTimer != null) + { + return; + } + MonitorClientTimer = new CTimer(o => RunMonitorClient(), 60000); + } + + /// + /// + /// + void RunMonitorClient() + { + MonitorClient = new GenericSecureTcpIpClient_ForServer(Key + "-MONITOR", "127.0.0.1", Port, 2000); + MonitorClient.SharedKeyRequired = this.SharedKeyRequired; + MonitorClient.SharedKey = this.SharedKey; + MonitorClient.ConnectionHasHungCallback = MonitorClientHasHungCallback; + //MonitorClient.ConnectionChange += MonitorClient_ConnectionChange; + MonitorClient.ClientReadyForCommunications += MonitorClient_IsReadyForComm; + + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Starting monitor check"); + + MonitorClient.Connect(); + // From here MonitorCLient either connects or hangs, MonitorClient will call back + + } + + /// + /// + /// + void StopMonitorClient() + { + if (MonitorClient == null) + return; + + MonitorClient.ClientReadyForCommunications -= MonitorClient_IsReadyForComm; + MonitorClient.Disconnect(); + MonitorClient = null; + } + + /// + /// On monitor connect, restart the operation + /// + void MonitorClient_IsReadyForComm(object sender, GenericTcpServerClientReadyForcommunicationsEventArgs args) + { + if (args.IsReady) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Monitor client connection success. Disconnecting in 2s"); + MonitorClientTimer.Stop(); + MonitorClientTimer = null; + MonitorClientFailureCount = 0; + CrestronEnvironment.Sleep(2000); + StopMonitorClient(); + StartMonitorClient(); + } + } + + /// + /// If the client hangs, add to counter and maybe fire the choke event + /// + void MonitorClientHasHungCallback() + { + MonitorClientFailureCount++; + MonitorClientTimer.Stop(); + MonitorClientTimer = null; + StopMonitorClient(); + if (MonitorClientFailureCount < MonitorClientMaxFailureCount) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Warning, "Monitor client connection has hung {0} time{1}, maximum {2}", + MonitorClientFailureCount, MonitorClientFailureCount > 1 ? "s" : "", MonitorClientMaxFailureCount); + StartMonitorClient(); + } + else + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, + "\r***************************\rMonitor client connection has hung a maximum of {0} times. \r***************************", + MonitorClientMaxFailureCount); + + var handler = ServerHasChoked; + if (handler != null) + handler(); + // Some external thing is in charge here. Expected reset of program + } + } + #endregion + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs new file mode 100644 index 00000000..fa5b95bb --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -0,0 +1,592 @@ +using System; +using System.Text; +using System.Threading; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using Org.BouncyCastle.Utilities; +using PepperDash.Core.Logging; +using Renci.SshNet; +using Renci.SshNet.Common; + +namespace PepperDash.Core +{ + /// + /// + /// + public class GenericSshClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SPlusKey = "Uninitialized SshClient"; + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Event that fires when data is received. Delivers args with byte array + /// + public event EventHandler BytesReceived; + + /// + /// Event that fires when data is received. Delivered as text. + /// + public event EventHandler TextReceived; + + /// + /// Event when the connection status changes. + /// + public event EventHandler ConnectionChange; + + ///// + ///// + ///// + //public event GenericSocketStatusChangeEventDelegate SocketStatusChange; + + /// + /// Address of server + /// + public string Hostname { get; set; } + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// Username for server + /// + public string Username { get; set; } + + /// + /// And... Password for server. That was worth documenting! + /// + public string Password { get; set; } + + /// + /// True when the server is connected - when status == 2. + /// + public bool IsConnected + { + // returns false if no client or not connected + get { return Client != null && ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// + /// + public SocketStatus ClientStatus + { + get { return _ClientStatus; } + private set + { + if (_ClientStatus == value) + return; + _ClientStatus = value; + OnConnectionChange(); + } + } + SocketStatus _ClientStatus; + + /// + /// Contains the familiar Simpl analog status values. This drives the ConnectionChange event + /// and IsConnected with be true when this == 2. + /// + public ushort UStatus + { + get { return (ushort)_ClientStatus; } + } + + /// + /// Determines whether client will attempt reconnection on failure. Default is true + /// + public bool AutoReconnect { get; set; } + + /// + /// Will be set and unset by connect and disconnect only + /// + public bool ConnectEnabled { get; private set; } + + /// + /// S+ helper for AutoReconnect + /// + public ushort UAutoReconnect + { + get { return (ushort)(AutoReconnect ? 1 : 0); } + set { AutoReconnect = value == 1; } + } + + /// + /// Millisecond value, determines the timeout period in between reconnect attempts. + /// Set to 5000 by default + /// + public int AutoReconnectIntervalMs { get; set; } + + SshClient Client; + + ShellStream TheStream; + + CTimer ReconnectTimer; + + //Lock object to prevent simulatneous connect/disconnect operations + //private CCriticalSection connectLock = new CCriticalSection(); + private SemaphoreSlim connectLock = new SemaphoreSlim(1); + + private bool DisconnectLogged = false; + + /// + /// Typical constructor. + /// + public GenericSshClient(string key, string hostname, int port, string username, string password) : + base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + Key = key; + Hostname = hostname; + Port = port; + Username = username; + Password = password; + AutoReconnectIntervalMs = 5000; + + ReconnectTimer = new CTimer(o => + { + if (ConnectEnabled) + { + Connect(); + } + }, System.Threading.Timeout.Infinite); + } + + /// + /// S+ Constructor - Must set all properties before calling Connect + /// + public GenericSshClient() + : base(SPlusKey) + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + + ReconnectTimer = new CTimer(o => + { + if (ConnectEnabled) + { + Connect(); + } + }, System.Threading.Timeout.Infinite); + } + + /// + /// Handles closing this up when the program shuts down + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + if (Client != null) + { + this.LogDebug("Program stopping. Closing connection"); + Disconnect(); + } + } + } + + /// + /// Connect to the server, using the provided properties. + /// + public void Connect() + { + // Don't go unless everything is here + if (string.IsNullOrEmpty(Hostname) || Port < 1 || Port > 65535 + || Username == null || Password == null) + { + this.LogError("Connect failed. Check hostname, port, username and password are set or not null"); + return; + } + + ConnectEnabled = true; + + try + { + connectLock.Wait(); + if (IsConnected) + { + this.LogDebug("Connection already connected. Exiting Connect"); + } + else + { + this.LogDebug("Attempting connect"); + + // Cancel reconnect if running. + if (ReconnectTimer != null) + { + ReconnectTimer.Stop(); + } + + // Cleanup the old client if it already exists + if (Client != null) + { + this.LogDebug("Cleaning up disconnected client"); + KillClient(SocketStatus.SOCKET_STATUS_BROKEN_LOCALLY); + } + + // This handles both password and keyboard-interactive (like on OS-X, 'nixes) + KeyboardInteractiveAuthenticationMethod kauth = new KeyboardInteractiveAuthenticationMethod(Username); + kauth.AuthenticationPrompt += new EventHandler(kauth_AuthenticationPrompt); + PasswordAuthenticationMethod pauth = new PasswordAuthenticationMethod(Username, Password); + + this.LogDebug("Creating new SshClient"); + ConnectionInfo connectionInfo = new ConnectionInfo(Hostname, Port, Username, pauth, kauth); + Client = new SshClient(connectionInfo); + Client.ErrorOccurred += Client_ErrorOccurred; + + //Attempt to connect + ClientStatus = SocketStatus.SOCKET_STATUS_WAITING; + try + { + Client.Connect(); + TheStream = Client.CreateShellStream("PDTShell", 0, 0, 0, 0, 65534); + if (TheStream.DataAvailable) + { + // empty the buffer if there is data + string str = TheStream.Read(); + } + TheStream.DataReceived += Stream_DataReceived; + this.LogInformation("Connected"); + ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; + DisconnectLogged = false; + } + catch (SshConnectionException e) + { + var ie = e.InnerException; // The details are inside!! + var errorLogLevel = DisconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error; + + if (ie is SocketException) + { + this.LogException(ie, "CONNECTION failure: Cannot reach host"); + } + + if (ie is System.Net.Sockets.SocketException socketException) + { + this.LogException(ie, "Connection failure: Cannot reach {host} on {port}", + Hostname, Port); + } + if (ie is SshAuthenticationException) + { + this.LogException(ie, "Authentication failure for username {userName}", Username); + } + else + this.LogException(ie, "Error on connect"); + + DisconnectLogged = true; + KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); + if (AutoReconnect) + { + this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); + ReconnectTimer.Reset(AutoReconnectIntervalMs); + } + } + catch(SshOperationTimeoutException ex) + { + this.LogWarning("Connection attempt timed out: {message}", ex.Message); + + DisconnectLogged = true; + KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); + if (AutoReconnect) + { + this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); + ReconnectTimer.Reset(AutoReconnectIntervalMs); + } + } + catch (Exception e) + { + var errorLogLevel = DisconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error; + this.LogException(e, "Unhandled exception on connect"); + DisconnectLogged = true; + KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); + if (AutoReconnect) + { + this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); + ReconnectTimer.Reset(AutoReconnectIntervalMs); + } + } + } + } + finally + { + connectLock.Release(); + } + } + + /// + /// Disconnect the clients and put away it's resources. + /// + public void Disconnect() + { + ConnectEnabled = false; + // Stop trying reconnects, if we are + if (ReconnectTimer != null) + { + ReconnectTimer.Stop(); + // ReconnectTimer = null; + } + + KillClient(SocketStatus.SOCKET_STATUS_BROKEN_LOCALLY); + } + + /// + /// Kills the stream, cleans up the client and sets it to null + /// + private void KillClient(SocketStatus status) + { + KillStream(); + + try + { + if (Client != null) + { + Client.ErrorOccurred -= Client_ErrorOccurred; + Client.Disconnect(); + Client.Dispose(); + Client = null; + ClientStatus = status; + this.LogDebug("Disconnected"); + } + } + catch (Exception ex) + { + this.LogException(ex,"Exception in Kill Client"); + } + } + + /// + /// Kills the stream + /// + void KillStream() + { + try + { + if (TheStream != null) + { + TheStream.DataReceived -= Stream_DataReceived; + TheStream.Close(); + TheStream.Dispose(); + TheStream = null; + this.LogDebug("Disconnected stream"); + } + } + catch (Exception ex) + { + this.LogException(ex, "Exception in Kill Stream:{0}"); + } + } + + /// + /// Handles the keyboard interactive authentication, should it be required. + /// + void kauth_AuthenticationPrompt(object sender, AuthenticationPromptEventArgs e) + { + foreach (AuthenticationPrompt prompt in e.Prompts) + if (prompt.Request.IndexOf("Password:", StringComparison.InvariantCultureIgnoreCase) != -1) + prompt.Response = Password; + } + + /// + /// Handler for data receive on ShellStream. Passes data across to queue for line parsing. + /// + void Stream_DataReceived(object sender, ShellDataEventArgs e) + { + if (((ShellStream)sender).Length <= 0L) + { + return; + } + var response = ((ShellStream)sender).Read(); + + var bytesHandler = BytesReceived; + + if (bytesHandler != null) + { + var bytes = Encoding.UTF8.GetBytes(response); + if (StreamDebugging.RxStreamDebuggingIsEnabled) + { + this.LogInformation("Received {1} bytes: '{0}'", ComTextHelper.GetEscapedText(bytes), bytes.Length); + } + bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + } + + var textHandler = TextReceived; + if (textHandler != null) + { + if (StreamDebugging.RxStreamDebuggingIsEnabled) + this.LogInformation("Received: '{0}'", ComTextHelper.GetDebugText(response)); + + textHandler(this, new GenericCommMethodReceiveTextArgs(response)); + } + + } + + + /// + /// Error event handler for client events - disconnect, etc. Will forward those events via ConnectionChange + /// event + /// + void Client_ErrorOccurred(object sender, ExceptionEventArgs e) + { + CrestronInvoke.BeginInvoke(o => + { + if (e.Exception is SshConnectionException || e.Exception is System.Net.Sockets.SocketException) + this.LogError("Disconnected by remote"); + else + this.LogException(e.Exception, "Unhandled SSH client error"); + try + { + connectLock.Wait(); + KillClient(SocketStatus.SOCKET_STATUS_BROKEN_REMOTELY); + } + finally + { + connectLock.Release(); + } + if (AutoReconnect && ConnectEnabled) + { + this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); + ReconnectTimer.Reset(AutoReconnectIntervalMs); + } + }); + } + + /// + /// Helper for ConnectionChange event + /// + void OnConnectionChange() + { + if (ConnectionChange != null) + ConnectionChange(this, new GenericSocketStatusChageEventArgs(this)); + } + + #region IBasicCommunication Members + + /// + /// Sends text to the server + /// + /// + public void SendText(string text) + { + try + { + if (Client != null && TheStream != null && IsConnected) + { + if (StreamDebugging.TxStreamDebuggingIsEnabled) + this.LogInformation( + "Sending {length} characters of text: '{text}'", + text.Length, + ComTextHelper.GetDebugText(text)); + + TheStream.Write(text); + TheStream.Flush(); + } + else + { + this.LogDebug("Client is null or disconnected. Cannot Send Text"); + } + } + catch (ObjectDisposedException) + { + this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); + + KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); + ReconnectTimer.Reset(); + } + catch (Exception ex) + { + this.LogException(ex, "Exception sending text: '{message}'", text); + } + } + + /// + /// Sends Bytes to the server + /// + /// + public void SendBytes(byte[] bytes) + { + try + { + if (Client != null && TheStream != null && IsConnected) + { + if (StreamDebugging.TxStreamDebuggingIsEnabled) + this.LogInformation("Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); + + TheStream.Write(bytes, 0, bytes.Length); + TheStream.Flush(); + } + else + { + this.LogDebug("Client is null or disconnected. Cannot Send Bytes"); + } + } + catch (ObjectDisposedException ex) + { + this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); + + KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); + ReconnectTimer.Reset(); + } + catch (Exception ex) + { + this.LogException(ex, "Exception sending {message}", ComTextHelper.GetEscapedText(bytes)); + } + } + #endregion + +} + +//***************************************************************************************************** +//***************************************************************************************************** +/// +/// Fired when connection changes +/// +public class SshConnectionChangeEventArgs : EventArgs + { + /// + /// Connection State + /// + public bool IsConnected { get; private set; } + + /// + /// Connection Status represented as a ushort + /// + public ushort UIsConnected { get { return (ushort)(Client.IsConnected ? 1 : 0); } } + + /// + /// The client + /// + public GenericSshClient Client { get; private set; } + + /// + /// Socket Status as represented by + /// + public ushort Status { get { return Client.UStatus; } } + + /// + /// S+ Constructor + /// + public SshConnectionChangeEventArgs() { } + + /// + /// EventArgs class + /// + /// Connection State + /// The Client + public SshConnectionChangeEventArgs(bool isConnected, GenericSshClient client) + { + IsConnected = isConnected; + Client = client; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericTcpIpClient.cs b/src/PepperDash.Core/Comm/GenericTcpIpClient.cs new file mode 100644 index 00000000..9529aa29 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericTcpIpClient.cs @@ -0,0 +1,566 @@ +using System; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using Newtonsoft.Json; + +namespace PepperDash.Core +{ + /// + /// A class to handle basic TCP/IP communications with a server + /// + public class GenericTcpIpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized TcpIpClient"; + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the server and returns it as a Byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the server and returns it as text + /// + public event EventHandler TextReceived; + + /// + /// + /// + //public event GenericSocketStatusChangeEventDelegate SocketStatusChange; + public event EventHandler ConnectionChange; + + + private string _hostname; + + /// + /// Address of server + /// + public string Hostname + { + get + { + return _hostname; + } + + set + { + _hostname = value; + if (_client != null) + { + _client.AddressClientConnectedTo = _hostname; + } + } + } + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// Another damn S+ helper because S+ seems to treat large port nums as signed ints + /// which screws up things + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// The actual client class + /// + private TCPClient _client; + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get { return _client != null && _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// _client socket status Read only + /// + public SocketStatus ClientStatus + { + get + { + return _client == null ? SocketStatus.SOCKET_STATUS_NO_CONNECT : _client.ClientStatus; + } + } + + /// + /// Contains the familiar Simpl analog status values. This drives the ConnectionChange event + /// and IsConnected would be true when this == 2. + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Status text shows the message associated with socket status + /// + public string ClientStatusText { get { return ClientStatus.ToString(); } } + + /// + /// Ushort representation of client status + /// + [Obsolete] + public ushort UClientStatus { get { return (ushort)ClientStatus; } } + + /// + /// Connection failure reason + /// + public string ConnectionFailure { get { return ClientStatus.ToString(); } } + + /// + /// bool to track if auto reconnect should be set on the socket + /// + 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; } + + /// + /// Set only when the disconnect method is called + /// + bool DisconnectCalledByUser; + + /// + /// + /// + public bool Connected + { + get { return _client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + //Lock object to prevent simulatneous connect/disconnect operations + private CCriticalSection connectLock = new CCriticalSection(); + + // private Timer for auto reconnect + private CTimer RetryTimer; + + /// + /// Constructor + /// + /// unique string to differentiate between instances + /// + /// + /// + public GenericTcpIpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + Hostname = address; + Port = port; + BufferSize = bufferSize; + + RetryTimer = new CTimer(o => + { + Reconnect(); + }, Timeout.Infinite); + } + + /// + /// Constructor + /// + /// + public GenericTcpIpClient(string key) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + RetryTimer = new CTimer(o => + { + Reconnect(); + }, Timeout.Infinite); + } + + /// + /// Default constructor for S+ + /// + public GenericTcpIpClient() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + RetryTimer = new CTimer(o => + { + Reconnect(); + }, Timeout.Infinite); + } + + /// + /// Just to help S+ set the key + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Handles closing this up when the program shuts down + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + Debug.Console(1, this, "Program stopping. Closing connection"); + Deactivate(); + } + } + + /// + /// + /// + /// + public override bool Deactivate() + { + RetryTimer.Stop(); + RetryTimer.Dispose(); + if (_client != null) + { + _client.SocketStatusChange -= this.Client_SocketStatusChange; + DisconnectClient(); + } + return true; + } + + /// + /// Attempts to connect to the server + /// + public void Connect() + { + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericTcpIpClient '{0}': No address set", Key); + return; + } + if (Port < 1 || Port > 65535) + { + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericTcpIpClient '{0}': Invalid port", Key); + return; + } + } + + try + { + connectLock.Enter(); + if (IsConnected) + { + Debug.Console(1, this, "Connection already connected. Exiting Connect()"); + } + else + { + //Stop retry timer if running + RetryTimer.Stop(); + _client = new TCPClient(Hostname, Port, BufferSize); + _client.SocketStatusChange -= Client_SocketStatusChange; + _client.SocketStatusChange += Client_SocketStatusChange; + DisconnectCalledByUser = false; + _client.ConnectToServerAsync(ConnectToServerCallback); + } + } + finally + { + connectLock.Leave(); + } + } + + private void Reconnect() + { + if (_client == null) + { + return; + } + try + { + connectLock.Enter(); + if (IsConnected || DisconnectCalledByUser == true) + { + Debug.Console(1, this, "Reconnect no longer needed. Exiting Reconnect()"); + } + else + { + Debug.Console(1, this, "Attempting reconnect now"); + _client.ConnectToServerAsync(ConnectToServerCallback); + } + } + finally + { + connectLock.Leave(); + } + } + + /// + /// Attempts to disconnect the client + /// + public void Disconnect() + { + try + { + connectLock.Enter(); + DisconnectCalledByUser = true; + + // Stop trying reconnects, if we are + RetryTimer.Stop(); + DisconnectClient(); + } + finally + { + connectLock.Leave(); + } + } + + /// + /// Does the actual disconnect business + /// + public void DisconnectClient() + { + if (_client != null) + { + Debug.Console(1, this, "Disconnecting client"); + if (IsConnected) + _client.DisconnectFromServer(); + } + } + + /// + /// Callback method for connection attempt + /// + /// + void ConnectToServerCallback(TCPClient c) + { + if (c.ClientStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(0, this, "Server connection result: {0}", c.ClientStatus); + WaitAndTryReconnect(); + } + else + { + Debug.Console(1, this, "Server connection result: {0}", c.ClientStatus); + } + } + + /// + /// Disconnects, waits and attemtps to connect again + /// + void WaitAndTryReconnect() + { + CrestronInvoke.BeginInvoke(o => + { + try + { + connectLock.Enter(); + if (!IsConnected && AutoReconnect && !DisconnectCalledByUser && _client != null) + { + DisconnectClient(); + Debug.Console(1, this, "Attempting reconnect, status={0}", _client.ClientStatus); + RetryTimer.Reset(AutoReconnectIntervalMs); + } + } + finally + { + connectLock.Leave(); + } + }); + } + + /// + /// Recieves incoming data + /// + /// + /// + void Receive(TCPClient client, int numBytes) + { + if (client != null) + { + if (numBytes > 0) + { + var bytes = client.IncomingDataBuffer.Take(numBytes).ToArray(); + var bytesHandler = BytesReceived; + if (bytesHandler != null) + { + if (StreamDebugging.RxStreamDebuggingIsEnabled) + { + Debug.Console(0, this, "Received {1} bytes: '{0}'", ComTextHelper.GetEscapedText(bytes), bytes.Length); + } + bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + } + var textHandler = TextReceived; + if (textHandler != null) + { + var str = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + + if (StreamDebugging.RxStreamDebuggingIsEnabled) + { + Debug.Console(0, this, "Received {1} characters of text: '{0}'", ComTextHelper.GetDebugText(str), str.Length); + } + + textHandler(this, new GenericCommMethodReceiveTextArgs(str)); + } + } + client.ReceiveDataAsync(Receive); + } + } + + /// + /// General send method + /// + public void SendText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + // Check debug level before processing byte array + if (StreamDebugging.TxStreamDebuggingIsEnabled) + Debug.Console(0, this, "Sending {0} characters of text: '{1}'", text.Length, ComTextHelper.GetDebugText(text)); + if (_client != null) + _client.SendData(bytes, bytes.Length); + } + + /// + /// This is useful from console and...? + /// + public void SendEscapedText(string text) + { + var unescapedText = Regex.Replace(text, @"\\x([0-9a-fA-F][0-9a-fA-F])", s => + { + var hex = s.Groups[1].Value; + return ((char)Convert.ToByte(hex, 16)).ToString(); + }); + SendText(unescapedText); + } + + /// + /// Sends Bytes to the server + /// + /// + public void SendBytes(byte[] bytes) + { + if (StreamDebugging.TxStreamDebuggingIsEnabled) + Debug.Console(0, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); + if (_client != null) + _client.SendData(bytes, bytes.Length); + } + + /// + /// Socket Status Change Handler + /// + /// + /// + void Client_SocketStatusChange(TCPClient client, SocketStatus clientSocketStatus) + { + if (clientSocketStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(0, this, "Socket status change {0} ({1})", clientSocketStatus, ClientStatusText); + WaitAndTryReconnect(); + } + else + { + Debug.Console(1, this, "Socket status change {0} ({1})", clientSocketStatus, ClientStatusText); + _client.ReceiveDataAsync(Receive); + } + + var handler = ConnectionChange; + if (handler != null) + ConnectionChange(this, new GenericSocketStatusChageEventArgs(this)); + } + } + + /// + /// Configuration properties for TCP/SSH Connections + /// + public class TcpSshPropertiesConfig + { + /// + /// Address to connect to + /// + [JsonProperty(Required = Required.Always)] + public string Address { get; set; } + + /// + /// Port to connect to + /// + [JsonProperty(Required = Required.Always)] + public int Port { get; set; } + + /// + /// Username credential + /// + public string Username { get; set; } + /// + /// Passord credential + /// + public string Password { get; set; } + + /// + /// Defaults to 32768 + /// + public int BufferSize { get; set; } + + /// + /// Defaults to true + /// + public bool AutoReconnect { get; set; } + + /// + /// Defaults to 5000ms + /// + public int AutoReconnectIntervalMs { get; set; } + + /// + /// Default constructor + /// + public TcpSshPropertiesConfig() + { + BufferSize = 32768; + AutoReconnect = true; + AutoReconnectIntervalMs = 5000; + Username = ""; + Password = ""; + } + + } + +} diff --git a/src/PepperDash.Core/Comm/GenericTcpIpClient_ForServer.cs b/src/PepperDash.Core/Comm/GenericTcpIpClient_ForServer.cs new file mode 100644 index 00000000..df277f00 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericTcpIpClient_ForServer.cs @@ -0,0 +1,774 @@ +/*PepperDash Technology Corp. +JAG +Copyright: 2017 +------------------------------------ +***Notice of Ownership and Copyright*** +The material in which this notice appears is the property of PepperDash Technology Corporation, +which claims copyright under the laws of the United States of America in the entire body of material +and in all parts thereof, regardless of the use to which it is being put. Any use, in whole or in part, +of this material by another party without the express written permission of PepperDash Technology Corporation is prohibited. +PepperDash Technology Corporation reserves all rights under applicable laws. +------------------------------------ */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; + +namespace PepperDash.Core +{ + /// + /// Generic TCP/IP client for server + /// + public class GenericTcpIpClient_ForServer : Device, IAutoReconnect + { + /// + /// Band aid delegate for choked server + /// + internal delegate void ConnectionHasHungCallbackDelegate(); + + #region Events + + //public event EventHandler BytesReceived; + + /// + /// Notifies of text received + /// + public event EventHandler TextReceived; + + /// + /// Notifies of socket status change + /// + public event EventHandler ConnectionChange; + + + /// + /// This is something of a band-aid callback. If the client times out during the connection process, because the server + /// is stuck, this will fire. It is intended to be used by the Server class monitor client, to help + /// keep a watch on the server and reset it if necessary. + /// + internal ConnectionHasHungCallbackDelegate ConnectionHasHungCallback; + + /// + /// For a client with a pre shared key, this will fire after the communication is established and the key exchange is complete. If you require + /// a key and subscribe to the socket change event and try to send data on a connection the data sent will interfere with the key exchange and disconnect. + /// + public event EventHandler ClientReadyForCommunications; + + #endregion + + #region Properties & Variables + + /// + /// Address of server + /// + public string Hostname { get; set; } + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// S+ helper + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Bool to show whether the server requires a preshared key. This is used in the DynamicTCPServer class + /// + public bool SharedKeyRequired { get; set; } + + /// + /// S+ helper for requires shared key bool + /// + public ushort USharedKeyRequired + { + set + { + if (value == 1) + SharedKeyRequired = true; + else + SharedKeyRequired = false; + } + } + + /// + /// SharedKey is sent for varification to the server. Shared key can be any text (255 char limit in SIMPL+ Module), but must match the Shared Key on the Server module + /// + public string SharedKey { get; set; } + + /// + /// flag to show the client is waiting for the server to send the shared key + /// + private bool WaitingForSharedKeyResponse { get; set; } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// Semaphore on connect method + /// + bool IsTryingToConnect; + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get + { + if (Client != null) + return Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; + else + return false; + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// Bool showing if socket is ready for communication after shared key exchange + /// + public bool IsReadyForCommunication { get; set; } + + /// + /// S+ helper for IsReadyForCommunication + /// + public ushort UIsReadyForCommunication + { + get { return (ushort)(IsReadyForCommunication ? 1 : 0); } + } + + /// + /// Client socket status Read only + /// + public SocketStatus ClientStatus + { + get + { + if (Client != null) + return Client.ClientStatus; + else + return SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// Contains the familiar Simpl analog status values. This drives the ConnectionChange event + /// and IsConnected would be true when this == 2. + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Status text shows the message associated with socket status + /// + public string ClientStatusText { get { return ClientStatus.ToString(); } } + + /// + /// bool to track if auto reconnect should be set on the socket + /// + 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; } + + /// + /// Flag Set only when the disconnect method is called. + /// + bool DisconnectCalledByUser; + + /// + /// private Timer for auto reconnect + /// + CTimer RetryTimer; + + + /// + /// + /// + public bool HeartbeatEnabled { get; set; } + + /// + /// + /// + public ushort UHeartbeatEnabled + { + get { return (ushort)(HeartbeatEnabled ? 1 : 0); } + set { HeartbeatEnabled = value == 1; } + } + + /// + /// + /// + public string HeartbeatString = "heartbeat"; + + /// + /// + /// + public int HeartbeatInterval = 50000; + + CTimer HeartbeatSendTimer; + CTimer HeartbeatAckTimer; + /// + /// Used to force disconnection on a dead connect attempt + /// + CTimer ConnectFailTimer; + CTimer WaitForSharedKey; + private int ConnectionCount; + /// + /// Internal secure client + /// + TCPClient Client; + + bool ProgramIsStopping; + + #endregion + + #region Constructors + + /// + /// Constructor + /// + /// + /// + /// + /// + public GenericTcpIpClient_ForServer(string key, string address, int port, int bufferSize) + : base(key) + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + Hostname = address; + Port = port; + BufferSize = bufferSize; + AutoReconnectIntervalMs = 5000; + + } + + /// + /// Constructor for S+ + /// + public GenericTcpIpClient_ForServer() + : base("Uninitialized DynamicTcpClient") + { + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + } + #endregion + + #region Methods + + /// + /// Just to help S+ set the key + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Handles closing this up when the program shuts down + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping || programEventType == eProgramStatusEventType.Paused) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Program stopping. Closing Client connection"); + ProgramIsStopping = true; + Disconnect(); + } + + } + + /// + /// Connect Method. Will return if already connected. Will write errors if missing address, port, or unique key/name. + /// + public void Connect() + { + ConnectionCount++; + Debug.Console(2, this, "Attempting connect Count:{0}", ConnectionCount); + + + if (IsConnected) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already connected. Ignoring."); + return; + } + if (IsTryingToConnect) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Notice, "Already trying to connect. Ignoring."); + return; + } + try + { + IsTryingToConnect = true; + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No address set"); + return; + } + if (Port < 1 || Port > 65535) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: Invalid port"); + return; + } + if (string.IsNullOrEmpty(SharedKey) && SharedKeyRequired) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Warning, "DynamicTcpClient: No Shared Key set"); + return; + } + + // clean up previous client + if (Client != null) + { + Cleanup(); + } + DisconnectCalledByUser = false; + + Client = new TCPClient(Hostname, Port, BufferSize); + Client.SocketStatusChange += Client_SocketStatusChange; + if(HeartbeatEnabled) + Client.SocketSendOrReceiveTimeOutInMs = (HeartbeatInterval * 5); + Client.AddressClientConnectedTo = Hostname; + Client.PortNumber = Port; + // SecureClient = c; + + //var timeOfConnect = DateTime.Now.ToString("HH:mm:ss.fff"); + + ConnectFailTimer = new CTimer(o => + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Connect attempt has not finished after 30sec Count:{0}", ConnectionCount); + if (IsTryingToConnect) + { + IsTryingToConnect = false; + + //if (ConnectionHasHungCallback != null) + //{ + // ConnectionHasHungCallback(); + //} + //SecureClient.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + } + }, 30000); + + Debug.Console(2, this, "Making Connection Count:{0}", ConnectionCount); + Client.ConnectToServerAsync(o => + { + Debug.Console(2, this, "ConnectToServerAsync Count:{0} Ran!", ConnectionCount); + + if (ConnectFailTimer != null) + { + ConnectFailTimer.Stop(); + } + IsTryingToConnect = false; + + if (o.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + Debug.Console(2, this, "Client connected to {0} on port {1}", o.AddressClientConnectedTo, o.LocalPortNumberOfClient); + o.ReceiveDataAsync(Receive); + + if (SharedKeyRequired) + { + WaitingForSharedKeyResponse = true; + WaitForSharedKey = new CTimer(timer => + { + + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Shared key exchange timer expired. IsReadyForCommunication={0}", IsReadyForCommunication); + // Debug.Console(1, this, "Connect attempt failed {0}", c.ClientStatus); + // This is the only case where we should call DisconectFromServer...Event handeler will trigger the cleanup + o.DisconnectFromServer(); + //CheckClosedAndTryReconnect(); + //OnClientReadyForcommunications(false); // Should send false event + }, 15000); + } + else + { + //CLient connected and shared key is not required so just raise the ready for communication event. if Shared key + //required this is called by the shared key being negotiated + if (IsReadyForCommunication == false) + { + OnClientReadyForcommunications(true); // Key not required + } + } + } + else + { + Debug.Console(1, this, "Connect attempt failed {0}", o.ClientStatus); + CheckClosedAndTryReconnect(); + } + }); + } + catch (Exception ex) + { + Debug.Console(0, this, Debug.ErrorLogLevel.Error, "Client connection exception: {0}", ex.Message); + IsTryingToConnect = false; + CheckClosedAndTryReconnect(); + } + } + + /// + /// + /// + public void Disconnect() + { + Debug.Console(2, "Disconnect Called"); + + DisconnectCalledByUser = true; + if (IsConnected) + { + Client.DisconnectFromServer(); + + } + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + Cleanup(); + } + + /// + /// Internal call to close up client. ALWAYS use this when disconnecting. + /// + void Cleanup() + { + IsTryingToConnect = false; + + if (Client != null) + { + //SecureClient.DisconnectFromServer(); + Debug.Console(2, this, "Disconnecting Client {0}", DisconnectCalledByUser ? ", Called by user" : ""); + Client.SocketStatusChange -= Client_SocketStatusChange; + Client.Dispose(); + Client = null; + } + if (ConnectFailTimer != null) + { + ConnectFailTimer.Stop(); + ConnectFailTimer.Dispose(); + ConnectFailTimer = null; + } + } + + + /// ff + /// Called from Connect failure or Socket Status change if + /// auto reconnect and socket disconnected (Not disconnected by user) + /// + void CheckClosedAndTryReconnect() + { + if (Client != null) + { + Debug.Console(2, this, "Cleaning up remotely closed/failed connection."); + Cleanup(); + } + if (!DisconnectCalledByUser && AutoReconnect) + { + var halfInterval = AutoReconnectIntervalMs / 2; + var rndTime = new Random().Next(-halfInterval, halfInterval) + AutoReconnectIntervalMs; + Debug.Console(2, this, "Attempting reconnect in {0} ms, randomized", rndTime); + if (RetryTimer != null) + { + RetryTimer.Stop(); + RetryTimer = null; + } + RetryTimer = new CTimer(o => Connect(), rndTime); + } + } + + /// + /// Receive callback + /// + /// + /// + void Receive(TCPClient client, int numBytes) + { + if (numBytes > 0) + { + string str = string.Empty; + + try + { + var bytes = client.IncomingDataBuffer.Take(numBytes).ToArray(); + str = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + Debug.Console(2, this, "Client Received:\r--------\r{0}\r--------", str); + if (!string.IsNullOrEmpty(checkHeartbeat(str))) + { + if (SharedKeyRequired && str == "SharedKey:") + { + Debug.Console(2, this, "Server asking for shared key, sending"); + SendText(SharedKey + "\n"); + } + else if (SharedKeyRequired && str == "Shared Key Match") + { + StopWaitForSharedKeyTimer(); + Debug.Console(2, this, "Shared key confirmed. Ready for communication"); + OnClientReadyForcommunications(true); // Successful key exchange + } + else + { + //var bytesHandler = BytesReceived; + //if (bytesHandler != null) + // bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + var textHandler = TextReceived; + if (textHandler != null) + textHandler(this, new GenericTcpServerCommMethodReceiveTextArgs(str)); + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error receiving data: {1}. Error: {0}", ex.Message, str); + } + } + if (client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + client.ReceiveDataAsync(Receive); + } + + void HeartbeatStart() + { + if (HeartbeatEnabled) + { + Debug.Console(2, this, "Starting Heartbeat"); + if (HeartbeatSendTimer == null) + { + + HeartbeatSendTimer = new CTimer(this.SendHeartbeat, null, HeartbeatInterval, HeartbeatInterval); + } + if (HeartbeatAckTimer == null) + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + } + + } + void HeartbeatStop() + { + + if (HeartbeatSendTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Send"); + HeartbeatSendTimer.Stop(); + HeartbeatSendTimer = null; + } + if (HeartbeatAckTimer != null) + { + Debug.Console(2, this, "Stoping Heartbeat Ack"); + HeartbeatAckTimer.Stop(); + HeartbeatAckTimer = null; + } + + } + void SendHeartbeat(object notused) + { + this.SendText(HeartbeatString); + Debug.Console(2, this, "Sending Heartbeat"); + + } + + //private method to check heartbeat requirements and start or reset timer + string checkHeartbeat(string received) + { + try + { + if (HeartbeatEnabled) + { + if (!string.IsNullOrEmpty(HeartbeatString)) + { + var remainingText = received.Replace(HeartbeatString, ""); + var noDelimiter = received.Trim(new char[] { '\r', '\n' }); + if (noDelimiter.Contains(HeartbeatString)) + { + if (HeartbeatAckTimer != null) + { + HeartbeatAckTimer.Reset(HeartbeatInterval * 2); + } + else + { + HeartbeatAckTimer = new CTimer(HeartbeatAckTimerFail, null, (HeartbeatInterval * 2), (HeartbeatInterval * 2)); + } + Debug.Console(2, this, "Heartbeat Received: {0}, from Server", HeartbeatString); + return remainingText; + } + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error checking heartbeat: {0}", ex.Message); + } + return received; + } + + + + void HeartbeatAckTimerFail(object o) + { + try + { + + if (IsConnected) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "Heartbeat not received from Server...DISCONNECTING BECAUSE HEARTBEAT REQUIRED IS TRUE"); + SendText("Heartbeat not received by server, closing connection"); + CheckClosedAndTryReconnect(); + } + + } + catch (Exception ex) + { + ErrorLog.Error("Heartbeat timeout Error on Client: {0}, {1}", Key, ex); + } + } + + /// + /// + /// + void StopWaitForSharedKeyTimer() + { + if (WaitForSharedKey != null) + { + WaitForSharedKey.Stop(); + WaitForSharedKey = null; + } + } + + /// + /// General send method + /// + public void SendText(string text) + { + if (!string.IsNullOrEmpty(text)) + { + try + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + if (Client != null && Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + { + Client.SendDataAsync(bytes, bytes.Length, (c, n) => + { + // HOW IN THE HELL DO WE CATCH AN EXCEPTION IN SENDING????? + if (n <= 0) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "[{0}] Sent zero bytes. Was there an error?", this.Key); + } + }); + } + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending text: {1}. Error: {0}", ex.Message, text); + } + } + } + + /// + /// + /// + public void SendBytes(byte[] bytes) + { + if (bytes.Length > 0) + { + try + { + if (Client != null && Client.ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED) + Client.SendData(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.Console(0, this, "Error sending bytes. Error: {0}", ex.Message); + } + } + } + + /// + /// SocketStatusChange Callback + /// + /// + /// + void Client_SocketStatusChange(TCPClient client, SocketStatus clientSocketStatus) + { + if (ProgramIsStopping) + { + ProgramIsStopping = false; + return; + } + try + { + Debug.Console(2, this, "Socket status change: {0} ({1})", client.ClientStatus, (ushort)(client.ClientStatus)); + + OnConnectionChange(); + + // The client could be null or disposed by this time... + if (Client == null || Client.ClientStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + HeartbeatStop(); + OnClientReadyForcommunications(false); // socket has gone low + CheckClosedAndTryReconnect(); + } + } + catch (Exception ex) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Error in socket status change callback. Error: {0}\r\r{1}", ex, ex.InnerException); + } + } + + /// + /// Helper for ConnectionChange event + /// + void OnConnectionChange() + { + var handler = ConnectionChange; + if (handler != null) + ConnectionChange(this, new GenericTcpServerSocketStatusChangeEventArgs(this, Client.ClientStatus)); + } + + /// + /// Helper to fire ClientReadyForCommunications event + /// + void OnClientReadyForcommunications(bool isReady) + { + IsReadyForCommunication = isReady; + if (this.IsReadyForCommunication) { HeartbeatStart(); } + var handler = ClientReadyForCommunications; + if (handler != null) + handler(this, new GenericTcpServerClientReadyForcommunicationsEventArgs(IsReadyForCommunication)); + } + #endregion + } + +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericTcpIpServer.cs b/src/PepperDash.Core/Comm/GenericTcpIpServer.cs new file mode 100644 index 00000000..680db5c5 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericTcpIpServer.cs @@ -0,0 +1,1010 @@ +/*PepperDash Technology Corp. +JAG +Copyright: 2017 +------------------------------------ +***Notice of Ownership and Copyright*** +The material in which this notice appears is the property of PepperDash Technology Corporation, +which claims copyright under the laws of the United States of America in the entire body of material +and in all parts thereof, regardless of the use to which it is being put. Any use, in whole or in part, +of this material by another party without the express written permission of PepperDash Technology Corporation is prohibited. +PepperDash Technology Corporation reserves all rights under applicable laws. +------------------------------------ */ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; + +namespace PepperDash.Core +{ + /// + /// Generic TCP/IP server device + /// + public class GenericTcpIpServer : Device + { + #region Events + /// + /// Event for Receiving text + /// + public event EventHandler TextReceived; + + /// + /// Event for client connection socket status change + /// + public event EventHandler ClientConnectionChange; + + /// + /// Event for Server State Change + /// + public event EventHandler ServerStateChange; + + /// + /// For a server with a pre shared key, this will fire after the communication is established and the key exchange is complete. If no shared key, this will fire + /// after connection is successful. Use this event to know when the client is ready for communication to avoid stepping on shared key. + /// + public event EventHandler ServerClientReadyForCommunications; + + /// + /// A band aid event to notify user that the server has choked. + /// + public ServerHasChokedCallbackDelegate ServerHasChoked { get; set; } + + /// + /// + /// + public delegate void ServerHasChokedCallbackDelegate(); + + #endregion + + #region Properties/Variables + + /// + /// + /// + CCriticalSection ServerCCSection = new CCriticalSection(); + + + /// + /// A bandaid client that monitors whether the server is reachable + /// + GenericTcpIpClient_ForServer MonitorClient; + + /// + /// Timer to operate the bandaid monitor client in a loop. + /// + CTimer MonitorClientTimer; + + /// + /// + /// + int MonitorClientFailureCount; + + /// + /// 3 by default + /// + public int MonitorClientMaxFailureCount { get; set; } + + /// + /// Text representation of the Socket Status enum values for the server + /// + public string Status + { + get + { + if (myTcpServer != null) + return myTcpServer.State.ToString(); + return ServerState.SERVER_NOT_LISTENING.ToString(); + + } + + } + + /// + /// Bool showing if socket is connected + /// + public bool IsConnected + { + get + { + if (myTcpServer != null) + return (myTcpServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED; + return false; + + //return (Secure ? SecureServer != null : UnsecureServer != null) && + //(Secure ? (SecureServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED : + // (UnsecureServer.State & ServerState.SERVER_CONNECTED) == ServerState.SERVER_CONNECTED); + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// Bool showing if socket is connected + /// + public bool IsListening + { + get + { + if (myTcpServer != null) + return (myTcpServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING; + else + return false; + //return (Secure ? SecureServer != null : UnsecureServer != null) && + //(Secure ? (SecureServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING : + // (UnsecureServer.State & ServerState.SERVER_LISTENING) == ServerState.SERVER_LISTENING); + } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsListening + { + get { return (ushort)(IsListening ? 1 : 0); } + } + + /// + /// The maximum number of clients. + /// Should be set by parameter in SIMPL+ in the MAIN method, Should not ever need to be configurable + /// + public ushort MaxClients { get; set; } + + /// + /// Number of clients currently connected. + /// + public ushort NumberOfClientsConnected + { + get + { + if (myTcpServer != null) + return (ushort)myTcpServer.NumberOfClientsConnected; + return 0; + } + } + + /// + /// Port Server should listen on + /// + public int Port { get; set; } + + /// + /// S+ helper for Port + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Bool to show whether the server requires a preshared key. Must be set the same in the client, and if true shared keys must be identical on server/client + /// + public bool SharedKeyRequired { get; set; } + + /// + /// S+ helper for requires shared key bool + /// + public ushort USharedKeyRequired + { + set + { + if (value == 1) + SharedKeyRequired = true; + else + SharedKeyRequired = false; + } + } + + /// + /// SharedKey is sent for varification to the server. Shared key can be any text (255 char limit in SIMPL+ Module), but must match the Shared Key on the Server module. + /// If SharedKey changes while server is listening or clients are connected, disconnect and stop listening will be called + /// + public string SharedKey { get; set; } + + /// + /// Heartbeat Required bool sets whether server disconnects client if heartbeat is not received + /// + public bool HeartbeatRequired { get; set; } + + /// + /// S+ Helper for Heartbeat Required + /// + public ushort UHeartbeatRequired + { + set + { + if (value == 1) + HeartbeatRequired = true; + else + HeartbeatRequired = false; + } + } + + /// + /// Milliseconds before server expects another heartbeat. Set by property HeartbeatRequiredIntervalInSeconds which is driven from S+ + /// + public int HeartbeatRequiredIntervalMs { get; set; } + + /// + /// Simpl+ Heartbeat Analog value in seconds + /// + public ushort HeartbeatRequiredIntervalInSeconds { set { HeartbeatRequiredIntervalMs = (value * 1000); } } + + /// + /// String to Match for heartbeat. If null or empty any string will reset heartbeat timer + /// + public string HeartbeatStringToMatch { get; set; } + + //private timers for Heartbeats per client + Dictionary HeartbeatTimerDictionary = new Dictionary(); + + //flags to show the secure server is waiting for client at index to send the shared key + List WaitingForSharedKey = new List(); + + List ClientReadyAfterKeyExchange = new List(); + + /// + /// The connected client indexes + /// + public List ConnectedClientsIndexes = new List(); + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// Private flag to note that the server has stopped intentionally + /// + private bool ServerStopped { get; set; } + + //Servers + TCPServer myTcpServer; + + /// + /// + /// + bool ProgramIsStopping; + + #endregion + + #region Constructors + /// + /// constructor S+ Does not accept a key. Use initialze with key to set the debug key on this device. If using with + make sure to set all properties manually. + /// + public GenericTcpIpServer() + : base("Uninitialized Dynamic TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + } + + /// + /// constructor with debug key set at instantiation. Make sure to set all properties before listening. + /// + /// + public GenericTcpIpServer(string key) + : base("Uninitialized Dynamic TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + Key = key; + } + + /// + /// Contstructor that sets all properties by calling the initialize method with a config object. + /// + /// + public GenericTcpIpServer(TcpServerConfigObject serverConfigObject) + : base("Uninitialized Dynamic TCP Server") + { + HeartbeatRequiredIntervalInSeconds = 15; + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + BufferSize = 2000; + MonitorClientMaxFailureCount = 3; + Initialize(serverConfigObject); + } + #endregion + + #region Methods - Server Actions + /// + /// Disconnects all clients and stops the server + /// + public void KillServer() + { + ServerStopped = true; + if (MonitorClient != null) + { + MonitorClient.Disconnect(); + } + DisconnectAllClientsForShutdown(); + StopListening(); + } + + /// + /// Initialize Key for device using client name from SIMPL+. Called on Listen from SIMPL+ + /// + /// + public void Initialize(string key) + { + Key = key; + } + + /// + /// Initialze with server configuration object + /// + /// + public void Initialize(TcpServerConfigObject serverConfigObject) + { + try + { + if (serverConfigObject != null || string.IsNullOrEmpty(serverConfigObject.Key)) + { + Key = serverConfigObject.Key; + MaxClients = serverConfigObject.MaxClients; + Port = serverConfigObject.Port; + SharedKeyRequired = serverConfigObject.SharedKeyRequired; + SharedKey = serverConfigObject.SharedKey; + HeartbeatRequired = serverConfigObject.HeartbeatRequired; + HeartbeatRequiredIntervalInSeconds = serverConfigObject.HeartbeatRequiredIntervalInSeconds; + HeartbeatStringToMatch = serverConfigObject.HeartbeatStringToMatch; + BufferSize = serverConfigObject.BufferSize; + + } + else + { + ErrorLog.Error("Could not initialize server with key: {0}", serverConfigObject.Key); + } + } + catch + { + ErrorLog.Error("Could not initialize server with key: {0}", serverConfigObject.Key); + } + } + + /// + /// Start listening on the specified port + /// + public void Listen() + { + ServerCCSection.Enter(); + try + { + if (Port < 1 || Port > 65535) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Server '{0}': Invalid port", Key); + ErrorLog.Warn(string.Format("Server '{0}': Invalid port", Key)); + return; + } + if (string.IsNullOrEmpty(SharedKey) && SharedKeyRequired) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Server '{0}': No Shared Key set", Key); + ErrorLog.Warn(string.Format("Server '{0}': No Shared Key set", Key)); + return; + } + if (IsListening) + return; + + if (myTcpServer == null) + { + myTcpServer = new TCPServer(Port, MaxClients); + if(HeartbeatRequired) + myTcpServer.SocketSendOrReceiveTimeOutInMs = (this.HeartbeatRequiredIntervalMs * 5); + + // myTcpServer.HandshakeTimeout = 30; + } + else + { + KillServer(); + myTcpServer.PortNumber = Port; + } + + myTcpServer.SocketStatusChange -= TcpServer_SocketStatusChange; + myTcpServer.SocketStatusChange += TcpServer_SocketStatusChange; + + ServerStopped = false; + myTcpServer.WaitForConnectionAsync(IPAddress.Any, TcpConnectCallback); + OnServerStateChange(myTcpServer.State); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "TCP Server Status: {0}, Socket Status: {1}", myTcpServer.State, myTcpServer.ServerSocketStatus); + + // StartMonitorClient(); + + + ServerCCSection.Leave(); + } + catch (Exception ex) + { + ServerCCSection.Leave(); + ErrorLog.Error("{1} Error with Dynamic Server: {0}", ex.ToString(), Key); + } + } + + /// + /// Stop Listening + /// + public void StopListening() + { + try + { + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Stopping Listener"); + if (myTcpServer != null) + { + myTcpServer.Stop(); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Server State: {0}", myTcpServer.State); + OnServerStateChange(myTcpServer.State); + } + ServerStopped = true; + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error stopping server. Error: {0}", ex); + } + } + + /// + /// Disconnects Client + /// + /// + public void DisconnectClient(uint client) + { + try + { + myTcpServer.Disconnect(client); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected client index: {0}", client); + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Disconnecting client index: {0}. Error: {1}", client, ex); + } + } + /// + /// Disconnect All Clients + /// + public void DisconnectAllClientsForShutdown() + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Disconnecting All Clients"); + if (myTcpServer != null) + { + myTcpServer.SocketStatusChange -= TcpServer_SocketStatusChange; + foreach (var index in ConnectedClientsIndexes.ToList()) // copy it here so that it iterates properly + { + var i = index; + if (!myTcpServer.ClientConnected(index)) + continue; + try + { + myTcpServer.Disconnect(i); + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected client index: {0}", i); + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Disconnecting client index: {0}. Error: {1}", i, ex); + } + } + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Server Status: {0}", myTcpServer.ServerSocketStatus); + } + + Debug.Console(2, this, Debug.ErrorLogLevel.Notice, "Disconnected All Clients"); + ConnectedClientsIndexes.Clear(); + + if (!ProgramIsStopping) + { + OnConnectionChange(); + OnServerStateChange(myTcpServer.State); //State shows both listening and connected + } + + // var o = new { }; + } + + /// + /// Broadcast text from server to all connected clients + /// + /// + public void BroadcastText(string text) + { + CCriticalSection CCBroadcast = new CCriticalSection(); + CCBroadcast.Enter(); + try + { + if (ConnectedClientsIndexes.Count > 0) + { + byte[] b = Encoding.GetEncoding(28591).GetBytes(text); + foreach (uint i in ConnectedClientsIndexes) + { + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(i))) + { + SocketErrorCodes error = myTcpServer.SendDataAsync(i, b, b.Length, (x, y, z) => { }); + if (error != SocketErrorCodes.SOCKET_OK && error != SocketErrorCodes.SOCKET_OPERATION_PENDING) + Debug.Console(2, error.ToString()); + } + } + } + CCBroadcast.Leave(); + } + catch (Exception ex) + { + CCBroadcast.Leave(); + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Broadcasting messages from server. Error: {0}", ex.Message); + } + } + + /// + /// Not sure this is useful in library, maybe Pro?? + /// + /// + /// + public void SendTextToClient(string text, uint clientIndex) + { + try + { + byte[] b = Encoding.GetEncoding(28591).GetBytes(text); + if (myTcpServer != null && myTcpServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + { + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(clientIndex))) + myTcpServer.SendDataAsync(clientIndex, b, b.Length, (x, y, z) => { }); + } + } + catch (Exception ex) + { + Debug.Console(2, this, "Error sending text to client. Text: {1}. Error: {0}", ex.Message, text); + } + } + + //private method to check heartbeat requirements and start or reset timer + string checkHeartbeat(uint clientIndex, string received) + { + try + { + if (HeartbeatRequired) + { + if (!string.IsNullOrEmpty(HeartbeatStringToMatch)) + { + var remainingText = received.Replace(HeartbeatStringToMatch, ""); + var noDelimiter = received.Trim(new char[] { '\r', '\n' }); + if (noDelimiter.Contains(HeartbeatStringToMatch)) + { + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + HeartbeatTimerDictionary[clientIndex].Reset(HeartbeatRequiredIntervalMs); + else + { + CTimer HeartbeatTimer = new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs); + HeartbeatTimerDictionary.Add(clientIndex, HeartbeatTimer); + } + Debug.Console(1, this, "Heartbeat Received: {0}, from client index: {1}", HeartbeatStringToMatch, clientIndex); + // Return Heartbeat + SendTextToClient(HeartbeatStringToMatch, clientIndex); + return remainingText; + } + } + else + { + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + HeartbeatTimerDictionary[clientIndex].Reset(HeartbeatRequiredIntervalMs); + else + { + CTimer HeartbeatTimer = new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs); + HeartbeatTimerDictionary.Add(clientIndex, HeartbeatTimer); + } + Debug.Console(1, this, "Heartbeat Received: {0}, from client index: {1}", received, clientIndex); + } + } + } + catch (Exception ex) + { + Debug.Console(1, this, "Error checking heartbeat: {0}", ex.Message); + } + return received; + } + + /// + /// Gets the IP address based on the client index + /// + /// + /// IP address of the client + public string GetClientIPAddress(uint clientIndex) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "GetClientIPAddress Index: {0}", clientIndex); + if (!SharedKeyRequired || (SharedKeyRequired && ClientReadyAfterKeyExchange.Contains(clientIndex))) + { + var ipa = this.myTcpServer.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "GetClientIPAddress IPAddreess: {0}", ipa); + return ipa; + + } + else + { + return ""; + } + } + + #endregion + + #region Methods - HeartbeatTimer Callback + + void HeartbeatTimer_CallbackFunction(object o) + { + uint clientIndex = 99999; + string address = string.Empty; + try + { + clientIndex = (uint)o; + address = myTcpServer.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex); + + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Heartbeat not received for Client index {2} IP: {0}, DISCONNECTING BECAUSE HEARTBEAT REQUIRED IS TRUE {1}", + address, string.IsNullOrEmpty(HeartbeatStringToMatch) ? "" : ("HeartbeatStringToMatch: " + HeartbeatStringToMatch), clientIndex); + + if (myTcpServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + SendTextToClient("Heartbeat not received by server, closing connection", clientIndex); + + var discoResult = myTcpServer.Disconnect(clientIndex); + //Debug.Console(1, this, "{0}", discoResult); + + if (HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary[clientIndex].Stop(); + HeartbeatTimerDictionary[clientIndex].Dispose(); + HeartbeatTimerDictionary.Remove(clientIndex); + } + } + catch (Exception ex) + { + ErrorLog.Error("{3}: Heartbeat timeout Error on Client Index: {0}, at address: {1}, error: {2}", clientIndex, address, ex.Message, Key); + } + } + + #endregion + + #region Methods - Socket Status Changed Callbacks + /// + /// Secure Server Socket Status Changed Callback + /// + /// + /// + /// + void TcpServer_SocketStatusChange(TCPServer server, uint clientIndex, SocketStatus serverSocketStatus) + { + try + { + + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "SecureServerSocketStatusChange Index:{0} status:{1} Port:{2} IP:{3}", clientIndex, serverSocketStatus, this.myTcpServer.GetPortNumberServerAcceptedConnectionFromForSpecificClient(clientIndex), this.myTcpServer.GetLocalAddressServerAcceptedConnectionFromForSpecificClient(clientIndex)); + if (serverSocketStatus != SocketStatus.SOCKET_STATUS_CONNECTED) + { + if (ConnectedClientsIndexes.Contains(clientIndex)) + ConnectedClientsIndexes.Remove(clientIndex); + if (HeartbeatRequired && HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary[clientIndex].Stop(); + HeartbeatTimerDictionary[clientIndex].Dispose(); + HeartbeatTimerDictionary.Remove(clientIndex); + } + if (ClientReadyAfterKeyExchange.Contains(clientIndex)) + ClientReadyAfterKeyExchange.Remove(clientIndex); + if (WaitingForSharedKey.Contains(clientIndex)) + WaitingForSharedKey.Remove(clientIndex); + } + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error in Socket Status Change Callback. Error: {0}", ex); + } + onConnectionChange(clientIndex, server.GetServerSocketStatusForSpecificClient(clientIndex)); + } + + #endregion + + #region Methods Connected Callbacks + /// + /// Secure TCP Client Connected to Secure Server Callback + /// + /// + /// + void TcpConnectCallback(TCPServer server, uint clientIndex) + { + try + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "ConnectCallback: IPAddress: {0}. Index: {1}. Status: {2}", + server.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex), + clientIndex, server.GetServerSocketStatusForSpecificClient(clientIndex)); + if (clientIndex != 0) + { + if (server.ClientConnected(clientIndex)) + { + + if (!ConnectedClientsIndexes.Contains(clientIndex)) + { + ConnectedClientsIndexes.Add(clientIndex); + } + if (SharedKeyRequired) + { + if (!WaitingForSharedKey.Contains(clientIndex)) + { + WaitingForSharedKey.Add(clientIndex); + } + byte[] b = Encoding.GetEncoding(28591).GetBytes("SharedKey:"); + server.SendDataAsync(clientIndex, b, b.Length, (x, y, z) => { }); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Sent Shared Key Request to client at {0}", server.GetAddressServerAcceptedConnectionFromForSpecificClient(clientIndex)); + } + else + { + OnServerClientReadyForCommunications(clientIndex); + } + if (HeartbeatRequired) + { + if (!HeartbeatTimerDictionary.ContainsKey(clientIndex)) + { + HeartbeatTimerDictionary.Add(clientIndex, new CTimer(HeartbeatTimer_CallbackFunction, clientIndex, HeartbeatRequiredIntervalMs)); + } + } + + server.ReceiveDataAsync(clientIndex, TcpServerReceivedDataAsyncCallback); + } + } + else + { + Debug.Console(1, this, Debug.ErrorLogLevel.Error, "Client attempt faulty."); + if (!ServerStopped) + { + server.WaitForConnectionAsync(IPAddress.Any, TcpConnectCallback); + return; + } + } + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error in Socket Status Connect Callback. Error: {0}", ex); + } + //Debug.Console(1, this, Debug.ErrorLogLevel, "((((((Server State bitfield={0}; maxclient={1}; ServerStopped={2}))))))", + // server.State, + // MaxClients, + // ServerStopped); + if ((server.State & ServerState.SERVER_LISTENING) != ServerState.SERVER_LISTENING && MaxClients > 1 && !ServerStopped) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Waiting for next connection"); + server.WaitForConnectionAsync(IPAddress.Any, TcpConnectCallback); + + } + } + + #endregion + + #region Methods - Send/Receive Callbacks + /// + /// Secure Received Data Async Callback + /// + /// + /// + /// + void TcpServerReceivedDataAsyncCallback(TCPServer myTCPServer, uint clientIndex, int numberOfBytesReceived) + { + if (numberOfBytesReceived > 0) + { + string received = "Nothing"; + try + { + byte[] bytes = myTCPServer.GetIncomingDataBufferForSpecificClient(clientIndex); + received = System.Text.Encoding.GetEncoding(28591).GetString(bytes, 0, numberOfBytesReceived); + if (WaitingForSharedKey.Contains(clientIndex)) + { + received = received.Replace("\r", ""); + received = received.Replace("\n", ""); + if (received != SharedKey) + { + byte[] b = Encoding.GetEncoding(28591).GetBytes("Shared key did not match server. Disconnecting"); + Debug.Console(1, this, Debug.ErrorLogLevel.Warning, "Client at index {0} Shared key did not match the server, disconnecting client. Key: {1}", clientIndex, received); + myTCPServer.SendData(clientIndex, b, b.Length); + myTCPServer.Disconnect(clientIndex); + return; + } + + WaitingForSharedKey.Remove(clientIndex); + byte[] success = Encoding.GetEncoding(28591).GetBytes("Shared Key Match"); + myTCPServer.SendDataAsync(clientIndex, success, success.Length, null); + OnServerClientReadyForCommunications(clientIndex); + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Client with index {0} provided the shared key and successfully connected to the server", clientIndex); + } + + else if (!string.IsNullOrEmpty(checkHeartbeat(clientIndex, received))) + onTextReceived(received, clientIndex); + } + catch (Exception ex) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, "Error Receiving data: {0}. Error: {1}", received, ex); + } + if (myTCPServer.GetServerSocketStatusForSpecificClient(clientIndex) == SocketStatus.SOCKET_STATUS_CONNECTED) + myTCPServer.ReceiveDataAsync(clientIndex, TcpServerReceivedDataAsyncCallback); + } + else + { + // If numberOfBytesReceived <= 0 + myTCPServer.Disconnect(); + } + + } + + #endregion + + #region Methods - EventHelpers/Callbacks + + //Private Helper method to call the Connection Change Event + void onConnectionChange(uint clientIndex, SocketStatus clientStatus) + { + if (clientIndex != 0) //0 is error not valid client change + { + var handler = ClientConnectionChange; + if (handler != null) + { + handler(this, new GenericTcpServerSocketStatusChangeEventArgs(myTcpServer, clientIndex, clientStatus)); + } + } + } + + //Private Helper method to call the Connection Change Event + void OnConnectionChange() + { + if (ProgramIsStopping) + { + return; + } + var handler = ClientConnectionChange; + if (handler != null) + { + handler(this, new GenericTcpServerSocketStatusChangeEventArgs()); + } + } + + //Private Helper Method to call the Text Received Event + void onTextReceived(string text, uint clientIndex) + { + var handler = TextReceived; + if (handler != null) + handler(this, new GenericTcpServerCommMethodReceiveTextArgs(text, clientIndex)); + } + + //Private Helper Method to call the Server State Change Event + void OnServerStateChange(ServerState state) + { + if (ProgramIsStopping) + { + return; + } + var handler = ServerStateChange; + if (handler != null) + { + handler(this, new GenericTcpServerStateChangedEventArgs(state)); + } + } + + /// + /// Private Event Handler method to handle the closing of connections when the program stops + /// + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + ProgramIsStopping = true; + // kill bandaid things + if (MonitorClientTimer != null) + MonitorClientTimer.Stop(); + if (MonitorClient != null) + MonitorClient.Disconnect(); + + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Program stopping. Closing server"); + KillServer(); + } + } + + //Private event handler method to raise the event that the server is ready to send data after a successful client shared key negotiation + void OnServerClientReadyForCommunications(uint clientIndex) + { + ClientReadyAfterKeyExchange.Add(clientIndex); + var handler = ServerClientReadyForCommunications; + if (handler != null) + handler(this, new GenericTcpServerSocketStatusChangeEventArgs( + this, clientIndex, myTcpServer.GetServerSocketStatusForSpecificClient(clientIndex))); + } + #endregion + + #region Monitor Client + /// + /// Starts the monitor client cycle. Timed wait, then call RunMonitorClient + /// + void StartMonitorClient() + { + if (MonitorClientTimer != null) + { + return; + } + MonitorClientTimer = new CTimer(o => RunMonitorClient(), 60000); + } + + /// + /// + /// + void RunMonitorClient() + { + MonitorClient = new GenericTcpIpClient_ForServer(Key + "-MONITOR", "127.0.0.1", Port, 2000); + MonitorClient.SharedKeyRequired = this.SharedKeyRequired; + MonitorClient.SharedKey = this.SharedKey; + MonitorClient.ConnectionHasHungCallback = MonitorClientHasHungCallback; + //MonitorClient.ConnectionChange += MonitorClient_ConnectionChange; + MonitorClient.ClientReadyForCommunications += MonitorClient_IsReadyForComm; + + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Starting monitor check"); + + MonitorClient.Connect(); + // From here MonitorCLient either connects or hangs, MonitorClient will call back + + } + + /// + /// + /// + void StopMonitorClient() + { + if (MonitorClient == null) + return; + + MonitorClient.ClientReadyForCommunications -= MonitorClient_IsReadyForComm; + MonitorClient.Disconnect(); + MonitorClient = null; + } + + /// + /// On monitor connect, restart the operation + /// + void MonitorClient_IsReadyForComm(object sender, GenericTcpServerClientReadyForcommunicationsEventArgs args) + { + if (args.IsReady) + { + Debug.Console(1, this, Debug.ErrorLogLevel.Notice, "Monitor client connection success. Disconnecting in 2s"); + MonitorClientTimer.Stop(); + MonitorClientTimer = null; + MonitorClientFailureCount = 0; + CrestronEnvironment.Sleep(2000); + StopMonitorClient(); + StartMonitorClient(); + } + } + + /// + /// If the client hangs, add to counter and maybe fire the choke event + /// + void MonitorClientHasHungCallback() + { + MonitorClientFailureCount++; + MonitorClientTimer.Stop(); + MonitorClientTimer = null; + StopMonitorClient(); + if (MonitorClientFailureCount < MonitorClientMaxFailureCount) + { + Debug.Console(2, this, Debug.ErrorLogLevel.Warning, "Monitor client connection has hung {0} time{1}, maximum {2}", + MonitorClientFailureCount, MonitorClientFailureCount > 1 ? "s" : "", MonitorClientMaxFailureCount); + StartMonitorClient(); + } + else + { + Debug.Console(2, this, Debug.ErrorLogLevel.Error, + "\r***************************\rMonitor client connection has hung a maximum of {0} times.\r***************************", + MonitorClientMaxFailureCount); + + var handler = ServerHasChoked; + if (handler != null) + handler(); + // Some external thing is in charge here. Expected reset of program + } + } + #endregion + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/GenericUdpServer.cs b/src/PepperDash.Core/Comm/GenericUdpServer.cs new file mode 100644 index 00000000..e31c581c --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpServer.cs @@ -0,0 +1,395 @@ + +using System; +using System.Linq; +using System.Text; + +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using Newtonsoft.Json; + +namespace PepperDash.Core +{ + /// + /// Generic UDP Server device + /// + public class GenericUdpServer : Device, ISocketStatusWithStreamDebugging + { + private const string SplusKey = "Uninitialized Udp Server"; + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + /// + /// + /// + public event EventHandler BytesReceived; + + /// + /// + /// + public event EventHandler TextReceived; + + /// + /// This event will fire when a message is dequeued that includes the source IP and Port info if needed to determine the source of the received data. + /// + public event EventHandler DataRecievedExtra; + + /// + /// + /// + public event EventHandler ConnectionChange; + + /// + /// + /// + public event EventHandler UpdateConnectionStatus; + + /// + /// + /// + public SocketStatus ClientStatus + { + get + { + return Server.ServerStatus; + } + } + + /// + /// + /// + public ushort UStatus + { + get { return (ushort)Server.ServerStatus; } + } + + /// + /// Address of server + /// + public string Hostname { get; set; } + + + /// + /// Port on server + /// + public int Port { get; set; } + + /// + /// Another damn S+ helper because S+ seems to treat large port nums as signed ints + /// which screws up things + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Indicates that the UDP Server is enabled + /// + public bool IsConnected + { + get; + private set; + } + + /// + /// Numeric value indicating + /// + public ushort UIsConnected + { + get { return IsConnected ? (ushort)1 : (ushort)0; } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// The server + /// + public UDPServer Server { get; private set; } + + /// + /// Constructor for S+. Make sure to set key, address, port, and buffersize using init method + /// + public GenericUdpServer() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + BufferSize = 5000; + + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + CrestronEnvironment.EthernetEventHandler += new EthernetEventHandler(CrestronEnvironment_EthernetEventHandler); + } + + /// + /// + /// + /// + /// + /// + /// + public GenericUdpServer(string key, string address, int port, int buffefSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + Hostname = address; + Port = port; + BufferSize = buffefSize; + + CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); + CrestronEnvironment.EthernetEventHandler += new EthernetEventHandler(CrestronEnvironment_EthernetEventHandler); + } + + /// + /// Call from S+ to initialize values + /// + /// + /// + /// + public void Initialize(string key, string address, ushort port) + { + Key = key; + Hostname = address; + UPort = port; + } + + /// + /// + /// + /// + void CrestronEnvironment_EthernetEventHandler(EthernetEventArgs ethernetEventArgs) + { + // Re-enable the server if the link comes back up and the status should be connected + if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp + && IsConnected) + { + Connect(); + } + } + + /// + /// + /// + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType != eProgramStatusEventType.Stopping) + return; + + Debug.Console(1, this, "Program stopping. Disabling Server"); + Disconnect(); + } + + /// + /// Enables the UDP Server + /// + public void Connect() + { + if (Server == null) + { + Server = new UDPServer(); + } + + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpServer '{0}': No address set", Key); + return; + } + if (Port < 1 || Port > 65535) + { + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpServer '{0}': Invalid port", Key); + return; + } + } + + var status = Server.EnableUDPServer(Hostname, Port); + + Debug.Console(2, this, "SocketErrorCode: {0}", status); + if (status == SocketErrorCodes.SOCKET_OK) + IsConnected = true; + + var handler = UpdateConnectionStatus; + if (handler != null) + handler(this, new GenericUdpConnectedEventArgs(UIsConnected)); + + // Start receiving data + Server.ReceiveDataAsync(Receive); + } + + /// + /// Disabled the UDP Server + /// + public void Disconnect() + { + if(Server != null) + Server.DisableUDPServer(); + + IsConnected = false; + + var handler = UpdateConnectionStatus; + if (handler != null) + handler(this, new GenericUdpConnectedEventArgs(UIsConnected)); + } + + + /// + /// Recursive method to receive data + /// + /// + /// + void Receive(UDPServer server, int numBytes) + { + Debug.Console(2, this, "Received {0} bytes", numBytes); + + try + { + if (numBytes <= 0) + return; + + var sourceIp = Server.IPAddressLastMessageReceivedFrom; + var sourcePort = Server.IPPortLastMessageReceivedFrom; + var bytes = server.IncomingDataBuffer.Take(numBytes).ToArray(); + var str = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + + var dataRecivedExtra = DataRecievedExtra; + if (dataRecivedExtra != null) + dataRecivedExtra(this, new GenericUdpReceiveTextExtraArgs(str, sourceIp, sourcePort, bytes)); + + Debug.Console(2, this, "Bytes: {0}", bytes.ToString()); + var bytesHandler = BytesReceived; + if (bytesHandler != null) + { + if (StreamDebugging.RxStreamDebuggingIsEnabled) + { + Debug.Console(0, this, "Received {1} bytes: '{0}'", ComTextHelper.GetEscapedText(bytes), bytes.Length); + } + bytesHandler(this, new GenericCommMethodReceiveBytesArgs(bytes)); + } + var textHandler = TextReceived; + if (textHandler != null) + { + if (StreamDebugging.RxStreamDebuggingIsEnabled) + Debug.Console(0, this, "Received {1} characters of text: '{0}'", ComTextHelper.GetDebugText(str), str.Length); + textHandler(this, new GenericCommMethodReceiveTextArgs(str)); + } + } + catch (Exception ex) + { + Debug.Console(0, "GenericUdpServer Receive error: {0}{1}", ex.Message, ex.StackTrace); + } + finally + { + server.ReceiveDataAsync(Receive); + } + } + + /// + /// General send method + /// + /// + public void SendText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + + if (IsConnected && Server != null) + { + if (StreamDebugging.TxStreamDebuggingIsEnabled) + Debug.Console(0, this, "Sending {0} characters of text: '{1}'", text.Length, ComTextHelper.GetDebugText(text)); + + Server.SendData(bytes, bytes.Length); + } + } + + /// + /// + /// + /// + public void SendBytes(byte[] bytes) + { + if (StreamDebugging.TxStreamDebuggingIsEnabled) + Debug.Console(0, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); + + if (IsConnected && Server != null) + Server.SendData(bytes, bytes.Length); + } + + } + + /// + /// + /// + public class GenericUdpReceiveTextExtraArgs : EventArgs + { + /// + /// + /// + public string Text { get; private set; } + /// + /// + /// + public string IpAddress { get; private set; } + /// + /// + /// + public int Port { get; private set; } + /// + /// + /// + public byte[] Bytes { get; private set; } + + /// + /// + /// + /// + /// + /// + /// + public GenericUdpReceiveTextExtraArgs(string text, string ipAddress, int port, byte[] bytes) + { + Text = text; + IpAddress = ipAddress; + Port = port; + Bytes = bytes; + } + + /// + /// Stupid S+ Constructor + /// + public GenericUdpReceiveTextExtraArgs() { } + } + + /// + /// + /// + public class UdpServerPropertiesConfig + { + /// + /// + /// + [JsonProperty(Required = Required.Always)] + public string Address { get; set; } + + /// + /// + /// + [JsonProperty(Required = Required.Always)] + public int Port { get; set; } + + /// + /// Defaults to 32768 + /// + public int BufferSize { get; set; } + + /// + /// + /// + public UdpServerPropertiesConfig() + { + BufferSize = 32768; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/TcpClientConfigObject.cs b/src/PepperDash.Core/Comm/TcpClientConfigObject.cs new file mode 100644 index 00000000..c3b3bcec --- /dev/null +++ b/src/PepperDash.Core/Comm/TcpClientConfigObject.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; + +namespace PepperDash.Core +{ + /// + /// Client config object for TCP client with server that inherits from TcpSshPropertiesConfig and adds properties for shared key and heartbeat + /// + public class TcpClientConfigObject + { + /// + /// TcpSsh Properties + /// + [JsonProperty("control")] + public ControlPropertiesConfig Control { get; set; } + + /// + /// Bool value for secure. Currently not implemented in TCP sockets as they are not dynamic + /// + [JsonProperty("secure")] + public bool Secure { get; set; } + + /// + /// Require a shared key that both server and client negotiate. If negotiation fails server disconnects the client + /// + [JsonProperty("sharedKeyRequired")] + public bool SharedKeyRequired { get; set; } + + /// + /// The shared key that must match on the server and client + /// + [JsonProperty("sharedKey")] + public string SharedKey { get; set; } + + /// + /// Require a heartbeat on the client/server connection that will cause the server/client to disconnect if the heartbeat is not received. + /// heartbeats do not raise received events. + /// + [JsonProperty("heartbeatRequired")] + public bool HeartbeatRequired { get; set; } + + /// + /// The interval in seconds for the heartbeat from the client. If not received client is disconnected + /// + [JsonProperty("heartbeatRequiredIntervalInSeconds")] + public ushort HeartbeatRequiredIntervalInSeconds { get; set; } + + /// + /// HeartbeatString that will be checked against the message received. defaults to heartbeat if no string is provided. + /// + [JsonProperty("heartbeatStringToMatch")] + public string HeartbeatStringToMatch { get; set; } + + /// + /// Receive Queue size must be greater than 20 or defaults to 20 + /// + [JsonProperty("receiveQueueSize")] + public int ReceiveQueueSize { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/TcpServerConfigObject.cs b/src/PepperDash.Core/Comm/TcpServerConfigObject.cs new file mode 100644 index 00000000..043cf58d --- /dev/null +++ b/src/PepperDash.Core/Comm/TcpServerConfigObject.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core +{ + /// + /// Tcp Server Config object with properties for a tcp server with shared key and heartbeat capabilities + /// + public class TcpServerConfigObject + { + /// + /// Uique key + /// + public string Key { get; set; } + /// + /// Max Clients that the server will allow to connect. + /// + public ushort MaxClients { get; set; } + /// + /// Bool value for secure. Currently not implemented in TCP sockets as they are not dynamic + /// + public bool Secure { get; set; } + /// + /// Port for the server to listen on + /// + public int Port { get; set; } + /// + /// Require a shared key that both server and client negotiate. If negotiation fails server disconnects the client + /// + public bool SharedKeyRequired { get; set; } + /// + /// The shared key that must match on the server and client + /// + public string SharedKey { get; set; } + /// + /// Require a heartbeat on the client/server connection that will cause the server/client to disconnect if the heartbeat is not received. + /// heartbeats do not raise received events. + /// + public bool HeartbeatRequired { get; set; } + /// + /// The interval in seconds for the heartbeat from the client. If not received client is disconnected + /// + public ushort HeartbeatRequiredIntervalInSeconds { get; set; } + /// + /// HeartbeatString that will be checked against the message received. defaults to heartbeat if no string is provided. + /// + public string HeartbeatStringToMatch { get; set; } + /// + /// Client buffer size. See Crestron help. defaults to 2000 if not greater than 2000 + /// + public int BufferSize { get; set; } + /// + /// Receive Queue size must be greater than 20 or defaults to 20 + /// + public int ReceiveQueueSize { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs new file mode 100644 index 00000000..28a95b12 --- /dev/null +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core +{ + /// + /// Crestron Control Methods for a comm object + /// + public enum eControlMethod + { + /// + /// + /// + None = 0, + /// + /// RS232/422/485 + /// + Com, + /// + /// Crestron IpId (most Crestron ethernet devices) + /// + IpId, + /// + /// Crestron IpIdTcp (HD-MD series, etc.) + /// + IpidTcp, + /// + /// Crestron IR control + /// + IR, + /// + /// SSH client + /// + Ssh, + /// + /// TCP/IP client + /// + Tcpip, + /// + /// Telnet + /// + Telnet, + /// + /// Crestnet device + /// + Cresnet, + /// + /// CEC Control, via a DM HDMI port + /// + Cec, + /// + /// UDP Server + /// + Udp, + /// + /// HTTP client + /// + Http, + /// + /// HTTPS client + /// + Https, + /// + /// Websocket client + /// + Ws, + /// + /// Secure Websocket client + /// + Wss, + /// + /// Secure TCP/IP + /// + SecureTcpIp + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/CommunicationExtras.cs b/src/PepperDash.Core/CommunicationExtras.cs new file mode 100644 index 00000000..81fd76c5 --- /dev/null +++ b/src/PepperDash.Core/CommunicationExtras.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace PepperDash.Core +{ + /// + /// An incoming communication stream + /// + public interface ICommunicationReceiver : IKeyed + { + /// + /// Notifies of bytes received + /// + event EventHandler BytesReceived; + /// + /// Notifies of text received + /// + event EventHandler TextReceived; + + /// + /// Indicates connection status + /// + [JsonProperty("isConnected")] + bool IsConnected { get; } + /// + /// Connect to the device + /// + void Connect(); + /// + /// Disconnect from the device + /// + void Disconnect(); + } + + /// + /// Represents a device that uses basic connection + /// + public interface IBasicCommunication : ICommunicationReceiver + { + /// + /// Send text to the device + /// + /// + void SendText(string text); + + /// + /// Send bytes to the device + /// + /// + void SendBytes(byte[] bytes); + } + + /// + /// Represents a device that implements IBasicCommunication and IStreamDebugging + /// + public interface IBasicCommunicationWithStreamDebugging : IBasicCommunication, IStreamDebugging + { + + } + + /// + /// Represents a device with stream debugging capablities + /// + public interface IStreamDebugging + { + /// + /// Object to enable stream debugging + /// + [JsonProperty("streamDebugging")] + CommunicationStreamDebugging StreamDebugging { get; } + } + + /// + /// For IBasicCommunication classes that have SocketStatus. GenericSshClient, + /// GenericTcpIpClient + /// + public interface ISocketStatus : IBasicCommunication + { + /// + /// Notifies of socket status changes + /// + event EventHandler ConnectionChange; + + /// + /// The current socket status of the client + /// + [JsonProperty("clientStatus")] + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + SocketStatus ClientStatus { get; } + } + + /// + /// Describes a device that implements ISocketStatus and IStreamDebugging + /// + public interface ISocketStatusWithStreamDebugging : ISocketStatus, IStreamDebugging + { + + } + + /// + /// Describes a device that can automatically attempt to reconnect + /// + public interface IAutoReconnect + { + /// + /// Enable automatic recconnect + /// + [JsonProperty("autoReconnect")] + bool AutoReconnect { get; set; } + /// + /// Interval in ms to attempt automatic recconnections + /// + [JsonProperty("autoReconnectIntervalMs")] + int AutoReconnectIntervalMs { get; set; } + } + + /// + /// + /// + public enum eGenericCommMethodStatusChangeType + { + /// + /// Connected + /// + Connected, + /// + /// Disconnected + /// + Disconnected + } + + /// + /// This delegate defines handler for IBasicCommunication status changes + /// + /// Device firing the status change + /// + public delegate void GenericCommMethodStatusHandler(IBasicCommunication comm, eGenericCommMethodStatusChangeType status); + + /// + /// + /// + public class GenericCommMethodReceiveBytesArgs : EventArgs + { + /// + /// + /// + public byte[] Bytes { get; private set; } + + /// + /// + /// + /// + public GenericCommMethodReceiveBytesArgs(byte[] bytes) + { + Bytes = bytes; + } + + /// + /// S+ Constructor + /// + public GenericCommMethodReceiveBytesArgs() { } + } + + /// + /// + /// + public class GenericCommMethodReceiveTextArgs : EventArgs + { + /// + /// + /// + public string Text { get; private set; } + /// + /// + /// + public string Delimiter { get; private set; } + /// + /// + /// + /// + public GenericCommMethodReceiveTextArgs(string text) + { + Text = text; + } + + /// + /// + /// + /// + /// + public GenericCommMethodReceiveTextArgs(string text, string delimiter) + :this(text) + { + Delimiter = delimiter; + } + + /// + /// S+ Constructor + /// + public GenericCommMethodReceiveTextArgs() { } + } + + + + /// + /// + /// + public class ComTextHelper + { + /// + /// Gets escaped text for a byte array + /// + /// + /// + public static string GetEscapedText(byte[] bytes) + { + return String.Concat(bytes.Select(b => string.Format(@"[{0:X2}]", (int)b)).ToArray()); + } + + /// + /// Gets escaped text for a string + /// + /// + /// + public static string GetEscapedText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + return String.Concat(bytes.Select(b => string.Format(@"[{0:X2}]", (int)b)).ToArray()); + } + + /// + /// Gets debug text for a string + /// + /// + /// + public static string GetDebugText(string text) + { + return Regex.Replace(text, @"[^\u0020-\u007E]", a => GetEscapedText(a.Value)); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Config/PortalConfigReader.cs b/src/PepperDash.Core/Config/PortalConfigReader.cs new file mode 100644 index 00000000..43e9ea1e --- /dev/null +++ b/src/PepperDash.Core/Config/PortalConfigReader.cs @@ -0,0 +1,235 @@ +using System; +using System.Linq; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PepperDash.Core; +using Serilog.Events; + +namespace PepperDash.Core.Config +{ + /// + /// Reads a Portal formatted config file + /// + public class PortalConfigReader + { + /// + /// Reads the config file, checks if it needs a merge, merges and saves, then returns the merged Object. + /// + /// JObject of config file + public static void ReadAndMergeFileIfNecessary(string filePath, string savePath) + { + try + { + if (!File.Exists(filePath)) + { + Debug.Console(1, Debug.ErrorLogLevel.Error, + "ERROR: Configuration file not present. Please load file to {0} and reset program", filePath); + } + + using (StreamReader fs = new StreamReader(filePath)) + { + var jsonObj = JObject.Parse(fs.ReadToEnd()); + if(jsonObj["template"] != null && jsonObj["system"] != null) + { + // it's a double-config, merge it. + var merged = MergeConfigs(jsonObj); + if (jsonObj["system_url"] != null) + { + merged["systemUrl"] = jsonObj["system_url"].Value(); + } + + if (jsonObj["template_url"] != null) + { + merged["templateUrl"] = jsonObj["template_url"].Value(); + } + + jsonObj = merged; + } + + using (StreamWriter fw = new StreamWriter(savePath)) + { + fw.Write(jsonObj.ToString(Formatting.Indented)); + Debug.LogMessage(LogEventLevel.Debug, "JSON config merged and saved to {0}", savePath); + } + + } + } + catch (Exception e) + { + Debug.LogMessage(e, "ERROR: Config load failed"); + } + } + + /// + /// + /// + /// + /// + public static JObject MergeConfigs(JObject doubleConfig) + { + var system = JObject.FromObject(doubleConfig["system"]); + var template = JObject.FromObject(doubleConfig["template"]); + var merged = new JObject(); + + // Put together top-level objects + if (system["info"] != null) + merged.Add("info", Merge(template["info"], system["info"], "infO")); + else + merged.Add("info", template["info"]); + + merged.Add("devices", MergeArraysOnTopLevelProperty(template["devices"] as JArray, + system["devices"] as JArray, "key", "devices")); + + if (system["rooms"] == null) + merged.Add("rooms", template["rooms"]); + else + merged.Add("rooms", MergeArraysOnTopLevelProperty(template["rooms"] as JArray, + system["rooms"] as JArray, "key", "rooms")); + + if (system["sourceLists"] == null) + merged.Add("sourceLists", template["sourceLists"]); + else + merged.Add("sourceLists", Merge(template["sourceLists"], system["sourceLists"], "sourceLists")); + + if (system["destinationLists"] == null) + merged.Add("destinationLists", template["destinationLists"]); + else + merged.Add("destinationLists", + Merge(template["destinationLists"], system["destinationLists"], "destinationLists")); + + + if (system["cameraLists"] == null) + merged.Add("cameraLists", template["cameraLists"]); + else + merged.Add("cameraLists", Merge(template["cameraLists"], system["cameraLists"], "cameraLists")); + + if (system["audioControlPointLists"] == null) + merged.Add("audioControlPointLists", template["audioControlPointLists"]); + else + merged.Add("audioControlPointLists", + Merge(template["audioControlPointLists"], system["audioControlPointLists"], "audioControlPointLists")); + + + // Template tie lines take precedence. Config tool doesn't do them at system + // level anyway... + if (template["tieLines"] != null) + merged.Add("tieLines", template["tieLines"]); + else if (system["tieLines"] != null) + merged.Add("tieLines", system["tieLines"]); + else + merged.Add("tieLines", new JArray()); + + if (template["joinMaps"] != null) + merged.Add("joinMaps", template["joinMaps"]); + else + merged.Add("joinMaps", new JObject()); + + if (system["global"] != null) + merged.Add("global", Merge(template["global"], system["global"], "global")); + else + merged.Add("global", template["global"]); + + //Debug.Console(2, "MERGED CONFIG RESULT: \x0d\x0a{0}", merged); + return merged; + } + + /// + /// Merges the contents of a base and a delta array, matching the entries on a top-level property + /// given by propertyName. Returns a merge of them. Items in the delta array that do not have + /// a matched item in base array will not be merged. Non keyed system items will replace the template items. + /// + static JArray MergeArraysOnTopLevelProperty(JArray a1, JArray a2, string propertyName, string path) + { + var result = new JArray(); + if (a2 == null || a2.Count == 0) // If the system array is null or empty, return the template array + return a1; + else if (a1 != null) + { + if (a2[0]["key"] == null) // If the first item in the system array has no key, overwrite the template array + { // with the system array + return a2; + } + else // The arrays are keyed, merge them by key + { + for (int i = 0; i < a1.Count(); i++) + { + var a1Dev = a1[i]; + // Try to get a system device and if found, merge it onto template + var a2Match = a2.FirstOrDefault(t => t[propertyName].Equals(a1Dev[propertyName]));// t.Value("uid") == tmplDev.Value("uid")); + if (a2Match != null) + { + var mergedItem = Merge(a1Dev, a2Match, string.Format("{0}[{1}].", path, i));// Merge(JObject.FromObject(a1Dev), JObject.FromObject(a2Match)); + result.Add(mergedItem); + } + else + result.Add(a1Dev); + } + } + } + return result; + } + + + /// + /// Helper for using with JTokens. Converts to JObject + /// + static JObject Merge(JToken t1, JToken t2, string path) + { + return Merge(JObject.FromObject(t1), JObject.FromObject(t2), path); + } + + /// + /// Merge o2 onto o1 + /// + /// + /// + /// + static JObject Merge(JObject o1, JObject o2, string path) + { + foreach (var o2Prop in o2) + { + var propKey = o2Prop.Key; + var o1Value = o1[propKey]; + var o2Value = o2[propKey]; + + // if the property doesn't exist on o1, then add it. + if (o1Value == null) + { + o1.Add(propKey, o2Value); + } + // otherwise merge them + else + { + // Drill down + var propPath = String.Format("{0}.{1}", path, propKey); + try + { + + if (o1Value is JArray) + { + if (o2Value is JArray) + { + o1Value.Replace(MergeArraysOnTopLevelProperty(o1Value as JArray, o2Value as JArray, "key", propPath)); + } + } + else if (o2Prop.Value.HasValues && o1Value.HasValues) + { + o1Value.Replace(Merge(JObject.FromObject(o1Value), JObject.FromObject(o2Value), propPath)); + } + else + { + o1Value.Replace(o2Prop.Value); + } + } + catch (Exception e) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "Cannot merge items at path {0}: \r{1}", propPath, e); + } + } + } + return o1; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Conversion/Convert.cs b/src/PepperDash.Core/Conversion/Convert.cs new file mode 100644 index 00000000..2bafdcb0 --- /dev/null +++ b/src/PepperDash.Core/Conversion/Convert.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core +{ + public class EncodingHelper + { + public static string ConvertUtf8ToAscii(string utf8String) + { + return Encoding.ASCII.GetString(Encoding.UTF8.GetBytes(utf8String), 0, utf8String.Length); + } + + public static string ConvertUtf8ToUtf16(string utf8String) + { + return Encoding.Unicode.GetString(Encoding.UTF8.GetBytes(utf8String), 0, utf8String.Length); + } + + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/CoreInterfaces.cs b/src/PepperDash.Core/CoreInterfaces.cs new file mode 100644 index 00000000..6e0b639e --- /dev/null +++ b/src/PepperDash.Core/CoreInterfaces.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Newtonsoft.Json; +using Serilog; + +namespace PepperDash.Core +{ + /// + /// Unique key interface to require a unique key for the class + /// + public interface IKeyed + { + /// + /// Unique Key + /// + [JsonProperty("key")] + string Key { get; } + } + + /// + /// Named Keyed device interface. Forces the device to have a Unique Key and a name. + /// + public interface IKeyName : IKeyed + { + /// + /// Isn't it obvious :) + /// + [JsonProperty("name")] + string Name { get; } + } + +} \ No newline at end of file diff --git a/src/PepperDash.Core/Device.cs b/src/PepperDash.Core/Device.cs new file mode 100644 index 00000000..fda30c4c --- /dev/null +++ b/src/PepperDash.Core/Device.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using Serilog.Events; + +namespace PepperDash.Core +{ + //********************************************************************************************************* + /// + /// The core event and status-bearing class that most if not all device and connectors can derive from. + /// + public class Device : IKeyName + { + + /// + /// Unique Key + /// + public string Key { get; protected set; } + /// + /// Name of the devie + /// + public string Name { get; protected set; } + /// + /// + /// + public bool Enabled { get; protected set; } + + ///// + ///// A place to store reference to the original config object, if any. These values should + ///// NOT be used as properties on the device as they are all publicly-settable values. + ///// + //public DeviceConfig Config { get; private set; } + ///// + ///// Helper method to check if Config exists + ///// + //public bool HasConfig { get { return Config != null; } } + + List _PreActivationActions; + List _PostActivationActions; + + /// + /// + /// + public static Device DefaultDevice { get { return _DefaultDevice; } } + static Device _DefaultDevice = new Device("Default", "Default"); + + /// + /// Base constructor for all Devices. + /// + /// + public Device(string key) + { + Key = key; + if (key.Contains(".")) Debug.LogMessage(LogEventLevel.Information, "WARNING: Device key should not include '.'", this); + Name = ""; + } + + /// + /// Constructor with key and name + /// + /// + /// + public Device(string key, string name) : this(key) + { + Name = name; + + } + + //public Device(DeviceConfig config) + // : this(config.Key, config.Name) + //{ + // Config = config; + //} + + /// + /// Adds a pre activation action + /// + /// + public void AddPreActivationAction(Action act) + { + if (_PreActivationActions == null) + _PreActivationActions = new List(); + _PreActivationActions.Add(act); + } + + /// + /// Adds a post activation action + /// + /// + public void AddPostActivationAction(Action act) + { + if (_PostActivationActions == null) + _PostActivationActions = new List(); + _PostActivationActions.Add(act); + } + + /// + /// Executes the preactivation actions + /// + public void PreActivate() + { + if (_PreActivationActions != null) + _PreActivationActions.ForEach(a => { + try + { + a.Invoke(); + } catch (Exception e) + { + Debug.LogMessage(e, "Error in PreActivationAction: " + e.Message, this); + } + }); + } + + /// + /// Gets this device ready to be used in the system. Runs any added pre-activation items, and + /// all post-activation at end. Classes needing additional logic to + /// run should override CustomActivate() + /// + public bool Activate() + { + //if (_PreActivationActions != null) + // _PreActivationActions.ForEach(a => a.Invoke()); + var result = CustomActivate(); + //if(result && _PostActivationActions != null) + // _PostActivationActions.ForEach(a => a.Invoke()); + return result; + } + + /// + /// Executes the postactivation actions + /// + public void PostActivate() + { + if (_PostActivationActions != null) + _PostActivationActions.ForEach(a => { + try + { + a.Invoke(); + } + catch (Exception e) + { + Debug.LogMessage(e, "Error in PostActivationAction: " + e.Message, this); + } + }); + } + + /// + /// Called in between Pre and PostActivationActions when Activate() is called. + /// Override to provide addtitional setup when calling activation. Overriding classes + /// do not need to call base.CustomActivate() + /// + /// true if device activated successfully. + public virtual bool CustomActivate() { return true; } + + /// + /// Call to deactivate device - unlink events, etc. Overriding classes do not + /// need to call base.Deactivate() + /// + /// + public virtual bool Deactivate() { return true; } + + /// + /// Call this method to start communications with a device. Overriding classes do not need to call base.Initialize() + /// + public virtual void Initialize() + { + } + + /// + /// Helper method to check object for bool value false and fire an Action method + /// + /// Should be of type bool, others will be ignored + /// Action to be run when o is false + public void OnFalse(object o, Action a) + { + if (o is bool && !(bool)o) a(); + } + + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Directory.build.targets b/src/PepperDash.Core/Directory.build.targets new file mode 100644 index 00000000..0ef185c7 --- /dev/null +++ b/src/PepperDash.Core/Directory.build.targets @@ -0,0 +1,43 @@ + + + + true + content; + + + true + content; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + doNotUse + + + + \ No newline at end of file diff --git a/src/PepperDash.Core/EssentialsPlugins-builds-4-series-caller.yml b/src/PepperDash.Core/EssentialsPlugins-builds-4-series-caller.yml new file mode 100644 index 00000000..c4b4d2c4 --- /dev/null +++ b/src/PepperDash.Core/EssentialsPlugins-builds-4-series-caller.yml @@ -0,0 +1,21 @@ +name: Build Essentials Plugin + +on: + push: + branches: + - '**' + +jobs: + getVersion: + uses: PepperDash/workflow-templates/.github/workflows/essentialsplugins-getversion.yml@main + secrets: inherit + build-4Series: + uses: PepperDash/workflow-templates/.github/workflows/essentialsplugins-4Series-builds.yml@main + secrets: inherit + needs: getVersion + if: needs.getVersion.outputs.newVersion == 'true' + with: + newVersion: ${{ needs.getVersion.outputs.newVersion }} + version: ${{ needs.getVersion.outputs.version }} + tag: ${{ needs.getVersion.outputs.tag }} + channel: ${{ needs.getVersion.outputs.channel }} \ No newline at end of file diff --git a/src/PepperDash.Core/EthernetHelper.cs b/src/PepperDash.Core/EthernetHelper.cs new file mode 100644 index 00000000..0ccc50aa --- /dev/null +++ b/src/PepperDash.Core/EthernetHelper.cs @@ -0,0 +1,116 @@ +using Crestron.SimplSharp; +using Newtonsoft.Json; + +namespace PepperDash.Core +{ + /// + /// Class to help with accessing values from the CrestronEthernetHelper class + /// + public class EthernetHelper + { + /// + /// + /// + public static EthernetHelper LanHelper + { + get + { + if (_LanHelper == null) _LanHelper = new EthernetHelper(0); + return _LanHelper; + } + } + static EthernetHelper _LanHelper; + + // ADD OTHER HELPERS HERE + + /// + /// + /// + public int PortNumber { get; private set; } + + private EthernetHelper(int portNumber) + { + PortNumber = portNumber; + } + + /// + /// + /// + [JsonProperty("linkActive")] + public bool LinkActive + { + get + { + var status = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_LINK_STATUS, 0); + Debug.Console(0, "LinkActive = {0}", status); + return status == ""; + } + } + + /// + /// + /// + [JsonProperty("dchpActive")] + public bool DhcpActive + { + get + { + return CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_DHCP_STATE, 0) == "ON"; + } + } + + /// + /// + /// + [JsonProperty("hostname")] + public string Hostname + { + get + { + return CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); + } + } + + /// + /// + /// + [JsonProperty("ipAddress")] + public string IPAddress + { + get + { + return CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); + } + } + + /// + /// + /// + [JsonProperty("subnetMask")] + public string SubnetMask + { + get + { + return CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_MASK, 0); + } + } + + /// + /// + /// + [JsonProperty("defaultGateway")] + public string DefaultGateway + { + get + { + return CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_ROUTER, 0); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/EventArgs.cs b/src/PepperDash.Core/EventArgs.cs new file mode 100644 index 00000000..29ef13a8 --- /dev/null +++ b/src/PepperDash.Core/EventArgs.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core +{ + /// + /// Bool change event args + /// + public class BoolChangeEventArgs : EventArgs + { + /// + /// Boolean state property + /// + public bool State { get; set; } + + /// + /// Boolean ushort value property + /// + public ushort IntValue { get { return (ushort)(State ? 1 : 0); } } + + /// + /// Boolean change event args type + /// + public ushort Type { get; set; } + + /// + /// Boolean change event args index + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public BoolChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public BoolChangeEventArgs(bool state, ushort type) + { + State = state; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public BoolChangeEventArgs(bool state, ushort type, ushort index) + { + State = state; + Type = type; + Index = index; + } + } + + /// + /// Ushort change event args + /// + public class UshrtChangeEventArgs : EventArgs + { + /// + /// Ushort change event args integer value + /// + public ushort IntValue { get; set; } + + /// + /// Ushort change event args type + /// + public ushort Type { get; set; } + + /// + /// Ushort change event args index + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public UshrtChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public UshrtChangeEventArgs(ushort intValue, ushort type) + { + IntValue = intValue; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public UshrtChangeEventArgs(ushort intValue, ushort type, ushort index) + { + IntValue = intValue; + Type = type; + Index = index; + } + } + + /// + /// String change event args + /// + public class StringChangeEventArgs : EventArgs + { + /// + /// String change event args value + /// + public string StringValue { get; set; } + + /// + /// String change event args type + /// + public ushort Type { get; set; } + + /// + /// string change event args index + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public StringChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public StringChangeEventArgs(string stringValue, ushort type) + { + StringValue = stringValue; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public StringChangeEventArgs(string stringValue, ushort type, ushort index) + { + StringValue = stringValue; + Type = type; + Index = index; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/GenericRESTfulCommunications/Constants.cs b/src/PepperDash.Core/GenericRESTfulCommunications/Constants.cs new file mode 100644 index 00000000..1b78c33f --- /dev/null +++ b/src/PepperDash.Core/GenericRESTfulCommunications/Constants.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.GenericRESTfulCommunications +{ + /// + /// Constants + /// + public class GenericRESTfulConstants + { + /// + /// Generic boolean change + /// + public const ushort BoolValueChange = 1; + /// + /// Generic Ushort change + /// + public const ushort UshrtValueChange = 101; + /// + /// Response Code Ushort change + /// + public const ushort ResponseCodeChange = 102; + /// + /// Generic String chagne + /// + public const ushort StringValueChange = 201; + /// + /// Response string change + /// + public const ushort ResponseStringChange = 202; + /// + /// Error string change + /// + public const ushort ErrorStringChange = 203; + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/GenericRESTfulCommunications/GenericRESTfulClient.cs b/src/PepperDash.Core/GenericRESTfulCommunications/GenericRESTfulClient.cs new file mode 100644 index 00000000..bd33e13c --- /dev/null +++ b/src/PepperDash.Core/GenericRESTfulCommunications/GenericRESTfulClient.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.Net.Http; +using Crestron.SimplSharp.Net.Https; + +namespace PepperDash.Core.GenericRESTfulCommunications +{ + /// + /// Generic RESTful communication class + /// + public class GenericRESTfulClient + { + /// + /// Boolean event handler + /// + public event EventHandler BoolChange; + /// + /// Ushort event handler + /// + public event EventHandler UshrtChange; + /// + /// String event handler + /// + public event EventHandler StringChange; + + /// + /// Constructor + /// + public GenericRESTfulClient() + { + + } + + /// + /// Generic RESTful submit request + /// + /// + /// + /// + /// + /// + /// + public void SubmitRequest(string url, ushort port, ushort requestType, string contentType, string username, string password) + { + if (url.StartsWith("https:", StringComparison.OrdinalIgnoreCase)) + { + SubmitRequestHttps(url, port, requestType, contentType, username, password); + } + else if (url.StartsWith("http:", StringComparison.OrdinalIgnoreCase)) + { + SubmitRequestHttp(url, port, requestType, contentType, username, password); + } + else + { + OnStringChange(string.Format("Invalid URL {0}", url), 0, GenericRESTfulConstants.ErrorStringChange); + } + } + + /// + /// Private HTTP submit request + /// + /// + /// + /// + /// + /// + /// + private void SubmitRequestHttp(string url, ushort port, ushort requestType, string contentType, string username, string password) + { + try + { + HttpClient client = new HttpClient(); + HttpClientRequest request = new HttpClientRequest(); + HttpClientResponse response; + + client.KeepAlive = false; + + if(port >= 1 || port <= 65535) + client.Port = port; + else + client.Port = 80; + + var authorization = ""; + if (!string.IsNullOrEmpty(username)) + authorization = EncodeBase64(username, password); + + if (!string.IsNullOrEmpty(authorization)) + request.Header.SetHeaderValue("Authorization", authorization); + + if (!string.IsNullOrEmpty(contentType)) + request.Header.ContentType = contentType; + + request.Url.Parse(url); + request.RequestType = (Crestron.SimplSharp.Net.Http.RequestType)requestType; + + response = client.Dispatch(request); + + CrestronConsole.PrintLine(string.Format("SubmitRequestHttp Response[{0}]: {1}", response.Code, response.ContentString.ToString())); + + if (!string.IsNullOrEmpty(response.ContentString.ToString())) + OnStringChange(response.ContentString.ToString(), 0, GenericRESTfulConstants.ResponseStringChange); + + if (response.Code > 0) + OnUshrtChange((ushort)response.Code, 0, GenericRESTfulConstants.ResponseCodeChange); + } + catch (Exception e) + { + //var msg = string.Format("SubmitRequestHttp({0}, {1}, {2}) failed:{3}", url, port, requestType, e.Message); + //CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + + CrestronConsole.PrintLine(e.Message); + OnStringChange(e.Message, 0, GenericRESTfulConstants.ErrorStringChange); + } + } + + /// + /// Private HTTPS submit request + /// + /// + /// + /// + /// + /// + /// + private void SubmitRequestHttps(string url, ushort port, ushort requestType, string contentType, string username, string password) + { + try + { + HttpsClient client = new HttpsClient(); + HttpsClientRequest request = new HttpsClientRequest(); + HttpsClientResponse response; + + client.KeepAlive = false; + client.HostVerification = false; + client.PeerVerification = false; + + var authorization = ""; + if (!string.IsNullOrEmpty(username)) + authorization = EncodeBase64(username, password); + + if (!string.IsNullOrEmpty(authorization)) + request.Header.SetHeaderValue("Authorization", authorization); + + if (!string.IsNullOrEmpty(contentType)) + request.Header.ContentType = contentType; + + request.Url.Parse(url); + request.RequestType = (Crestron.SimplSharp.Net.Https.RequestType)requestType; + + response = client.Dispatch(request); + + CrestronConsole.PrintLine(string.Format("SubmitRequestHttp Response[{0}]: {1}", response.Code, response.ContentString.ToString())); + + if(!string.IsNullOrEmpty(response.ContentString.ToString())) + OnStringChange(response.ContentString.ToString(), 0, GenericRESTfulConstants.ResponseStringChange); + + if(response.Code > 0) + OnUshrtChange((ushort)response.Code, 0, GenericRESTfulConstants.ResponseCodeChange); + + } + catch (Exception e) + { + //var msg = string.Format("SubmitRequestHttps({0}, {1}, {2}, {3}, {4}) failed:{5}", url, port, requestType, username, password, e.Message); + //CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + + CrestronConsole.PrintLine(e.Message); + OnStringChange(e.Message, 0, GenericRESTfulConstants.ErrorStringChange); + } + } + + /// + /// Private method to encode username and password to Base64 string + /// + /// + /// + /// authorization + private string EncodeBase64(string username, string password) + { + var authorization = ""; + + try + { + if (!string.IsNullOrEmpty(username)) + { + string base64String = System.Convert.ToBase64String(System.Text.Encoding.GetEncoding("ISO-8859-1").GetBytes(string.Format("{0}:{1}", username, password))); + authorization = string.Format("Basic {0}", base64String); + } + } + catch (Exception e) + { + var msg = string.Format("EncodeBase64({0}, {1}) failed:\r{2}", username, password, e); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + return "" ; + } + + return authorization; + } + + /// + /// Protected method to handle boolean change events + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// Protected mehtod to handle ushort change events + /// + /// + /// + /// + protected void OnUshrtChange(ushort value, ushort index, ushort type) + { + var handler = UshrtChange; + if (handler != null) + { + var args = new UshrtChangeEventArgs(value, type); + args.Index = index; + UshrtChange(this, args); + } + } + + /// + /// Protected method to handle string change events + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonStandardObjects/EventArgs and Constants.cs b/src/PepperDash.Core/JsonStandardObjects/EventArgs and Constants.cs new file mode 100644 index 00000000..ed02ccb0 --- /dev/null +++ b/src/PepperDash.Core/JsonStandardObjects/EventArgs and Constants.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.JsonStandardObjects +{ + /// + /// Constants for simpl modules + /// + public class JsonStandardDeviceConstants + { + /// + /// Json object evaluated constant + /// + public const ushort JsonObjectEvaluated = 2; + + /// + /// Json object changed constant + /// + public const ushort JsonObjectChanged = 104; + } + + /// + /// + /// + public class DeviceChangeEventArgs : EventArgs + { + /// + /// Device change event args object + /// + public DeviceConfig Device { get; set; } + + /// + /// Device change event args type + /// + public ushort Type { get; set; } + + /// + /// Device change event args index + /// + public ushort Index { get; set; } + + /// + /// Default constructor + /// + public DeviceChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public DeviceChangeEventArgs(DeviceConfig device, ushort type) + { + Device = device; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public DeviceChangeEventArgs(DeviceConfig device, ushort type, ushort index) + { + Device = device; + Type = type; + Index = index; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDevice.cs b/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDevice.cs new file mode 100644 index 00000000..3abfd36a --- /dev/null +++ b/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDevice.cs @@ -0,0 +1,183 @@ +using System; +using System.Linq; +using Crestron.SimplSharp; +using PepperDash.Core.JsonToSimpl; +using Serilog.Events; + +namespace PepperDash.Core.JsonStandardObjects +{ + /// + /// Device class + /// + public class DeviceConfig + { + /// + /// JSON config key property + /// + public string key { get; set; } + /// + /// JSON config name property + /// + public string name { get; set; } + /// + /// JSON config type property + /// + public string type { get; set; } + /// + /// JSON config properties + /// + public PropertiesConfig properties { get; set; } + + /// + /// Bool change event handler + /// + public event EventHandler BoolChange; + /// + /// Ushort change event handler + /// + public event EventHandler UshrtChange; + /// + /// String change event handler + /// + public event EventHandler StringChange; + /// + /// Object change event handler + /// + public event EventHandler DeviceChange; + + /// + /// Constructor + /// + public DeviceConfig() + { + properties = new PropertiesConfig(); + } + + /// + /// Initialize method + /// + /// + /// + public void Initialize(string uniqueID, string deviceKey) + { + // S+ set EvaluateFb low + OnBoolChange(false, 0, JsonStandardDeviceConstants.JsonObjectEvaluated); + // validate parameters + if (string.IsNullOrEmpty(uniqueID) || string.IsNullOrEmpty(deviceKey)) + { + Debug.LogMessage(LogEventLevel.Debug, "UniqueID ({0} or key ({1} is null or empty", uniqueID, deviceKey); + // S+ set EvaluteFb high + OnBoolChange(true, 0, JsonStandardDeviceConstants.JsonObjectEvaluated); + return; + } + + key = deviceKey; + + try + { + // get the file using the unique ID + JsonToSimplMaster jsonMaster = J2SGlobal.GetMasterByFile(uniqueID); + if (jsonMaster == null) + { + Debug.LogMessage(LogEventLevel.Debug, "Could not find JSON file with uniqueID {0}", uniqueID); + return; + } + + // get the device configuration using the key + var devices = jsonMaster.JsonObject.ToObject().devices; + var device = devices.FirstOrDefault(d => d.key.Equals(key)); + if (device == null) + { + Debug.LogMessage(LogEventLevel.Debug, "Could not find device with key {0}", key); + return; + } + OnObjectChange(device, 0, JsonStandardDeviceConstants.JsonObjectChanged); + + var index = devices.IndexOf(device); + OnStringChange(string.Format("devices[{0}]", index), 0, JsonToSimplConstants.FullPathToArrayChange); + } + catch (Exception e) + { + var msg = string.Format("Device {0} lookup failed:\r{1}", key, e); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + } + finally + { + // S+ set EvaluteFb high + OnBoolChange(true, 0, JsonStandardDeviceConstants.JsonObjectEvaluated); + } + } + + #region EventHandler Helpers + + /// + /// BoolChange event handler helper + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// UshrtChange event handler helper + /// + /// + /// + /// + protected void OnUshrtChange(ushort state, ushort index, ushort type) + { + var handler = UshrtChange; + if (handler != null) + { + var args = new UshrtChangeEventArgs(state, type); + args.Index = index; + UshrtChange(this, args); + } + } + + /// + /// StringChange event handler helper + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + + /// + /// ObjectChange event handler helper + /// + /// + /// + /// + protected void OnObjectChange(DeviceConfig device, ushort index, ushort type) + { + if (DeviceChange != null) + { + var args = new DeviceChangeEventArgs(device, type); + args.Index = index; + DeviceChange(this, args); + } + } + + #endregion EventHandler Helpers + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDeviceConfig.cs b/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDeviceConfig.cs new file mode 100644 index 00000000..fa23d87e --- /dev/null +++ b/src/PepperDash.Core/JsonStandardObjects/JsonToSimplDeviceConfig.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.JsonStandardObjects +{ + /* + Convert JSON snippt to C#: http://json2csharp.com/# + + JSON Snippet: + { + "devices": [ + { + "key": "deviceKey", + "name": "deviceName", + "type": "deviceType", + "properties": { + "deviceId": 1, + "enabled": true, + "control": { + "method": "methodName", + "controlPortDevKey": "deviceControlPortDevKey", + "controlPortNumber": 1, + "comParams": { + "baudRate": 9600, + "dataBits": 8, + "stopBits": 1, + "parity": "None", + "protocol": "RS232", + "hardwareHandshake": "None", + "softwareHandshake": "None", + "pacing": 0 + }, + "tcpSshProperties": { + "address": "172.22.1.101", + "port": 23, + "username": "user01", + "password": "password01", + "autoReconnect": false, + "autoReconnectIntervalMs": 10000 + } + } + } + } + ] + } + */ + /// + /// Device communication parameter class + /// + public class ComParamsConfig + { + /// + /// + /// + public int baudRate { get; set; } + /// + /// + /// + public int dataBits { get; set; } + /// + /// + /// + public int stopBits { get; set; } + /// + /// + /// + public string parity { get; set; } + /// + /// + /// + public string protocol { get; set; } + /// + /// + /// + public string hardwareHandshake { get; set; } + /// + /// + /// + public string softwareHandshake { get; set; } + /// + /// + /// + public int pacing { get; set; } + + // convert properties for simpl + /// + /// + /// + public ushort simplBaudRate { get { return Convert.ToUInt16(baudRate); } } + /// + /// + /// + public ushort simplDataBits { get { return Convert.ToUInt16(dataBits); } } + /// + /// + /// + public ushort simplStopBits { get { return Convert.ToUInt16(stopBits); } } + /// + /// + /// + public ushort simplPacing { get { return Convert.ToUInt16(pacing); } } + + /// + /// Constructor + /// + public ComParamsConfig() + { + + } + } + + /// + /// Device TCP/SSH properties class + /// + public class TcpSshPropertiesConfig + { + /// + /// + /// + public string address { get; set; } + /// + /// + /// + public int port { get; set; } + /// + /// + /// + public string username { get; set; } + /// + /// + /// + public string password { get; set; } + /// + /// + /// + public bool autoReconnect { get; set; } + /// + /// + /// + public int autoReconnectIntervalMs { get; set; } + + // convert properties for simpl + /// + /// + /// + public ushort simplPort { get { return Convert.ToUInt16(port); } } + /// + /// + /// + public ushort simplAutoReconnect { get { return (ushort)(autoReconnect ? 1 : 0); } } + /// + /// + /// + public ushort simplAutoReconnectIntervalMs { get { return Convert.ToUInt16(autoReconnectIntervalMs); } } + + /// + /// Constructor + /// + public TcpSshPropertiesConfig() + { + + } + } + + /// + /// Device control class + /// + public class ControlConfig + { + /// + /// + /// + public string method { get; set; } + /// + /// + /// + public string controlPortDevKey { get; set; } + /// + /// + /// + public int controlPortNumber { get; set; } + /// + /// + /// + public ComParamsConfig comParams { get; set; } + /// + /// + /// + public TcpSshPropertiesConfig tcpSshProperties { get; set; } + + // convert properties for simpl + /// + /// + /// + public ushort simplControlPortNumber { get { return Convert.ToUInt16(controlPortNumber); } } + + /// + /// Constructor + /// + public ControlConfig() + { + comParams = new ComParamsConfig(); + tcpSshProperties = new TcpSshPropertiesConfig(); + } + } + + /// + /// Device properties class + /// + public class PropertiesConfig + { + /// + /// + /// + public int deviceId { get; set; } + /// + /// + /// + public bool enabled { get; set; } + /// + /// + /// + public ControlConfig control { get; set; } + + // convert properties for simpl + /// + /// + /// + public ushort simplDeviceId { get { return Convert.ToUInt16(deviceId); } } + /// + /// + /// + public ushort simplEnabled { get { return (ushort)(enabled ? 1 : 0); } } + + /// + /// Constructor + /// + public PropertiesConfig() + { + control = new ControlConfig(); + } + } + + /// + /// Root device class + /// + public class RootObject + { + /// + /// The collection of devices + /// + public List devices { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/Constants.cs b/src/PepperDash.Core/JsonToSimpl/Constants.cs new file mode 100644 index 00000000..d87b50c2 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/Constants.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Constants for Simpl modules + /// + public class JsonToSimplConstants + { + /// + /// + /// + public const ushort BoolValueChange = 1; + /// + /// + /// + public const ushort JsonIsValidBoolChange = 2; + + /// + /// Reports the if the device is 3-series compatible + /// + public const ushort ProgramCompatibility3SeriesChange = 3; + + /// + /// Reports the if the device is 4-series compatible + /// + public const ushort ProgramCompatibility4SeriesChange = 4; + + /// + /// Reports the device platform enum value + /// + public const ushort DevicePlatformValueChange = 5; + + /// + /// + /// + public const ushort UshortValueChange = 101; + + /// + /// + /// + public const ushort StringValueChange = 201; + /// + /// + /// + public const ushort FullPathToArrayChange = 202; + /// + /// + /// + public const ushort ActualFilePathChange = 203; + + /// + /// + /// + public const ushort FilenameResolvedChange = 204; + /// + /// + /// + public const ushort FilePathResolvedChange = 205; + + /// + /// Reports the root directory change + /// + public const ushort RootDirectoryChange = 206; + + /// + /// Reports the room ID change + /// + public const ushort RoomIdChange = 207; + + /// + /// Reports the room name change + /// + public const ushort RoomNameChange = 208; + } + + /// + /// S+ values delegate + /// + public delegate void SPlusValuesDelegate(); + + /// + /// S+ values wrapper + /// + public class SPlusValueWrapper + { + /// + /// + /// + public SPlusType ValueType { get; private set; } + /// + /// + /// + public ushort Index { get; private set; } + /// + /// + /// + public ushort BoolUShortValue { get; set; } + /// + /// + /// + public string StringValue { get; set; } + + /// + /// + /// + public SPlusValueWrapper() {} + + /// + /// + /// + /// + /// + public SPlusValueWrapper(SPlusType type, ushort index) + { + ValueType = type; + Index = index; + } + } + + /// + /// S+ types enum + /// + public enum SPlusType + { + /// + /// Digital + /// + Digital, + /// + /// Analog + /// + Analog, + /// + /// String + /// + String + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/Global.cs b/src/PepperDash.Core/JsonToSimpl/Global.cs new file mode 100644 index 00000000..be2e7951 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/Global.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +//using PepperDash.Core; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// The global class to manage all the instances of JsonToSimplMaster + /// + public class J2SGlobal + { + static List Masters = new List(); + + + /// + /// Adds a file master. If the master's key or filename is equivalent to any existing + /// master, this will fail + /// + /// New master to add + /// + public static void AddMaster(JsonToSimplMaster master) + { + if (master == null) + throw new ArgumentNullException("master"); + + if (string.IsNullOrEmpty(master.UniqueID)) + throw new InvalidOperationException("JSON Master cannot be added with a null UniqueId"); + + Debug.Console(1, "JSON Global adding master {0}", master.UniqueID); + + if (Masters.Contains(master)) return; + + var existing = Masters.FirstOrDefault(m => + m.UniqueID.Equals(master.UniqueID, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { + Masters.Add(master); + } + else + { + var msg = string.Format("Cannot add JSON Master with unique ID '{0}'.\rID is already in use on another master.", master.UniqueID); + CrestronConsole.PrintLine(msg); + ErrorLog.Warn(msg); + } + } + + /// + /// Gets a master by its key. Case-insensitive + /// + public static JsonToSimplMaster GetMasterByFile(string file) + { + return Masters.FirstOrDefault(m => m.UniqueID.Equals(file, StringComparison.OrdinalIgnoreCase)); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplArrayLookupChild.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplArrayLookupChild.cs new file mode 100644 index 00000000..c94dad29 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplArrayLookupChild.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using Serilog.Events; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Used to interact with an array of values with the S+ modules + /// + public class JsonToSimplArrayLookupChild : JsonToSimplChildObjectBase + { + /// + /// + /// + public string SearchPropertyName { get; set; } + /// + /// + /// + public string SearchPropertyValue { get; set; } + + int ArrayIndex; + + /// + /// For gt2.4.1 array lookups + /// + /// + /// + /// + /// + /// + /// + public void Initialize(string file, string key, string pathPrefix, string pathSuffix, + string searchPropertyName, string searchPropertyValue) + { + base.Initialize(file, key, pathPrefix, pathSuffix); + SearchPropertyName = searchPropertyName; + SearchPropertyValue = searchPropertyValue; + } + + + /// + /// For newer >=2.4.1 array lookups. + /// + /// + /// + /// + /// + /// + /// + /// + public void InitializeWithAppend(string file, string key, string pathPrefix, string pathAppend, + string pathSuffix, string searchPropertyName, string searchPropertyValue) + { + string pathPrefixWithAppend = (pathPrefix != null ? pathPrefix : "") + GetPathAppend(pathAppend); + base.Initialize(file, key, pathPrefixWithAppend, pathSuffix); + + SearchPropertyName = searchPropertyName; + SearchPropertyValue = searchPropertyValue; + } + + + + //PathPrefix+ArrayName+[x]+path+PathSuffix + /// + /// + /// + /// + /// + protected override string GetFullPath(string path) + { + return string.Format("{0}[{1}].{2}{3}", + PathPrefix == null ? "" : PathPrefix, + ArrayIndex, + path, + PathSuffix == null ? "" : PathSuffix); + } + + /// + /// Process all values + /// + public override void ProcessAll() + { + if (FindInArray()) + base.ProcessAll(); + } + + /// + /// Provides the path append for GetFullPath + /// + /// + string GetPathAppend(string a) + { + if (string.IsNullOrEmpty(a)) + { + return ""; + } + if (a.StartsWith(".")) + { + return a; + } + else + { + return "." + a; + } + } + + /// + /// + /// + /// + bool FindInArray() + { + if (Master == null) + throw new InvalidOperationException("Cannot do operations before master is linked"); + if (Master.JsonObject == null) + throw new InvalidOperationException("Cannot do operations before master JSON has read"); + if (PathPrefix == null) + throw new InvalidOperationException("Cannot do operations before PathPrefix is set"); + + + var token = Master.JsonObject.SelectToken(PathPrefix); + if (token is JArray) + { + var array = token as JArray; + try + { + var item = array.FirstOrDefault(o => + { + var prop = o[SearchPropertyName]; + return prop != null && prop.Value() + .Equals(SearchPropertyValue, StringComparison.OrdinalIgnoreCase); + }); + if (item == null) + { + Debug.LogMessage(LogEventLevel.Debug,"JSON Child[{0}] Array '{1}' '{2}={3}' not found: ", Key, + PathPrefix, SearchPropertyName, SearchPropertyValue); + this.LinkedToObject = false; + return false; + } + + this.LinkedToObject = true; + ArrayIndex = array.IndexOf(item); + OnStringChange(string.Format("{0}[{1}]", PathPrefix, ArrayIndex), 0, JsonToSimplConstants.FullPathToArrayChange); + Debug.LogMessage(LogEventLevel.Debug, "JSON Child[{0}] Found array match at index {1}", Key, ArrayIndex); + return true; + } + catch (Exception e) + { + Debug.LogMessage(e, "JSON Child[{key}] Array '{pathPrefix}' lookup error: '{searchPropertyName}={searchPropertyValue}'", null, Key, + PathPrefix, SearchPropertyName, SearchPropertyValue, e); + } + } + else + { + Debug.LogMessage(LogEventLevel.Debug, "JSON Child[{0}] Path '{1}' is not an array", Key, PathPrefix); + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplChildObjectBase.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplChildObjectBase.cs new file mode 100644 index 00000000..5aa67c96 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplChildObjectBase.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Base class for JSON objects + /// + public abstract class JsonToSimplChildObjectBase : IKeyed + { + /// + /// Notifies of bool change + /// + public event EventHandler BoolChange; + /// + /// Notifies of ushort change + /// + public event EventHandler UShortChange; + /// + /// Notifies of string change + /// + public event EventHandler StringChange; + + /// + /// Delegate to get all values + /// + public SPlusValuesDelegate GetAllValuesDelegate { get; set; } + + /// + /// Use a callback to reduce task switch/threading + /// + public SPlusValuesDelegate SetAllPathsDelegate { get; set; } + + /// + /// Unique identifier for instance + /// + public string Key { get; protected set; } + + /// + /// This will be prepended to all paths to allow path swapping or for more organized + /// sub-paths + /// + public string PathPrefix { get; protected set; } + + /// + /// This is added to the end of all paths + /// + public string PathSuffix { get; protected set; } + + /// + /// Indicates if the instance is linked to an object + /// + public bool LinkedToObject { get; protected set; } + + /// + /// Reference to Master instance + /// + protected JsonToSimplMaster Master; + + /// + /// Paths to boolean values in JSON structure + /// + protected Dictionary BoolPaths = new Dictionary(); + /// + /// Paths to numeric values in JSON structure + /// + protected Dictionary UshortPaths = new Dictionary(); + /// + /// Paths to string values in JSON structure + /// + protected Dictionary StringPaths = new Dictionary(); + + /// + /// Call this before doing anything else + /// + /// + /// + /// + /// + public void Initialize(string masterUniqueId, string key, string pathPrefix, string pathSuffix) + { + Key = key; + PathPrefix = pathPrefix; + PathSuffix = pathSuffix; + + Master = J2SGlobal.GetMasterByFile(masterUniqueId); + if (Master != null) + Master.AddChild(this); + else + Debug.Console(1, "JSON Child [{0}] cannot link to master {1}", key, masterUniqueId); + } + + /// + /// Sets the path prefix for the object + /// + /// + public void SetPathPrefix(string pathPrefix) + { + PathPrefix = pathPrefix; + } + /// + /// Set the JPath to evaluate for a given bool out index. + /// + public void SetBoolPath(ushort index, string path) + { + Debug.Console(1, "JSON Child[{0}] SetBoolPath {1}={2}", Key, index, path); + if (path == null || path.Trim() == string.Empty) return; + BoolPaths[index] = path; + } + + /// + /// Set the JPath for a ushort out index. + /// + public void SetUshortPath(ushort index, string path) + { + Debug.Console(1, "JSON Child[{0}] SetUshortPath {1}={2}", Key, index, path); + if (path == null || path.Trim() == string.Empty) return; + UshortPaths[index] = path; + } + + /// + /// Set the JPath for a string output index. + /// + public void SetStringPath(ushort index, string path) + { + Debug.Console(1, "JSON Child[{0}] SetStringPath {1}={2}", Key, index, path); + if (path == null || path.Trim() == string.Empty) return; + StringPaths[index] = path; + } + + /// + /// Evalutates all outputs with defined paths. called by S+ when paths are ready to process + /// and by Master when file is read. + /// + public virtual void ProcessAll() + { + if (!LinkedToObject) + { + Debug.Console(1, this, "Not linked to object in file. Skipping"); + return; + } + + if (SetAllPathsDelegate == null) + { + Debug.Console(1, this, "No SetAllPathsDelegate set. Ignoring ProcessAll"); + return; + } + SetAllPathsDelegate(); + foreach (var kvp in BoolPaths) + ProcessBoolPath(kvp.Key); + foreach (var kvp in UshortPaths) + ProcessUshortPath(kvp.Key); + foreach (var kvp in StringPaths) + ProcessStringPath(kvp.Key); + } + + /// + /// Processes a bool property, converting to bool, firing off a BoolChange event + /// + void ProcessBoolPath(ushort index) + { + string response; + if (Process(BoolPaths[index], out response)) + OnBoolChange(response.Equals("true", StringComparison.OrdinalIgnoreCase), + index, JsonToSimplConstants.BoolValueChange); + else { } + // OnBoolChange(false, index, JsonToSimplConstants.BoolValueChange); + } + + // Processes the path to a ushort, converting to ushort if able, twos complement if necessary, firing off UshrtChange event + void ProcessUshortPath(ushort index) { + string response; + if (Process(UshortPaths[index], out response)) { + ushort val; + try { val = Convert.ToInt32(response) < 0 ? (ushort)(Convert.ToInt16(response) + 65536) : Convert.ToUInt16(response); } + catch { val = 0; } + + OnUShortChange(val, index, JsonToSimplConstants.UshortValueChange); + } + else { } + // OnUShortChange(0, index, JsonToSimplConstants.UshortValueChange); + } + + // Processes the path to a string property and fires of a StringChange event. + void ProcessStringPath(ushort index) + { + string response; + if (Process(StringPaths[index], out response)) + OnStringChange(response, index, JsonToSimplConstants.StringValueChange); + else { } + // OnStringChange("", index, JsonToSimplConstants.StringValueChange); + } + + /// + /// Processes the given path. + /// + /// JPath formatted path to the desired property + /// The string value of the property, or a default value if it + /// doesn't exist + /// This will return false in the case that EvaulateAllOnJsonChange + /// is false and the path does not evaluate to a property in the incoming JSON. + bool Process(string path, out string response) + { + path = GetFullPath(path); + Debug.Console(1, "JSON Child[{0}] Processing {1}", Key, path); + response = ""; + if (Master == null) + { + Debug.Console(1, "JSONChild[{0}] cannot process without Master attached", Key); + return false; + } + + if (Master.JsonObject != null && path != string.Empty) + { + bool isCount = false; + path = path.Trim(); + if (path.EndsWith(".Count")) + { + path = path.Remove(path.Length - 6, 6); + isCount = true; + } + try // Catch a strange cast error on a bad path + { + var t = Master.JsonObject.SelectToken(path); + if (t != null) + { + // return the count of children objects - if any + if (isCount) + response = (t.HasValues ? t.Children().Count() : 0).ToString(); + else + response = t.Value(); + Debug.Console(1, " ='{0}'", response); + return true; + } + } + catch + { + response = ""; + } + } + // If the path isn't found, return this to determine whether to pass out the non-value or not. + return false; + } + + + //************************************************************************************************ + // Save-related functions + + + /// + /// Called from Master to read inputs and update their values in master JObject + /// Callback should hit one of the following four methods + /// + public void UpdateInputsForMaster() + { + if (!LinkedToObject) + { + Debug.Console(1, this, "Not linked to object in file. Skipping"); + return; + } + + if (SetAllPathsDelegate == null) + { + Debug.Console(1, this, "No SetAllPathsDelegate set. Ignoring UpdateInputsForMaster"); + return; + } + SetAllPathsDelegate(); + var del = GetAllValuesDelegate; + if (del != null) + GetAllValuesDelegate(); + } + + /// + /// + /// + /// + /// + public void USetBoolValue(ushort key, ushort theValue) + { + SetBoolValue(key, theValue == 1); + } + + /// + /// + /// + /// + /// + public void SetBoolValue(ushort key, bool theValue) + { + if (BoolPaths.ContainsKey(key)) + SetValueOnMaster(BoolPaths[key], new JValue(theValue)); + } + + /// + /// + /// + /// + /// + public void SetUShortValue(ushort key, ushort theValue) + { + if (UshortPaths.ContainsKey(key)) + SetValueOnMaster(UshortPaths[key], new JValue(theValue)); + } + + /// + /// + /// + /// + /// + public void SetStringValue(ushort key, string theValue) + { + if (StringPaths.ContainsKey(key)) + SetValueOnMaster(StringPaths[key], new JValue(theValue)); + } + + /// + /// + /// + /// + /// + public void SetValueOnMaster(string keyPath, JValue valueToSave) + { + var path = GetFullPath(keyPath); + try + { + Debug.Console(1, "JSON Child[{0}] Queueing value on master {1}='{2}'", Key, path, valueToSave); + + //var token = Master.JsonObject.SelectToken(path); + //if (token != null) // The path exists in the file + Master.AddUnsavedValue(path, valueToSave); + } + catch (Exception e) + { + Debug.Console(1, "JSON Child[{0}] Failed setting value for path '{1}'\r{2}", Key, path, e); + } + } + + /// + /// Called during Process(...) to get the path to a given property. By default, + /// returns PathPrefix+path+PathSuffix. Override to change the way path is built. + /// + protected virtual string GetFullPath(string path) + { + return (PathPrefix != null ? PathPrefix : "") + + path + (PathSuffix != null ? PathSuffix : ""); + } + + // Helpers for events + //****************************************************************************************** + /// + /// Event helper + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + //****************************************************************************************** + /// + /// Event helper + /// + /// + /// + /// + protected void OnUShortChange(ushort state, ushort index, ushort type) + { + var handler = UShortChange; + if (handler != null) + { + var args = new UshrtChangeEventArgs(state, type); + args.Index = index; + UShortChange(this, args); + } + } + + /// + /// Event helper + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplFileMaster.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplFileMaster.cs new file mode 100644 index 00000000..411fbdc5 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplFileMaster.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Newtonsoft.Json.Linq; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Represents a JSON file that can be read and written to + /// + public class JsonToSimplFileMaster : JsonToSimplMaster + { + /// + /// Sets the filepath as well as registers this with the Global.Masters list + /// + public string Filepath { get; private set; } + + /// + /// Filepath to the actual file that will be read (Portal or local) + /// + public string ActualFilePath { get; private set; } + + /// + /// + /// + public string Filename { get; private set; } + /// + /// + /// + public string FilePathName { get; private set; } + + /*****************************************************************************************/ + /** Privates **/ + + + // The JSON file in JObject form + // For gathering the incoming data + object StringBuilderLock = new object(); + // To prevent multiple same-file access + static object FileLock = new object(); + + /*****************************************************************************************/ + + /// + /// SIMPL+ default constructor. + /// + public JsonToSimplFileMaster() + { + } + + /// + /// Read, evaluate and udpate status + /// + public void EvaluateFile(string filepath) + { + try + { + OnBoolChange(false, 0, JsonToSimplConstants.JsonIsValidBoolChange); + + var dirSeparator = Path.DirectorySeparatorChar; + var dirSeparatorAlt = Path.AltDirectorySeparatorChar; + + var series = CrestronEnvironment.ProgramCompatibility; + + var is3Series = (eCrestronSeries.Series3 == (series & eCrestronSeries.Series3)); + OnBoolChange(is3Series, 0, + JsonToSimplConstants.ProgramCompatibility3SeriesChange); + + var is4Series = (eCrestronSeries.Series4 == (series & eCrestronSeries.Series4)); + OnBoolChange(is4Series, 0, + JsonToSimplConstants.ProgramCompatibility4SeriesChange); + + var isServer = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server; + OnBoolChange(isServer, 0, + JsonToSimplConstants.DevicePlatformValueChange); + + // get the roomID + var roomId = Crestron.SimplSharp.InitialParametersClass.RoomId; + if (!string.IsNullOrEmpty(roomId)) + { + OnStringChange(roomId, 0, JsonToSimplConstants.RoomIdChange); + } + + // get the roomName + var roomName = Crestron.SimplSharp.InitialParametersClass.RoomName; + if (!string.IsNullOrEmpty(roomName)) + { + OnStringChange(roomName, 0, JsonToSimplConstants.RoomNameChange); + } + + var rootDirectory = Directory.GetApplicationRootDirectory(); + OnStringChange(rootDirectory, 0, JsonToSimplConstants.RootDirectoryChange); + + var splusPath = string.Empty; + if (Regex.IsMatch(filepath, @"user", RegexOptions.IgnoreCase)) + { + if (is4Series) + splusPath = Regex.Replace(filepath, "user", "user", RegexOptions.IgnoreCase); + else if (isServer) + splusPath = Regex.Replace(filepath, "user", "User", RegexOptions.IgnoreCase); + else + splusPath = filepath; + } + + filepath = splusPath.Replace(dirSeparatorAlt, dirSeparator); + + Filepath = string.Format("{1}{0}{2}", dirSeparator, rootDirectory, + filepath.TrimStart(dirSeparator, dirSeparatorAlt)); + + OnStringChange(string.Format("Attempting to evaluate {0}", Filepath), 0, JsonToSimplConstants.StringValueChange); + + if (string.IsNullOrEmpty(Filepath)) + { + OnStringChange(string.Format("Cannot evaluate file. JSON file path not set"), 0, JsonToSimplConstants.StringValueChange); + CrestronConsole.PrintLine("Cannot evaluate file. JSON file path not set"); + return; + } + + // get file directory and name to search + var fileDirectory = Path.GetDirectoryName(Filepath); + var fileName = Path.GetFileName(Filepath); + + OnStringChange(string.Format("Checking '{0}' for '{1}'", fileDirectory, fileName), 0, JsonToSimplConstants.StringValueChange); + Debug.Console(1, "Checking '{0}' for '{1}'", fileDirectory, fileName); + + if (Directory.Exists(fileDirectory)) + { + // get the directory info + var directoryInfo = new DirectoryInfo(fileDirectory); + + // get the file to be read + var actualFile = directoryInfo.GetFiles(fileName).FirstOrDefault(); + if (actualFile == null) + { + var msg = string.Format("JSON file not found: {0}", Filepath); + OnStringChange(msg, 0, JsonToSimplConstants.StringValueChange); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + return; + } + + // \xSE\xR\PDT000-Template_Main_Config-Combined_DSP_v00.02.json + // \USER\PDT000-Template_Main_Config-Combined_DSP_v00.02.json + ActualFilePath = actualFile.FullName; + OnStringChange(ActualFilePath, 0, JsonToSimplConstants.ActualFilePathChange); + OnStringChange(string.Format("Actual JSON file is {0}", ActualFilePath), 0, JsonToSimplConstants.StringValueChange); + Debug.Console(1, "Actual JSON file is {0}", ActualFilePath); + + Filename = actualFile.Name; + OnStringChange(Filename, 0, JsonToSimplConstants.FilenameResolvedChange); + OnStringChange(string.Format("JSON Filename is {0}", Filename), 0, JsonToSimplConstants.StringValueChange); + Debug.Console(1, "JSON Filename is {0}", Filename); + + + FilePathName = string.Format(@"{0}{1}", actualFile.DirectoryName, dirSeparator); + OnStringChange(string.Format(@"{0}", actualFile.DirectoryName), 0, JsonToSimplConstants.FilePathResolvedChange); + OnStringChange(string.Format(@"JSON File Path is {0}", actualFile.DirectoryName), 0, JsonToSimplConstants.StringValueChange); + Debug.Console(1, "JSON File Path is {0}", FilePathName); + + var json = File.ReadToEnd(ActualFilePath, System.Text.Encoding.ASCII); + + JsonObject = JObject.Parse(json); + foreach (var child in Children) + child.ProcessAll(); + + OnBoolChange(true, 0, JsonToSimplConstants.JsonIsValidBoolChange); + } + else + { + OnStringChange(string.Format("'{0}' not found", fileDirectory), 0, JsonToSimplConstants.StringValueChange); + Debug.Console(1, "'{0}' not found", fileDirectory); + } + } + catch (Exception e) + { + var msg = string.Format("EvaluateFile Exception: Message\r{0}", e.Message); + OnStringChange(msg, 0, JsonToSimplConstants.StringValueChange); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + + var stackTrace = string.Format("EvaluateFile: Stack Trace\r{0}", e.StackTrace); + OnStringChange(stackTrace, 0, JsonToSimplConstants.StringValueChange); + CrestronConsole.PrintLine(stackTrace); + ErrorLog.Error(stackTrace); + } + } + + + /// + /// Sets the debug level + /// + /// + public void setDebugLevel(uint level) + { + Debug.SetDebugLevel(level); + } + + /// + /// Saves the values to the file + /// + public override void Save() + { + // this code is duplicated in the other masters!!!!!!!!!!!!! + UnsavedValues = new Dictionary(); + // Make each child update their values into master object + foreach (var child in Children) + { + Debug.Console(1, "Master [{0}] checking child [{1}] for updates to save", UniqueID, child.Key); + child.UpdateInputsForMaster(); + } + + if (UnsavedValues == null || UnsavedValues.Count == 0) + { + Debug.Console(1, "Master [{0}] No updated values to save. Skipping", UniqueID); + return; + } + lock (FileLock) + { + Debug.Console(1, "Saving"); + foreach (var path in UnsavedValues.Keys) + { + var tokenToReplace = JsonObject.SelectToken(path); + if (tokenToReplace != null) + {// It's found + tokenToReplace.Replace(UnsavedValues[path]); + Debug.Console(1, "JSON Master[{0}] Updating '{1}'", UniqueID, path); + } + else // No token. Let's make one + { + //http://stackoverflow.com/questions/17455052/how-to-set-the-value-of-a-json-path-using-json-net + Debug.Console(1, "JSON Master[{0}] Cannot write value onto missing property: '{1}'", UniqueID, path); + + // JContainer jpart = JsonObject; + // // walk down the path and find where it goes + //#warning Does not handle arrays. + // foreach (var part in path.Split('.')) + // { + + // var openPos = part.IndexOf('['); + // if (openPos > -1) + // { + // openPos++; // move to number + // var closePos = part.IndexOf(']'); + // var arrayName = part.Substring(0, openPos - 1); // get the name + // var index = Convert.ToInt32(part.Substring(openPos, closePos - openPos)); + + // // Check if the array itself exists and add the item if so + // if (jpart[arrayName] != null) + // { + // var arrayObj = jpart[arrayName] as JArray; + // var item = arrayObj[index]; + // if (item == null) + // arrayObj.Add(new JObject()); + // } + + // Debug.Console(0, "IGNORING MISSING ARRAY VALUE FOR NOW"); + // continue; + // } + // // Build the + // if (jpart[part] == null) + // jpart.Add(new JProperty(part, new JObject())); + // jpart = jpart[part] as JContainer; + // } + // jpart.Replace(UnsavedValues[path]); + } + } + using (StreamWriter sw = new StreamWriter(ActualFilePath)) + { + try + { + sw.Write(JsonObject.ToString()); + sw.Flush(); + } + catch (Exception e) + { + string err = string.Format("Error writing JSON file:\r{0}", e); + Debug.Console(0, err); + ErrorLog.Warn(err); + return; + } + } + } + } + } +} diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplFixedPathObject.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplFixedPathObject.cs new file mode 100644 index 00000000..3e69ed9d --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplFixedPathObject.cs @@ -0,0 +1,18 @@ + + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// + /// + public class JsonToSimplFixedPathObject : JsonToSimplChildObjectBase + { + /// + /// Constructor + /// + public JsonToSimplFixedPathObject() + { + this.LinkedToObject = true; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplGenericMaster.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplGenericMaster.cs new file mode 100644 index 00000000..e0f42f8e --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplGenericMaster.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using Crestron.SimplSharp; +using Newtonsoft.Json.Linq; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Generic Master + /// + public class JsonToSimplGenericMaster : JsonToSimplMaster + { + /*****************************************************************************************/ + /** Privates **/ + + + // The JSON file in JObject form + // For gathering the incoming data + object StringBuilderLock = new object(); + // To prevent multiple same-file access + static object WriteLock = new object(); + + /// + /// Callback action for saving + /// + public Action SaveCallback { get; set; } + + /*****************************************************************************************/ + + /// + /// SIMPL+ default constructor. + /// + public JsonToSimplGenericMaster() + { + } + + /// + /// Loads in JSON and triggers evaluation on all children + /// + /// + public void LoadWithJson(string json) + { + OnBoolChange(false, 0, JsonToSimplConstants.JsonIsValidBoolChange); + try + { + JsonObject = JObject.Parse(json); + foreach (var child in Children) + child.ProcessAll(); + OnBoolChange(true, 0, JsonToSimplConstants.JsonIsValidBoolChange); + } + catch (Exception e) + { + var msg = string.Format("JSON parsing failed:\r{0}", e); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + } + } + + /// + /// Loads JSON into JsonObject, but does not trigger evaluation by children + /// + /// + public void SetJsonWithoutEvaluating(string json) + { + try + { + JsonObject = JObject.Parse(json); + } + catch (Exception e) + { + Debug.Console(0, this, "JSON parsing failed:\r{0}", e); + } + } + + /// + /// + /// + public override void Save() + { + // this code is duplicated in the other masters!!!!!!!!!!!!! + UnsavedValues = new Dictionary(); + // Make each child update their values into master object + foreach (var child in Children) + { + Debug.Console(1, this, "Master. checking child [{0}] for updates to save", child.Key); + child.UpdateInputsForMaster(); + } + + if (UnsavedValues == null || UnsavedValues.Count == 0) + { + Debug.Console(1, this, "Master. No updated values to save. Skipping"); + return; + } + + lock (WriteLock) + { + Debug.Console(1, this, "Saving"); + foreach (var path in UnsavedValues.Keys) + { + var tokenToReplace = JsonObject.SelectToken(path); + if (tokenToReplace != null) + {// It's found + tokenToReplace.Replace(UnsavedValues[path]); + Debug.Console(1, this, "Master Updating '{0}'", path); + } + else // No token. Let's make one + { + Debug.Console(1, "Master Cannot write value onto missing property: '{0}'", path); + } + } + } + if (SaveCallback != null) + SaveCallback(JsonObject.ToString()); + else + Debug.Console(0, this, "WARNING: No save callback defined."); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplMaster.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplMaster.cs new file mode 100644 index 00000000..2f872e41 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplMaster.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Abstract base class for JsonToSimpl interactions + /// + public abstract class JsonToSimplMaster : IKeyed + { + /// + /// Notifies of bool change + /// + public event EventHandler BoolChange; + /// + /// Notifies of ushort change + /// + public event EventHandler UshrtChange; + /// + /// Notifies of string change + /// + public event EventHandler StringChange; + + /// + /// A collection of associated child modules + /// + protected List Children = new List(); + + /*****************************************************************************************/ + + /// + /// Mirrors the Unique ID for now. + /// + public string Key { get { return UniqueID; } } + + /// + /// A unique ID + /// + public string UniqueID { get; protected set; } + + /// + /// Merely for use in debug messages + /// + public string DebugName + { + get { return _DebugName; } + set { if (DebugName == null) _DebugName = ""; else _DebugName = value; } + } + string _DebugName = ""; + + /// + /// This will be prepended to all paths to allow path swapping or for more organized + /// sub-paths + /// + public string PathPrefix { get; set; } + + /// + /// This is added to the end of all paths + /// + public string PathSuffix { get; set; } + + /// + /// Enables debugging output to the console. Certain error messages will be logged to the + /// system's error log regardless of this setting + /// + public bool DebugOn { get; set; } + + /// + /// Ushort helper for Debug property + /// + public ushort UDebug + { + get { return (ushort)(DebugOn ? 1 : 0); } + set + { + DebugOn = (value == 1); + CrestronConsole.PrintLine("JsonToSimpl debug={0}", DebugOn); + } + } + + /// + /// + /// + public JObject JsonObject { get; protected set; } + + /*****************************************************************************************/ + /** Privates **/ + + + // The JSON file in JObject form + // For gathering the incoming data + protected Dictionary UnsavedValues = new Dictionary(); + + /*****************************************************************************************/ + + /// + /// SIMPL+ default constructor. + /// + public JsonToSimplMaster() + { + } + + + /// + /// Sets up class - overriding methods should always call this. + /// + /// + public virtual void Initialize(string uniqueId) + { + UniqueID = uniqueId; + J2SGlobal.AddMaster(this); // Should not re-add + } + + /// + /// Adds a child "module" to this master + /// + /// + public void AddChild(JsonToSimplChildObjectBase child) + { + if (!Children.Contains(child)) + { + Children.Add(child); + } + } + + /// + /// Called from the child to add changed or new values for saving + /// + public void AddUnsavedValue(string path, JValue value) + { + if (UnsavedValues.ContainsKey(path)) + { + Debug.Console(0, "Master[{0}] WARNING - Attempt to add duplicate value for path '{1}'.\r Ingoring. Please ensure that path does not exist on multiple modules.", UniqueID, path); + } + else + UnsavedValues.Add(path, value); + //Debug.Console(0, "Master[{0}] Unsaved size={1}", UniqueID, UnsavedValues.Count); + } + + /// + /// Saves the file + /// + public abstract void Save(); + + + /// + /// + /// + public static class JsonFixes + { + /// + /// Deserializes a string into a JObject + /// + /// + /// + public static JObject ParseObject(string json) + { + #if NET6_0 + using (var reader = new JsonTextReader(new System.IO.StringReader(json))) +#else + using (var reader = new JsonTextReader(new Crestron.SimplSharp.CrestronIO.StringReader(json))) +#endif + { + var startDepth = reader.Depth; + var obj = JObject.Load(reader); + if (startDepth != reader.Depth) + throw new JsonSerializationException("Unenclosed json found"); + return obj; + } + } + + /// + /// Deserializes a string into a JArray + /// + /// + /// + public static JArray ParseArray(string json) + { + #if NET6_0 + using (var reader = new JsonTextReader(new System.IO.StringReader(json))) +#else + using (var reader = new JsonTextReader(new Crestron.SimplSharp.CrestronIO.StringReader(json))) +#endif + { + var startDepth = reader.Depth; + var obj = JArray.Load(reader); + if (startDepth != reader.Depth) + throw new JsonSerializationException("Unenclosed json found"); + return obj; + } + } + } + + /// + /// Helper event + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + if (BoolChange != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// Helper event + /// + /// + /// + /// + protected void OnUshrtChange(ushort state, ushort index, ushort type) + { + if (UshrtChange != null) + { + var args = new UshrtChangeEventArgs(state, type); + args.Index = index; + UshrtChange(this, args); + } + } + + /// + /// Helper event + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + if (StringChange != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + } +} diff --git a/src/PepperDash.Core/JsonToSimpl/JsonToSimplPortalFileMaster.cs b/src/PepperDash.Core/JsonToSimpl/JsonToSimplPortalFileMaster.cs new file mode 100644 index 00000000..c170a9a1 --- /dev/null +++ b/src/PepperDash.Core/JsonToSimpl/JsonToSimplPortalFileMaster.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Newtonsoft.Json.Linq; +using PepperDash.Core.Config; + +namespace PepperDash.Core.JsonToSimpl +{ + /// + /// Portal File Master + /// + public class JsonToSimplPortalFileMaster : JsonToSimplMaster + { + /// + /// Sets the filepath as well as registers this with the Global.Masters list + /// + public string PortalFilepath { get; private set; } + + /// + /// File path of the actual file being read (Portal or local) + /// + public string ActualFilePath { get; private set; } + + /*****************************************************************************************/ + /** Privates **/ + + // To prevent multiple same-file access + object StringBuilderLock = new object(); + static object FileLock = new object(); + + /*****************************************************************************************/ + + /// + /// SIMPL+ default constructor. + /// + public JsonToSimplPortalFileMaster() + { + } + + /// + /// Read, evaluate and udpate status + /// + public void EvaluateFile(string portalFilepath) + { + PortalFilepath = portalFilepath; + + OnBoolChange(false, 0, JsonToSimplConstants.JsonIsValidBoolChange); + if (string.IsNullOrEmpty(PortalFilepath)) + { + CrestronConsole.PrintLine("Cannot evaluate file. JSON file path not set"); + return; + } + + // Resolve possible wildcarded filename + + // If the portal file is xyz.json, then + // the file we want to check for first will be called xyz.local.json + var localFilepath = Path.ChangeExtension(PortalFilepath, "local.json"); + Debug.Console(0, this, "Checking for local file {0}", localFilepath); + var actualLocalFile = GetActualFileInfoFromPath(localFilepath); + + if (actualLocalFile != null) + { + ActualFilePath = actualLocalFile.FullName; + OnStringChange(ActualFilePath, 0, JsonToSimplConstants.ActualFilePathChange); + } + // If the local file does not exist, then read the portal file xyz.json + // and create the local. + else + { + Debug.Console(1, this, "Local JSON file not found {0}\rLoading portal JSON file", localFilepath); + var actualPortalFile = GetActualFileInfoFromPath(portalFilepath); + if (actualPortalFile != null) + { + var newLocalPath = Path.ChangeExtension(actualPortalFile.FullName, "local.json"); + // got the portal file, hand off to the merge / save method + PortalConfigReader.ReadAndMergeFileIfNecessary(actualPortalFile.FullName, newLocalPath); + ActualFilePath = newLocalPath; + OnStringChange(ActualFilePath, 0, JsonToSimplConstants.ActualFilePathChange); + } + else + { + var msg = string.Format("Portal JSON file not found: {0}", PortalFilepath); + Debug.Console(1, this, msg); + ErrorLog.Error(msg); + return; + } + } + + // At this point we should have a local file. Do it. + Debug.Console(1, "Reading local JSON file {0}", ActualFilePath); + + string json = File.ReadToEnd(ActualFilePath, System.Text.Encoding.ASCII); + + try + { + JsonObject = JObject.Parse(json); + foreach (var child in Children) + child.ProcessAll(); + OnBoolChange(true, 0, JsonToSimplConstants.JsonIsValidBoolChange); + } + catch (Exception e) + { + var msg = string.Format("JSON parsing failed:\r{0}", e); + CrestronConsole.PrintLine(msg); + ErrorLog.Error(msg); + return; + } + } + + /// + /// Returns the FileInfo object for a given path, with possible wildcards + /// + /// + /// + FileInfo GetActualFileInfoFromPath(string path) + { + var dir = Path.GetDirectoryName(path); + var localFilename = Path.GetFileName(path); + var directory = new DirectoryInfo(dir); + // search the directory for the file w/ wildcards + return directory.GetFiles(localFilename).FirstOrDefault(); + } + + /// + /// + /// + /// + public void setDebugLevel(uint level) + { + Debug.SetDebugLevel(level); + } + + /// + /// + /// + public override void Save() + { + // this code is duplicated in the other masters!!!!!!!!!!!!! + UnsavedValues = new Dictionary(); + // Make each child update their values into master object + foreach (var child in Children) + { + Debug.Console(1, "Master [{0}] checking child [{1}] for updates to save", UniqueID, child.Key); + child.UpdateInputsForMaster(); + } + + if (UnsavedValues == null || UnsavedValues.Count == 0) + { + Debug.Console(1, "Master [{0}] No updated values to save. Skipping", UniqueID); + return; + } + lock (FileLock) + { + Debug.Console(1, "Saving"); + foreach (var path in UnsavedValues.Keys) + { + var tokenToReplace = JsonObject.SelectToken(path); + if (tokenToReplace != null) + {// It's found + tokenToReplace.Replace(UnsavedValues[path]); + Debug.Console(1, "JSON Master[{0}] Updating '{1}'", UniqueID, path); + } + else // No token. Let's make one + { + //http://stackoverflow.com/questions/17455052/how-to-set-the-value-of-a-json-path-using-json-net + Debug.Console(1, "JSON Master[{0}] Cannot write value onto missing property: '{1}'", UniqueID, path); + + } + } + using (StreamWriter sw = new StreamWriter(ActualFilePath)) + { + try + { + sw.Write(JsonObject.ToString()); + sw.Flush(); + } + catch (Exception e) + { + string err = string.Format("Error writing JSON file:\r{0}", e); + Debug.Console(0, err); + ErrorLog.Warn(err); + return; + } + } + } + } + } +} diff --git a/src/PepperDash.Core/Logging/CrestronEnricher.cs b/src/PepperDash.Core/Logging/CrestronEnricher.cs new file mode 100644 index 00000000..902ce8d5 --- /dev/null +++ b/src/PepperDash.Core/Logging/CrestronEnricher.cs @@ -0,0 +1,37 @@ +using Crestron.SimplSharp; +using Serilog.Core; +using Serilog.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PepperDash.Core.Logging +{ + public class CrestronEnricher : ILogEventEnricher + { + static readonly string _appName; + + static CrestronEnricher() + { + switch (CrestronEnvironment.DevicePlatform) + { + case eDevicePlatform.Appliance: + _appName = $"App {InitialParametersClass.ApplicationNumber}"; + break; + case eDevicePlatform.Server: + _appName = $"{InitialParametersClass.RoomId}"; + break; + } + } + + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var property = propertyFactory.CreateProperty("App", _appName); + + logEvent.AddOrUpdateProperty(property); + } + } +} diff --git a/src/PepperDash.Core/Logging/Debug.cs b/src/PepperDash.Core/Logging/Debug.cs new file mode 100644 index 00000000..4e79a601 --- /dev/null +++ b/src/PepperDash.Core/Logging/Debug.cs @@ -0,0 +1,895 @@ +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronDataStore; +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.CrestronLogger; +using Newtonsoft.Json; +using PepperDash.Core.Logging; +using Serilog; +using Serilog.Context; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Compact; +using Serilog.Formatting.Json; +using Serilog.Templates; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace PepperDash.Core +{ + /// + /// Contains debug commands for use in various situations + /// + public static class Debug + { + private static readonly string LevelStoreKey = "ConsoleDebugLevel"; + private static readonly string WebSocketLevelStoreKey = "WebsocketDebugLevel"; + private static readonly string ErrorLogLevelStoreKey = "ErrorLogDebugLevel"; + private static readonly string FileLevelStoreKey = "FileDebugLevel"; + + private static readonly Dictionary _logLevels = new Dictionary() + { + {0, LogEventLevel.Information }, + {3, LogEventLevel.Warning }, + {4, LogEventLevel.Error }, + {5, LogEventLevel.Fatal }, + {1, LogEventLevel.Debug }, + {2, LogEventLevel.Verbose }, + }; + + private static ILogger _logger; + + private static readonly LoggingLevelSwitch _consoleLoggingLevelSwitch; + + private static readonly LoggingLevelSwitch _websocketLoggingLevelSwitch; + + private static readonly LoggingLevelSwitch _errorLogLevelSwitch; + + private static readonly LoggingLevelSwitch _fileLevelSwitch; + + public static LogEventLevel WebsocketMinimumLogLevel + { + get { return _websocketLoggingLevelSwitch.MinimumLevel; } + } + + private static readonly DebugWebsocketSink _websocketSink; + + public static DebugWebsocketSink WebsocketSink + { + get { return _websocketSink; } + } + + /// + /// Describes the folder location where a given program stores it's debug level memory. By default, the + /// file written will be named appNdebug where N is 1-10. + /// + public static string OldFilePathPrefix = @"\nvram\debug\"; + + /// + /// Describes the new folder location where a given program stores it's debug level memory. By default, the + /// file written will be named appNdebug where N is 1-10. + /// + public static string NewFilePathPrefix = @"\user\debug\"; + + /// + /// The name of the file containing the current debug settings. + /// + public static string FileName = string.Format(@"app{0}Debug.json", InitialParametersClass.ApplicationNumber); + + /// + /// Debug level to set for a given program. + /// + public static int Level { get; private set; } + + /// + /// When this is true, the configuration file will NOT be loaded until triggered by either a console command or a signal + /// + public static bool DoNotLoadConfigOnNextBoot { get; private set; } + + private static DebugContextCollection _contexts; + + private const int SaveTimeoutMs = 30000; + + public static bool IsRunningOnAppliance = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance; + + /// + /// Version for the currently loaded PepperDashCore dll + /// + public static string PepperDashCoreVersion { get; private set; } + + private static CTimer _saveTimer; + + /// + /// When true, the IncludedExcludedKeys dict will contain keys to include. + /// When false (default), IncludedExcludedKeys will contain keys to exclude. + /// + private static bool _excludeAllMode; + + //static bool ExcludeNoKeyMessages; + + private static readonly Dictionary IncludedExcludedKeys; + + private static readonly LoggerConfiguration _defaultLoggerConfiguration; + + private static LoggerConfiguration _loggerConfiguration; + + public static LoggerConfiguration LoggerConfiguration => _loggerConfiguration; + + static Debug() + { + CrestronDataStoreStatic.InitCrestronDataStore(); + + var defaultConsoleLevel = GetStoredLogEventLevel(LevelStoreKey); + + var defaultWebsocketLevel = GetStoredLogEventLevel(WebSocketLevelStoreKey); + + var defaultErrorLogLevel = GetStoredLogEventLevel(ErrorLogLevelStoreKey); + + var defaultFileLogLevel = GetStoredLogEventLevel(FileLevelStoreKey); + + _consoleLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultConsoleLevel); + + _websocketLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultWebsocketLevel); + + _errorLogLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultErrorLogLevel); + + _fileLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel); + + _websocketSink = new DebugWebsocketSink(new JsonFormatter(renderMessage: true)); + + var logFilePath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance ? + $@"{Directory.GetApplicationRootDirectory()}{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}debug{Path.DirectorySeparatorChar}app{InitialParametersClass.ApplicationNumber}{Path.DirectorySeparatorChar}global-log.log" : + $@"{Directory.GetApplicationRootDirectory()}{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}debug{Path.DirectorySeparatorChar}room{InitialParametersClass.RoomId}{Path.DirectorySeparatorChar}global-log.log"; + + CrestronConsole.PrintLine($"Saving log files to {logFilePath}"); + + var errorLogTemplate = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance + ? "{@t:fff}ms [{@l:u4}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}" + : "[{@t:yyyy-MM-dd HH:mm:ss.fff}][{@l:u4}][{App}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}"; + + _defaultLoggerConfiguration = new LoggerConfiguration() + .MinimumLevel.Verbose() + .Enrich.FromLogContext() + .Enrich.With(new CrestronEnricher()) + .WriteTo.Sink(new DebugConsoleSink(new ExpressionTemplate("[{@t:yyyy-MM-dd HH:mm:ss.fff}][{@l:u4}][{App}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}")), levelSwitch: _consoleLoggingLevelSwitch) + .WriteTo.Sink(_websocketSink, levelSwitch: _websocketLoggingLevelSwitch) + .WriteTo.Sink(new DebugErrorLogSink(new ExpressionTemplate(errorLogTemplate)), levelSwitch: _errorLogLevelSwitch) + .WriteTo.File(new RenderedCompactJsonFormatter(), logFilePath, + rollingInterval: RollingInterval.Day, + restrictedToMinimumLevel: LogEventLevel.Debug, + retainedFileCountLimit: CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance ? 30 : 60, + levelSwitch: _fileLevelSwitch + ); + + try + { + if (InitialParametersClass.NumberOfRemovableDrives > 0) + { + CrestronConsole.PrintLine("{0} RM Drive(s) Present. Initializing CrestronLogger", InitialParametersClass.NumberOfRemovableDrives); + _defaultLoggerConfiguration.WriteTo.Sink(new DebugCrestronLoggerSink()); + } + else + CrestronConsole.PrintLine("No RM Drive(s) Present. Not using Crestron Logger"); + } + catch (Exception e) + { + CrestronConsole.PrintLine("Initializing of CrestronLogger failed: {0}", e); + } + + // Instantiate the root logger + _loggerConfiguration = _defaultLoggerConfiguration; + + _logger = _loggerConfiguration.CreateLogger(); + // Get the assembly version and print it to console and the log + GetVersion(); + + string msg = $"[App {InitialParametersClass.ApplicationNumber}] Using PepperDash_Core v{PepperDashCoreVersion}"; + + if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) + { + msg = $"[Room {InitialParametersClass.RoomId}] Using PepperDash_Core v{PepperDashCoreVersion}"; + } + + CrestronConsole.PrintLine(msg); + + LogMessage(LogEventLevel.Information,msg); + + IncludedExcludedKeys = new Dictionary(); + + if (CrestronEnvironment.RuntimeEnvironment == eRuntimeEnvironment.SimplSharpPro) + { + // Add command to console + CrestronConsole.AddNewConsoleCommand(SetDoNotLoadOnNextBootFromConsole, "donotloadonnextboot", + "donotloadonnextboot:P [true/false]: Should the application load on next boot", ConsoleAccessLevelEnum.AccessOperator); + + CrestronConsole.AddNewConsoleCommand(SetDebugFromConsole, "appdebug", + "appdebug:P [0-5]: Sets the application's console debug message level", + ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(ShowDebugLog, "appdebuglog", + "appdebuglog:P [all] Use \"all\" for full log.", + ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(s => CrestronLogger.Clear(false), "appdebugclear", + "appdebugclear:P Clears the current custom log", + ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(SetDebugFilterFromConsole, "appdebugfilter", + "appdebugfilter [params]", ConsoleAccessLevelEnum.AccessOperator); + } + + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + + LoadMemory(); + + var context = _contexts.GetOrCreateItem("DEFAULT"); + Level = context.Level; + DoNotLoadConfigOnNextBoot = context.DoNotLoadOnNextBoot; + + if(DoNotLoadConfigOnNextBoot) + CrestronConsole.PrintLine(string.Format("Program {0} will not load config after next boot. Use console command go:{0} to load the config manually", InitialParametersClass.ApplicationNumber)); + + _consoleLoggingLevelSwitch.MinimumLevelChanged += (sender, args) => + { + Console(0, "Console debug level set to {0}", _consoleLoggingLevelSwitch.MinimumLevel); + }; + } + + public static void UpdateLoggerConfiguration(LoggerConfiguration config) + { + _loggerConfiguration = config; + + _logger = config.CreateLogger(); + } + + public static void ResetLoggerConfiguration() + { + _loggerConfiguration = _defaultLoggerConfiguration; + + _logger = _loggerConfiguration.CreateLogger(); + } + + private static LogEventLevel GetStoredLogEventLevel(string levelStoreKey) + { + try + { + var result = CrestronDataStoreStatic.GetLocalIntValue(levelStoreKey, out int logLevel); + + if (result != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) + { + CrestronConsole.Print($"Unable to retrieve stored log level for {levelStoreKey}.\r\nError: {result}.\r\nSetting level to {LogEventLevel.Information}\r\n"); + return LogEventLevel.Information; + } + + if(logLevel < 0 || logLevel > 5) + { + CrestronConsole.PrintLine($"Stored Log level not valid for {levelStoreKey}: {logLevel}. Setting level to {LogEventLevel.Information}"); + return LogEventLevel.Information; + } + + return (LogEventLevel)logLevel; + } catch (Exception ex) + { + CrestronConsole.PrintLine($"Exception retrieving log level for {levelStoreKey}: {ex.Message}"); + return LogEventLevel.Information; + } + } + + private static void GetVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var ver = + assembly + .GetCustomAttributes(typeof (AssemblyInformationalVersionAttribute), false); + + if (ver != null && ver.Length > 0) + { + if (ver[0] is AssemblyInformationalVersionAttribute verAttribute) + { + PepperDashCoreVersion = verAttribute.InformationalVersion; + } + } + else + { + var version = assembly.GetName().Version; + PepperDashCoreVersion = version.ToString(); + } + } + + /// + /// Used to save memory when shutting down + /// + /// + static void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + + if (programEventType == eProgramStatusEventType.Stopping) + { + Log.CloseAndFlush(); + + if (_saveTimer != null) + { + _saveTimer.Stop(); + _saveTimer = null; + } + Console(0, "Saving debug settings"); + SaveMemory(); + } + } + + /// + /// Callback for console command + /// + /// + public static void SetDebugFromConsole(string levelString) + { + try + { + if (levelString.Trim() == "?") + { + CrestronConsole.ConsoleCommandResponse( + $@"Used to set the minimum level of debug messages to be printed to the console: +{_logLevels[0]} = 0 +{_logLevels[1]} = 1 +{_logLevels[2]} = 2 +{_logLevels[3]} = 3 +{_logLevels[4]} = 4 +{_logLevels[5]} = 5"); + return; + } + + if (string.IsNullOrEmpty(levelString.Trim())) + { + CrestronConsole.ConsoleCommandResponse("AppDebug level = {0}", _consoleLoggingLevelSwitch.MinimumLevel); + return; + } + + if(int.TryParse(levelString, out var levelInt)) + { + if(levelInt < 0 || levelInt > 5) + { + CrestronConsole.ConsoleCommandResponse($"Error: Unable to parse {levelString} to valid log level. If using a number, value must be between 0-5"); + return; + } + SetDebugLevel((uint) levelInt); + return; + } + + if(Enum.TryParse(levelString, out var levelEnum)) + { + SetDebugLevel(levelEnum); + return; + } + + CrestronConsole.ConsoleCommandResponse($"Error: Unable to parse {levelString} to valid log level"); + } + catch + { + CrestronConsole.ConsoleCommandResponse("Usage: appdebug:P [0-5]"); + } + } + + /// + /// Sets the debug level + /// + /// Valid values 0-5 + public static void SetDebugLevel(uint level) + { + if(!_logLevels.TryGetValue(level, out var logLevel)) + { + logLevel = LogEventLevel.Information; + + CrestronConsole.ConsoleCommandResponse($"{level} not valid. Setting level to {logLevel}"); + + SetDebugLevel(logLevel); + } + + SetDebugLevel(logLevel); + } + + public static void SetDebugLevel(LogEventLevel level) + { + _consoleLoggingLevelSwitch.MinimumLevel = level; + + CrestronConsole.ConsoleCommandResponse("[Application {0}], Debug level set to {1}\r\n", + InitialParametersClass.ApplicationNumber, _consoleLoggingLevelSwitch.MinimumLevel); + + CrestronConsole.ConsoleCommandResponse($"Storing level {level}:{(int) level}"); + + var err = CrestronDataStoreStatic.SetLocalIntValue(LevelStoreKey, (int) level); + + CrestronConsole.ConsoleCommandResponse($"Store result: {err}:{(int)level}"); + + if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) + CrestronConsole.PrintLine($"Error saving console debug level setting: {err}"); + } + + public static void SetWebSocketMinimumDebugLevel(LogEventLevel level) + { + _websocketLoggingLevelSwitch.MinimumLevel = level; + + var err = CrestronDataStoreStatic.SetLocalUintValue(WebSocketLevelStoreKey, (uint) level); + + if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) + LogMessage(LogEventLevel.Information, "Error saving websocket debug level setting: {erro}", err); + + LogMessage(LogEventLevel.Information, "Websocket debug level set to {0}", _websocketLoggingLevelSwitch.MinimumLevel); + } + + public static void SetErrorLogMinimumDebugLevel(LogEventLevel level) + { + _errorLogLevelSwitch.MinimumLevel = level; + + var err = CrestronDataStoreStatic.SetLocalUintValue(ErrorLogLevelStoreKey, (uint)level); + + if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) + LogMessage(LogEventLevel.Information, "Error saving Error Log debug level setting: {error}", err); + + LogMessage(LogEventLevel.Information, "Error log debug level set to {0}", _websocketLoggingLevelSwitch.MinimumLevel); + } + + public static void SetFileMinimumDebugLevel(LogEventLevel level) + { + _errorLogLevelSwitch.MinimumLevel = level; + + var err = CrestronDataStoreStatic.SetLocalUintValue(ErrorLogLevelStoreKey, (uint)level); + + if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) + LogMessage(LogEventLevel.Information, "Error saving File debug level setting: {error}", err); + + LogMessage(LogEventLevel.Information, "File debug level set to {0}", _websocketLoggingLevelSwitch.MinimumLevel); + } + + /// + /// Callback for console command + /// + /// + public static void SetDoNotLoadOnNextBootFromConsole(string stateString) + { + try + { + if (string.IsNullOrEmpty(stateString.Trim())) + { + CrestronConsole.ConsoleCommandResponse("DoNotLoadOnNextBoot = {0}", DoNotLoadConfigOnNextBoot); + return; + } + + SetDoNotLoadConfigOnNextBoot(bool.Parse(stateString)); + } + catch + { + CrestronConsole.ConsoleCommandResponse("Usage: donotloadonnextboot:P [true/false]"); + } + } + + /// + /// Callback for console command + /// + /// + public static void SetDebugFilterFromConsole(string items) + { + var str = items.Trim(); + if (str == "?") + { + CrestronConsole.ConsoleCommandResponse("Usage:\r APPDEBUGFILTER key1 key2 key3....\r " + + "+all: at beginning puts filter into 'default include' mode\r" + + " All keys that follow will be excluded from output.\r" + + "-all: at beginning puts filter into 'default exclude all' mode.\r" + + " All keys that follow will be the only keys that are shown\r" + + "+nokey: Enables messages with no key (default)\r" + + "-nokey: Disables messages with no key.\r" + + "(nokey settings are independent of all other settings)"); + return; + } + var keys = Regex.Split(str, @"\s*"); + foreach (var keyToken in keys) + { + var lkey = keyToken.ToLower(); + if (lkey == "+all") + { + IncludedExcludedKeys.Clear(); + _excludeAllMode = false; + } + else if (lkey == "-all") + { + IncludedExcludedKeys.Clear(); + _excludeAllMode = true; + } + //else if (lkey == "+nokey") + //{ + // ExcludeNoKeyMessages = false; + //} + //else if (lkey == "-nokey") + //{ + // ExcludeNoKeyMessages = true; + //} + else + { + string key; + if (lkey.StartsWith("-")) + { + key = lkey.Substring(1); + // if in exclude all mode, we need to remove this from the inclusions + if (_excludeAllMode) + { + if (IncludedExcludedKeys.ContainsKey(key)) + IncludedExcludedKeys.Remove(key); + } + // otherwise include all mode, add to the exclusions + else + { + IncludedExcludedKeys[key] = new object(); + } + } + else if (lkey.StartsWith("+")) + { + key = lkey.Substring(1); + // if in exclude all mode, we need to add this as inclusion + if (_excludeAllMode) + { + + IncludedExcludedKeys[key] = new object(); + } + // otherwise include all mode, remove this from exclusions + else + { + if (IncludedExcludedKeys.ContainsKey(key)) + IncludedExcludedKeys.Remove(key); + } + } + } + } + } + + + + + /// + /// sets the settings for a device or creates a new entry + /// + /// + /// + /// + public static void SetDeviceDebugSettings(string deviceKey, object settings) + { + _contexts.SetDebugSettingsForKey(deviceKey, settings); + SaveMemoryOnTimeout(); + } + + /// + /// Gets the device settings for a device by key or returns null + /// + /// + /// + public static object GetDeviceDebugSettingsForKey(string deviceKey) + { + return _contexts.GetDebugSettingsForKey(deviceKey); + } + + /// + /// Sets the flag to prevent application starting on next boot + /// + /// + public static void SetDoNotLoadConfigOnNextBoot(bool state) + { + DoNotLoadConfigOnNextBoot = state; + _contexts.GetOrCreateItem("DEFAULT").DoNotLoadOnNextBoot = state; + SaveMemoryOnTimeout(); + + CrestronConsole.ConsoleCommandResponse("[Application {0}], Do Not Load Config on Next Boot set to {1}", + InitialParametersClass.ApplicationNumber, DoNotLoadConfigOnNextBoot); + } + + /// + /// + /// + public static void ShowDebugLog(string s) + { + var loglist = CrestronLogger.PrintTheLog(s.ToLower() == "all"); + foreach (var l in loglist) + CrestronConsole.ConsoleCommandResponse(l + CrestronEnvironment.NewLine); + } + + /// + /// Log an Exception using Serilog's default Exception logging mechanism + /// + /// Exception to log + /// Message template + /// Optional IKeyed device. If provided, the Key of the device will be added to the log message + /// Args to put into message template + public static void LogMessage(Exception ex, string message, IKeyed device = null, params object[] args) + { + using (LogContext.PushProperty("Key", device?.Key)) + { + _logger.Error(ex, message, args); + } + } + + /// + /// Log a message + /// + /// Level to log at + /// Message template + /// Optional IKeyed device. If provided, the Key of the device will be added to the log message + /// Args to put into message template + public static void LogMessage(LogEventLevel level, string message, IKeyed device=null, params object[] args) + { + using (LogContext.PushProperty("Key", device?.Key)) + { + _logger.Write(level, message, args); + } + } + + public static void LogMessage(LogEventLevel level, string message, params object[] args) + { + LogMessage(level, message, null, args); + } + + public static void LogMessage(LogEventLevel level, IKeyed keyed, string message, params object[] args) + { + LogMessage(level, message, keyed, args); + } + + + private static void LogMessage(uint level, string format, params object[] items) + { + if (!_logLevels.ContainsKey(level)) return; + + var logLevel = _logLevels[level]; + + LogMessage(logLevel, format, items); + } + + private static void LogMessage(uint level, IKeyed keyed, string format, params object[] items) + { + if (!_logLevels.ContainsKey(level)) return; + + var logLevel = _logLevels[level]; + + LogMessage(logLevel, keyed, format, items); + } + + + /// + /// Prints message to console if current debug level is equal to or higher than the level of this message. + /// Uses CrestronConsole.PrintLine. + /// + /// + /// Console format string + /// Object parameters + [Obsolete("Use LogMessage methods")] + public static void Console(uint level, string format, params object[] items) + { + + LogMessage(level, format, items); + + //if (IsRunningOnAppliance) + //{ + // CrestronConsole.PrintLine("[{0}]App {1} Lvl {2}:{3}", DateTime.Now.ToString("HH:mm:ss.fff"), + // InitialParametersClass.ApplicationNumber, + // level, + // string.Format(format, items)); + //} + } + + /// + /// Logs to Console when at-level, and all messages to error log, including device key + /// + [Obsolete("Use LogMessage methods")] + public static void Console(uint level, IKeyed dev, string format, params object[] items) + { + LogMessage(level, dev, format, items); + + //if (Level >= level) + // Console(level, "[{0}] {1}", dev.Key, message); + } + + /// + /// Prints message to console if current debug level is equal to or higher than the level of this message. Always sends message to Error Log. + /// Uses CrestronConsole.PrintLine. + /// + [Obsolete("Use LogMessage methods")] + public static void Console(uint level, IKeyed dev, ErrorLogLevel errorLogLevel, + string format, params object[] items) + { + LogMessage(level, dev, format, items); + } + + /// + /// Logs to Console when at-level, and all messages to error log + /// + [Obsolete("Use LogMessage methods")] + public static void Console(uint level, ErrorLogLevel errorLogLevel, + string format, params object[] items) + { + LogMessage(level, format, items); + } + + /// + /// Logs to both console and the custom user log (not the built-in error log). If appdebug level is set at + /// or above the level provided, then the output will be written to both console and the log. Otherwise + /// it will only be written to the log. + /// + [Obsolete("Use LogMessage methods")] + public static void ConsoleWithLog(uint level, string format, params object[] items) + { + LogMessage(level, format, items); + + // var str = string.Format(format, items); + //if (Level >= level) + // CrestronConsole.PrintLine("App {0}:{1}", InitialParametersClass.ApplicationNumber, str); + // CrestronLogger.WriteToLog(str, level); + } + + /// + /// Logs to both console and the custom user log (not the built-in error log). If appdebug level is set at + /// or above the level provided, then the output will be written to both console and the log. Otherwise + /// it will only be written to the log. + /// + [Obsolete("Use LogMessage methods")] + public static void ConsoleWithLog(uint level, IKeyed dev, string format, params object[] items) + { + LogMessage(level, dev, format, items); + + // var str = string.Format(format, items); + // CrestronLogger.WriteToLog(string.Format("[{0}] {1}", dev.Key, str), level); + } + + /// + /// Prints to log and error log + /// + /// + /// + [Obsolete("Use LogMessage methods")] + public static void LogError(ErrorLogLevel errorLogLevel, string str) + { + switch (errorLogLevel) + { + case ErrorLogLevel.Error: + LogMessage(LogEventLevel.Error, str); + break; + case ErrorLogLevel.Warning: + LogMessage(LogEventLevel.Warning, str); + break; + case ErrorLogLevel.Notice: + LogMessage(LogEventLevel.Information, str); + break; + } + } + + /// + /// Writes the memory object after timeout + /// + static void SaveMemoryOnTimeout() + { + Console(0, "Saving debug settings"); + if (_saveTimer == null) + _saveTimer = new CTimer(o => + { + _saveTimer = null; + SaveMemory(); + }, SaveTimeoutMs); + else + _saveTimer.Reset(SaveTimeoutMs); + } + + /// + /// Writes the memory - use SaveMemoryOnTimeout + /// + static void SaveMemory() + { + //var dir = @"\NVRAM\debug"; + //if (!Directory.Exists(dir)) + // Directory.Create(dir); + + var fileName = GetMemoryFileName(); + + Console(0, ErrorLogLevel.Notice, "Loading debug settings file from {0}", fileName); + + using (var sw = new StreamWriter(fileName)) + { + var json = JsonConvert.SerializeObject(_contexts); + sw.Write(json); + sw.Flush(); + } + } + + /// + /// + /// + static void LoadMemory() + { + var file = GetMemoryFileName(); + if (File.Exists(file)) + { + using (var sr = new StreamReader(file)) + { + var json = sr.ReadToEnd(); + _contexts = JsonConvert.DeserializeObject(json); + + if (_contexts != null) + { + Console(1, "Debug memory restored from file"); + return; + } + } + } + + _contexts = new DebugContextCollection(); + } + + /// + /// Helper to get the file path for this app's debug memory + /// + static string GetMemoryFileName() + { + if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance) + { + // CheckForMigration(); + return string.Format(@"\user\debugSettings\program{0}", InitialParametersClass.ApplicationNumber); + } + + return string.Format("{0}{1}user{1}debugSettings{1}{2}.json",Directory.GetApplicationRootDirectory(), Path.DirectorySeparatorChar, InitialParametersClass.RoomId); + } + + private static void CheckForMigration() + { + var oldFilePath = String.Format(@"\nvram\debugSettings\program{0}", InitialParametersClass.ApplicationNumber); + var newFilePath = String.Format(@"\user\debugSettings\program{0}", InitialParametersClass.ApplicationNumber); + + //check for file at old path + if (!File.Exists(oldFilePath)) + { + Console(0, ErrorLogLevel.Notice, + String.Format( + @"Debug settings file migration not necessary. Using file at \user\debugSettings\program{0}", + InitialParametersClass.ApplicationNumber)); + + return; + } + + //create the new directory if it doesn't already exist + if (!Directory.Exists(@"\user\debugSettings")) + { + Directory.CreateDirectory(@"\user\debugSettings"); + } + + Console(0, ErrorLogLevel.Notice, + String.Format( + @"File found at \nvram\debugSettings\program{0}. Migrating to \user\debugSettings\program{0}", InitialParametersClass.ApplicationNumber)); + + //Copy file from old path to new path, then delete it. This will overwrite the existing file + File.Copy(oldFilePath, newFilePath, true); + File.Delete(oldFilePath); + + //Check if the old directory is empty, then delete it if it is + if (Directory.GetFiles(@"\nvram\debugSettings").Length > 0) + { + return; + } + + Directory.Delete(@"\nvram\debugSettings"); + } + + /// + /// Error level to for message to be logged at + /// + public enum ErrorLogLevel + { + /// + /// Error + /// + Error, + /// + /// Warning + /// + Warning, + /// + /// Notice + /// + Notice, + /// + /// None + /// + None, + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Logging/DebugConsoleSink.cs b/src/PepperDash.Core/Logging/DebugConsoleSink.cs new file mode 100644 index 00000000..a6c7f893 --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugConsoleSink.cs @@ -0,0 +1,55 @@ +using Crestron.SimplSharp; +using Serilog.Configuration; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Json; +using System.IO; +using System.Text; + + +namespace PepperDash.Core +{ + public class DebugConsoleSink : ILogEventSink + { + private readonly ITextFormatter _textFormatter; + + public void Emit(LogEvent logEvent) + { + if (!Debug.IsRunningOnAppliance) return; + + /*string message = $"[{logEvent.Timestamp}][{logEvent.Level}][App {InitialParametersClass.ApplicationNumber}]{logEvent.RenderMessage()}"; + + if(logEvent.Properties.TryGetValue("Key",out var value) && value is ScalarValue sv && sv.Value is string rawValue) + { + message = $"[{logEvent.Timestamp}][{logEvent.Level}][App {InitialParametersClass.ApplicationNumber}][{rawValue,3}]: {logEvent.RenderMessage()}"; + }*/ + + var buffer = new StringWriter(new StringBuilder(256)); + + _textFormatter.Format(logEvent, buffer); + + var message = buffer.ToString(); + + CrestronConsole.PrintLine(message); + } + + public DebugConsoleSink(ITextFormatter formatProvider ) + { + _textFormatter = formatProvider ?? new JsonFormatter(); + } + + } + + public static class DebugConsoleSinkExtensions + { + public static LoggerConfiguration DebugConsoleSink( + this LoggerSinkConfiguration loggerConfiguration, + ITextFormatter formatProvider = null) + { + return loggerConfiguration.Sink(new DebugConsoleSink(formatProvider)); + } + } + +} diff --git a/src/PepperDash.Core/Logging/DebugContext.cs b/src/PepperDash.Core/Logging/DebugContext.cs new file mode 100644 index 00000000..54c87414 --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugContext.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Newtonsoft.Json; + + +namespace PepperDash.Core +{ + /// + /// Represents a debugging context + /// + public class DebugContext + { + /// + /// Describes the folder location where a given program stores it's debug level memory. By default, the + /// file written will be named appNdebug where N is 1-10. + /// + public string Key { get; private set; } + + ///// + ///// The name of the file containing the current debug settings. + ///// + //string FileName = string.Format(@"\nvram\debug\app{0}Debug.json", InitialParametersClass.ApplicationNumber); + + DebugContextSaveData SaveData; + + int SaveTimeoutMs = 30000; + + CTimer SaveTimer; + + + static List Contexts = new List(); + + /// + /// Creates or gets a debug context + /// + /// + /// + public static DebugContext GetDebugContext(string key) + { + var context = Contexts.FirstOrDefault(c => c.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (context == null) + { + context = new DebugContext(key); + Contexts.Add(context); + } + return context; + } + + /// + /// Do not use. For S+ access. + /// + public DebugContext() { } + + DebugContext(string key) + { + Key = key; + if (CrestronEnvironment.RuntimeEnvironment == eRuntimeEnvironment.SimplSharpPro) + { + // Add command to console + CrestronConsole.AddNewConsoleCommand(SetDebugFromConsole, "appdebug", + "appdebug:P [0-2]: Sets the application's console debug message level", + ConsoleAccessLevelEnum.AccessOperator); + } + + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + + LoadMemory(); + } + + /// + /// Used to save memory when shutting down + /// + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + if (SaveTimer != null) + { + SaveTimer.Stop(); + SaveTimer = null; + } + Console(0, "Saving debug settings"); + SaveMemory(); + } + } + + /// + /// Callback for console command + /// + /// + public void SetDebugFromConsole(string levelString) + { + try + { + if (string.IsNullOrEmpty(levelString.Trim())) + { + CrestronConsole.ConsoleCommandResponse("AppDebug level = {0}", SaveData.Level); + return; + } + + SetDebugLevel(Convert.ToInt32(levelString)); + } + catch + { + CrestronConsole.PrintLine("Usage: appdebug:P [0-2]"); + } + } + + /// + /// Sets the debug level + /// + /// Valid values 0 (no debug), 1 (critical), 2 (all messages) + public void SetDebugLevel(int level) + { + if (level <= 2) + { + SaveData.Level = level; + SaveMemoryOnTimeout(); + + CrestronConsole.PrintLine("[Application {0}], Debug level set to {1}", + InitialParametersClass.ApplicationNumber, SaveData.Level); + } + } + + /// + /// Prints message to console if current debug level is equal to or higher than the level of this message. + /// Uses CrestronConsole.PrintLine. + /// + /// + /// Console format string + /// Object parameters + public void Console(uint level, string format, params object[] items) + { + if (SaveData.Level >= level) + CrestronConsole.PrintLine("App {0}:{1}", InitialParametersClass.ApplicationNumber, + string.Format(format, items)); + } + + /// + /// Appends a device Key to the beginning of a message + /// + public void Console(uint level, IKeyed dev, string format, params object[] items) + { + if (SaveData.Level >= level) + Console(level, "[{0}] {1}", dev.Key, string.Format(format, items)); + } + + /// + /// + /// + /// + /// + /// + /// + /// + public void Console(uint level, IKeyed dev, Debug.ErrorLogLevel errorLogLevel, + string format, params object[] items) + { + if (SaveData.Level >= level) + { + var str = string.Format("[{0}] {1}", dev.Key, string.Format(format, items)); + Console(level, str); + LogError(errorLogLevel, str); + } + } + + /// + /// + /// + /// + /// + /// + /// + public void Console(uint level, Debug.ErrorLogLevel errorLogLevel, + string format, params object[] items) + { + if (SaveData.Level >= level) + { + var str = string.Format(format, items); + Console(level, str); + LogError(errorLogLevel, str); + } + } + + /// + /// + /// + /// + /// + public void LogError(Debug.ErrorLogLevel errorLogLevel, string str) + { + string msg = string.Format("App {0}:{1}", InitialParametersClass.ApplicationNumber, str); + switch (errorLogLevel) + { + case Debug.ErrorLogLevel.Error: + ErrorLog.Error(msg); + break; + case Debug.ErrorLogLevel.Warning: + ErrorLog.Warn(msg); + break; + case Debug.ErrorLogLevel.Notice: + ErrorLog.Notice(msg); + break; + } + } + + /// + /// Writes the memory object after timeout + /// + void SaveMemoryOnTimeout() + { + if (SaveTimer == null) + SaveTimer = new CTimer(o => + { + SaveTimer = null; + SaveMemory(); + }, SaveTimeoutMs); + else + SaveTimer.Reset(SaveTimeoutMs); + } + + /// + /// Writes the memory - use SaveMemoryOnTimeout + /// + void SaveMemory() + { + using (StreamWriter sw = new StreamWriter(GetMemoryFileName())) + { + var json = JsonConvert.SerializeObject(SaveData); + sw.Write(json); + sw.Flush(); + } + } + + /// + /// + /// + void LoadMemory() + { + var file = GetMemoryFileName(); + if (File.Exists(file)) + { + using (StreamReader sr = new StreamReader(file)) + { + var data = JsonConvert.DeserializeObject(sr.ReadToEnd()); + if (data != null) + { + SaveData = data; + Debug.Console(1, "Debug memory restored from file"); + return; + } + else + SaveData = new DebugContextSaveData(); + } + } + } + + /// + /// Helper to get the file path for this app's debug memory + /// + string GetMemoryFileName() + { + return string.Format(@"\NVRAM\debugSettings\program{0}-{1}", InitialParametersClass.ApplicationNumber, Key); + } + } + + /// + /// + /// + public class DebugContextSaveData + { + /// + /// + /// + public int Level { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Logging/DebugCrestronLoggerSink.cs b/src/PepperDash.Core/Logging/DebugCrestronLoggerSink.cs new file mode 100644 index 00000000..0814453b --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugCrestronLoggerSink.cs @@ -0,0 +1,29 @@ +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronLogger; +using Serilog.Core; +using Serilog.Events; + +namespace PepperDash.Core.Logging +{ + public class DebugCrestronLoggerSink : ILogEventSink + { + public void Emit(LogEvent logEvent) + { + if (!Debug.IsRunningOnAppliance) return; + + string message = $"[{logEvent.Timestamp}][{logEvent.Level}][App {InitialParametersClass.ApplicationNumber}]{logEvent.RenderMessage()}"; + + if (logEvent.Properties.TryGetValue("Key", out var value) && value is ScalarValue sv && sv.Value is string rawValue) + { + message = $"[{logEvent.Timestamp}][{logEvent.Level}][App {InitialParametersClass.ApplicationNumber}][{rawValue}]: {logEvent.RenderMessage()}"; + } + + CrestronLogger.WriteToLog(message, (uint)logEvent.Level); + } + + public DebugCrestronLoggerSink() + { + CrestronLogger.Initialize(1, LoggerModeEnum.RM); + } + } +} diff --git a/src/PepperDash.Core/Logging/DebugErrorLogSink.cs b/src/PepperDash.Core/Logging/DebugErrorLogSink.cs new file mode 100644 index 00000000..3885982b --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugErrorLogSink.cs @@ -0,0 +1,65 @@ +using Crestron.SimplSharp; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PepperDash.Core.Logging +{ + public class DebugErrorLogSink : ILogEventSink + { + private ITextFormatter _formatter; + + private Dictionary> _errorLogMap = new Dictionary> + { + { LogEventLevel.Verbose, (msg) => ErrorLog.Notice(msg) }, + {LogEventLevel.Debug, (msg) => ErrorLog.Notice(msg) }, + {LogEventLevel.Information, (msg) => ErrorLog.Notice(msg) }, + {LogEventLevel.Warning, (msg) => ErrorLog.Warn(msg) }, + {LogEventLevel.Error, (msg) => ErrorLog.Error(msg) }, + {LogEventLevel.Fatal, (msg) => ErrorLog.Error(msg) } + }; + public void Emit(LogEvent logEvent) + { + string message; + + if (_formatter == null) + { + var programId = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance + ? $"App {InitialParametersClass.ApplicationNumber}" + : $"Room {InitialParametersClass.RoomId}"; + + message = $"[{logEvent.Timestamp}][{logEvent.Level}][{programId}]{logEvent.RenderMessage()}"; + + if (logEvent.Properties.TryGetValue("Key", out var value) && value is ScalarValue sv && sv.Value is string rawValue) + { + message = $"[{logEvent.Timestamp}][{logEvent.Level}][{programId}][{rawValue}]: {logEvent.RenderMessage()}"; + } + } else + { + var buffer = new StringWriter(new StringBuilder(256)); + + _formatter.Format(logEvent, buffer); + + message = buffer.ToString(); + } + + if(!_errorLogMap.TryGetValue(logEvent.Level, out var handler)) + { + return; + } + + handler(message); + } + + public DebugErrorLogSink(ITextFormatter formatter = null) + { + _formatter = formatter; + } + } +} diff --git a/src/PepperDash.Core/Logging/DebugExtensions.cs b/src/PepperDash.Core/Logging/DebugExtensions.cs new file mode 100644 index 00000000..68e9868b --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugExtensions.cs @@ -0,0 +1,44 @@ +using Serilog; +using Serilog.Events; +using System; +using Log = PepperDash.Core.Debug; + +namespace PepperDash.Core.Logging +{ + public static class DebugExtensions + { + public static void LogException(this IKeyed device, Exception ex, string message, params object[] args) + { + Log.LogMessage(ex, message, device, args); + } + public static void LogVerbose(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Verbose, device, message, args); + } + + public static void LogDebug(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Debug, device, message, args); + } + + public static void LogInformation(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Information, device, message, args); + } + + public static void LogWarning(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Warning, device, message, args); + } + + public static void LogError(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Error, device, message, args); + } + + public static void LogFatal(this IKeyed device, string message, params object[] args) + { + Log.LogMessage(LogEventLevel.Fatal, device, message, args); + } + } +} diff --git a/src/PepperDash.Core/Logging/DebugMemory.cs b/src/PepperDash.Core/Logging/DebugMemory.cs new file mode 100644 index 00000000..a5737af9 --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugMemory.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using Crestron.SimplSharp; +using Newtonsoft.Json; + +namespace PepperDash.Core.Logging +{ + /// + /// Class to persist current Debug settings across program restarts + /// + public class DebugContextCollection + { + /// + /// To prevent threading issues with the DeviceDebugSettings collection + /// + private readonly CCriticalSection _deviceDebugSettingsLock; + + [JsonProperty("items")] private readonly Dictionary _items; + + /// + /// Collection of the debug settings for each device where the dictionary key is the device key + /// + [JsonProperty("deviceDebugSettings")] + private Dictionary DeviceDebugSettings { get; set; } + + + /// + /// Default constructor + /// + public DebugContextCollection() + { + _deviceDebugSettingsLock = new CCriticalSection(); + DeviceDebugSettings = new Dictionary(); + _items = new Dictionary(); + } + + /// + /// Sets the level of a given context item, and adds that item if it does not + /// exist + /// + /// + /// + public void SetLevel(string contextKey, int level) + { + if (level < 0 || level > 2) + return; + GetOrCreateItem(contextKey).Level = level; + } + + /// + /// Gets a level or creates it if not existing + /// + /// + /// + public DebugContextItem GetOrCreateItem(string contextKey) + { + if (!_items.ContainsKey(contextKey)) + _items[contextKey] = new DebugContextItem { Level = 0 }; + return _items[contextKey]; + } + + + /// + /// sets the settings for a device or creates a new entry + /// + /// + /// + /// + public void SetDebugSettingsForKey(string deviceKey, object settings) + { + try + { + _deviceDebugSettingsLock.Enter(); + + if (DeviceDebugSettings.ContainsKey(deviceKey)) + { + DeviceDebugSettings[deviceKey] = settings; + } + else + DeviceDebugSettings.Add(deviceKey, settings); + } + finally + { + _deviceDebugSettingsLock.Leave(); + } + } + + /// + /// Gets the device settings for a device by key or returns null + /// + /// + /// + public object GetDebugSettingsForKey(string deviceKey) + { + return DeviceDebugSettings[deviceKey]; + } + } + + /// + /// Contains information about + /// + public class DebugContextItem + { + /// + /// The level of debug messages to print + /// + [JsonProperty("level")] + public int Level { get; set; } + + /// + /// Property to tell the program not to intitialize when it boots, if desired + /// + [JsonProperty("doNotLoadOnNextBoot")] + public bool DoNotLoadOnNextBoot { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs new file mode 100644 index 00000000..d818977e --- /dev/null +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Configuration; +using WebSocketSharp.Server; +using Crestron.SimplSharp; +using WebSocketSharp; +using System.Security.Authentication; +using WebSocketSharp.Net; +using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using System.IO; +using Org.BouncyCastle.Asn1.X509; +using Serilog.Formatting; +using Newtonsoft.Json.Linq; +using Serilog.Formatting.Json; + +namespace PepperDash.Core +{ + public class DebugWebsocketSink : ILogEventSink + { + private HttpServer _httpsServer; + + private string _path = "/debug/join/"; + private const string _certificateName = "selfCres"; + private const string _certificatePassword = "cres12345"; + + public int Port + { get + { + + if(_httpsServer == null) return 0; + return _httpsServer.Port; + } + } + + public string Url + { + get + { + if (_httpsServer == null) return ""; + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; + } + } + + public bool IsRunning { get => _httpsServer?.IsListening ?? false; } + + + private readonly ITextFormatter _textFormatter; + + public DebugWebsocketSink(ITextFormatter formatProvider) + { + + _textFormatter = formatProvider ?? new JsonFormatter(); + + if (!File.Exists($"\\user\\{_certificateName}.pfx")) + CreateCert(null); + + CrestronEnvironment.ProgramStatusEventHandler += type => + { + if (type == eProgramStatusEventType.Stopping) + { + StopServer(); + } + }; + } + + private void CreateCert(string[] args) + { + try + { + //Debug.Console(0,"CreateCert Creating Utility"); + CrestronConsole.PrintLine("CreateCert Creating Utility"); + //var utility = new CertificateUtility(); + var utility = new BouncyCertificate(); + //Debug.Console(0, "CreateCert Calling CreateCert"); + CrestronConsole.PrintLine("CreateCert Calling CreateCert"); + //utility.CreateCert(); + var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); + var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); + var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); + + //Debug.Console(0, "DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress); + CrestronConsole.PrintLine(string.Format("DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); + + var certificate = utility.CreateSelfSignedCertificate(string.Format("CN={0}.{1}", hostName, domainName), new[] { string.Format("{0}.{1}", hostName, domainName), ipAddress }, new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }); + //Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested + //Debug.Print($"CreateCert Storing Certificate To My.LocalMachine"); + //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); + //Debug.Console(0, "CreateCert Saving Cert to \\user\\"); + CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\"); + utility.CertificatePassword = _certificatePassword; + utility.WriteCertificate(certificate, @"\user\", _certificateName); + //Debug.Console(0, "CreateCert Ending CreateCert"); + CrestronConsole.PrintLine("CreateCert Ending CreateCert"); + } + catch (Exception ex) + { + //Debug.Console(0, "WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace); + CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace)); + } + } + + public void Emit(LogEvent logEvent) + { + if (_httpsServer == null || !_httpsServer.IsListening) return; + + var sw = new StringWriter(); + _textFormatter.Format(logEvent, sw); + + _httpsServer.WebSocketServices.Broadcast(sw.ToString()); + + } + + public void StartServerAndSetPort(int port) + { + Debug.Console(0, "Starting Websocket Server on port: {0}", port); + + + Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword); + } + + private void Start(int port, string certPath = "", string certPassword = "") + { + try + { + _httpsServer = new HttpServer(port, true); + + + if (!string.IsNullOrWhiteSpace(certPath)) + { + Debug.Console(0, "Assigning SSL Configuration"); + _httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword)) + { + ClientCertificateRequired = false, + CheckCertificateRevocation = false, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, + //this is just to test, you might want to actually validate + ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered"); + return true; + } + }; + } + Debug.Console(0, "Adding Debug Client Service"); + _httpsServer.AddWebSocketService(_path); + Debug.Console(0, "Assigning Log Info"); + _httpsServer.Log.Level = LogLevel.Trace; + _httpsServer.Log.Output = (d, s) => + { + uint level; + + switch(d.Level) + { + case WebSocketSharp.LogLevel.Fatal: + level = 3; + break; + case WebSocketSharp.LogLevel.Error: + level = 2; + break; + case WebSocketSharp.LogLevel.Warn: + level = 1; + break; + case WebSocketSharp.LogLevel.Info: + level = 0; + break; + case WebSocketSharp.LogLevel.Debug: + level = 4; + break; + case WebSocketSharp.LogLevel.Trace: + level = 5; + break; + default: + level = 4; + break; + } + + Debug.Console(level, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s); + }; + Debug.Console(0, "Starting"); + + _httpsServer.Start(); + Debug.Console(0, "Ready"); + } + catch (Exception ex) + { + Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); + } + } + + public void StopServer() + { + Debug.Console(0, "Stopping Websocket Server"); + _httpsServer?.Stop(); + + _httpsServer = null; + } + } + + public static class DebugWebsocketSinkExtensions + { + public static LoggerConfiguration DebugWebsocketSink( + this LoggerSinkConfiguration loggerConfiguration, + ITextFormatter formatProvider = null) + { + return loggerConfiguration.Sink(new DebugWebsocketSink(formatProvider)); + } + } + + public class DebugClient : WebSocketBehavior + { + private DateTime _connectionTime; + + public TimeSpan ConnectedDuration + { + get + { + if (Context.WebSocket.IsAlive) + { + return DateTime.Now - _connectionTime; + } + else + { + return new TimeSpan(0); + } + } + } + + public DebugClient() + { + Debug.Console(0, "DebugClient Created"); + } + + protected override void OnOpen() + { + base.OnOpen(); + + var url = Context.WebSocket.Url; + Debug.Console(0, Debug.ErrorLogLevel.Notice, "New WebSocket Connection from: {0}", url); + + _connectionTime = DateTime.Now; + } + + protected override void OnMessage(MessageEventArgs e) + { + base.OnMessage(e); + + Debug.Console(0, "WebSocket UiClient Message: {0}", e.Data); + } + + protected override void OnClose(CloseEventArgs e) + { + base.OnClose(e); + + Debug.Console(0, Debug.ErrorLogLevel.Notice, "WebSocket UiClient Closing: {0} reason: {1}", e.Code, e.Reason); + + } + + protected override void OnError(WebSocketSharp.ErrorEventArgs e) + { + base.OnError(e); + + Debug.Console(2, Debug.ErrorLogLevel.Notice, "WebSocket UiClient Error: {0} message: {1}", e.Exception, e.Message); + } + } +} diff --git a/src/PepperDash.Core/Network/DiscoveryThings.cs b/src/PepperDash.Core/Network/DiscoveryThings.cs new file mode 100644 index 00000000..973c03a4 --- /dev/null +++ b/src/PepperDash.Core/Network/DiscoveryThings.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core +{ + /// + /// Not in use + /// + public static class NetworkComm + { + /// + /// Not in use + /// + static NetworkComm() + { + } + } + +} \ No newline at end of file diff --git a/src/PepperDash.Core/PasswordManagement/Config.cs b/src/PepperDash.Core/PasswordManagement/Config.cs new file mode 100644 index 00000000..22aa4881 --- /dev/null +++ b/src/PepperDash.Core/PasswordManagement/Config.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.PasswordManagement +{ + /// + /// JSON password configuration + /// + public class PasswordConfig + { + /// + /// Password object configured password + /// + public string password { get; set; } + /// + /// Constructor + /// + public PasswordConfig() + { + + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/PasswordManagement/Constants.cs b/src/PepperDash.Core/PasswordManagement/Constants.cs new file mode 100644 index 00000000..65a1bf45 --- /dev/null +++ b/src/PepperDash.Core/PasswordManagement/Constants.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.PasswordManagement +{ + /// + /// Constants + /// + public class PasswordManagementConstants + { + /// + /// Generic boolean value change constant + /// + public const ushort BoolValueChange = 1; + /// + /// Evaluated boolean change constant + /// + public const ushort PasswordInitializedChange = 2; + /// + /// Update busy change const + /// + public const ushort PasswordUpdateBusyChange = 3; + /// + /// Password is valid change constant + /// + public const ushort PasswordValidationChange = 4; + /// + /// Password LED change constant + /// + public const ushort PasswordLedFeedbackChange = 5; + + /// + /// Generic ushort value change constant + /// + public const ushort UshrtValueChange = 101; + /// + /// Password count + /// + public const ushort PasswordManagerCountChange = 102; + /// + /// Password selecte index change constant + /// + public const ushort PasswordSelectIndexChange = 103; + /// + /// Password length + /// + public const ushort PasswordLengthChange = 104; + + /// + /// Generic string value change constant + /// + public const ushort StringValueChange = 201; + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/PasswordManagement/PasswordClient.cs b/src/PepperDash.Core/PasswordManagement/PasswordClient.cs new file mode 100644 index 00000000..225a563c --- /dev/null +++ b/src/PepperDash.Core/PasswordManagement/PasswordClient.cs @@ -0,0 +1,187 @@ +using System; + +namespace PepperDash.Core.PasswordManagement +{ + /// + /// A class to allow user interaction with the PasswordManager + /// + public class PasswordClient + { + /// + /// Password selected + /// + public string Password { get; set; } + /// + /// Password selected key + /// + public ushort Key { get; set; } + /// + /// Used to build the password entered by the user + /// + public string PasswordToValidate { get; set; } + + /// + /// Boolean event + /// + public event EventHandler BoolChange; + /// + /// Ushort event + /// + public event EventHandler UshrtChange; + /// + /// String event + /// + public event EventHandler StringChange; + + /// + /// Constructor + /// + public PasswordClient() + { + PasswordManager.PasswordChange += new EventHandler(PasswordManager_PasswordChange); + } + + /// + /// Initialize method + /// + public void Initialize() + { + OnBoolChange(false, 0, PasswordManagementConstants.PasswordInitializedChange); + + Password = ""; + PasswordToValidate = ""; + + OnUshrtChange((ushort)PasswordManager.Passwords.Count, 0, PasswordManagementConstants.PasswordManagerCountChange); + OnBoolChange(true, 0, PasswordManagementConstants.PasswordInitializedChange); + } + + /// + /// Retrieve password by index + /// + /// + public void GetPasswordByIndex(ushort key) + { + OnUshrtChange((ushort)PasswordManager.Passwords.Count, 0, PasswordManagementConstants.PasswordManagerCountChange); + + Key = key; + + var pw = PasswordManager.Passwords[Key]; + if (pw == null) + { + OnUshrtChange(0, 0, PasswordManagementConstants.PasswordLengthChange); + return; + } + + Password = pw; + OnUshrtChange((ushort)Password.Length, 0, PasswordManagementConstants.PasswordLengthChange); + OnUshrtChange(key, 0, PasswordManagementConstants.PasswordSelectIndexChange); + } + + /// + /// Password validation method + /// + /// + public void ValidatePassword(string password) + { + if (string.IsNullOrEmpty(password)) + return; + + if (string.Equals(Password, password)) + OnBoolChange(true, 0, PasswordManagementConstants.PasswordValidationChange); + else + OnBoolChange(false, 0, PasswordManagementConstants.PasswordValidationChange); + + ClearPassword(); + } + + /// + /// Builds the user entered passwrod string, will attempt to validate the user entered + /// password against the selected password when the length of the 2 are equal + /// + /// + public void BuildPassword(string data) + { + PasswordToValidate = String.Concat(PasswordToValidate, data); + OnBoolChange(true, (ushort)PasswordToValidate.Length, PasswordManagementConstants.PasswordLedFeedbackChange); + + if (PasswordToValidate.Length == Password.Length) + ValidatePassword(PasswordToValidate); + } + + /// + /// Clears the user entered password and resets the LEDs + /// + public void ClearPassword() + { + PasswordToValidate = ""; + OnBoolChange(false, (ushort)PasswordToValidate.Length, PasswordManagementConstants.PasswordLedFeedbackChange); + } + + /// + /// Protected boolean change event handler + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// Protected ushort change event handler + /// + /// + /// + /// + protected void OnUshrtChange(ushort value, ushort index, ushort type) + { + var handler = UshrtChange; + if (handler != null) + { + var args = new UshrtChangeEventArgs(value, type); + args.Index = index; + UshrtChange(this, args); + } + } + + /// + /// Protected string change event handler + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + + /// + /// If password changes while selected change event will be notifed and update the client + /// + /// + /// + protected void PasswordManager_PasswordChange(object sender, StringChangeEventArgs args) + { + //throw new NotImplementedException(); + if (Key == args.Index) + { + //PasswordSelectedKey = args.Index; + //PasswordSelected = args.StringValue; + GetPasswordByIndex(args.Index); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/PasswordManagement/PasswordManager.cs b/src/PepperDash.Core/PasswordManagement/PasswordManager.cs new file mode 100644 index 00000000..d15ac1e1 --- /dev/null +++ b/src/PepperDash.Core/PasswordManagement/PasswordManager.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using Crestron.SimplSharp; + +namespace PepperDash.Core.PasswordManagement +{ + /// + /// Allows passwords to be stored and managed + /// + public class PasswordManager + { + /// + /// Public dictionary of known passwords + /// + public static Dictionary Passwords = new Dictionary(); + /// + /// Private dictionary, used when passwords are updated + /// + private Dictionary _passwords = new Dictionary(); + + /// + /// Timer used to wait until password changes have stopped before updating the dictionary + /// + CTimer PasswordTimer; + /// + /// Timer length + /// + public long PasswordTimerElapsedMs = 5000; + + /// + /// Boolean event + /// + public event EventHandler BoolChange; + /// + /// Ushort event + /// + public event EventHandler UshrtChange; + /// + /// String event + /// + public event EventHandler StringChange; + /// + /// Event to notify clients of an updated password at the specified index (uint) + /// + public static event EventHandler PasswordChange; + + /// + /// Constructor + /// + public PasswordManager() + { + + } + + /// + /// Initialize password manager + /// + public void Initialize() + { + if (Passwords == null) + Passwords = new Dictionary(); + + if (_passwords == null) + _passwords = new Dictionary(); + + OnBoolChange(true, 0, PasswordManagementConstants.PasswordInitializedChange); + } + + /// + /// Updates password stored in the dictonary + /// + /// + /// + public void UpdatePassword(ushort key, string password) + { + // validate the parameters + if (key > 0 && string.IsNullOrEmpty(password)) + { + Debug.Console(1, string.Format("PasswordManager.UpdatePassword: key [{0}] or password are not valid", key, password)); + return; + } + + try + { + // if key exists, update the value + if(_passwords.ContainsKey(key)) + _passwords[key] = password; + // else add the key & value + else + _passwords.Add(key, password); + + Debug.Console(1, string.Format("PasswordManager.UpdatePassword: _password[{0}] = {1}", key, _passwords[key])); + + if (PasswordTimer == null) + { + PasswordTimer = new CTimer((o) => PasswordTimerElapsed(), PasswordTimerElapsedMs); + Debug.Console(1, string.Format("PasswordManager.UpdatePassword: CTimer Started")); + OnBoolChange(true, 0, PasswordManagementConstants.PasswordUpdateBusyChange); + } + else + { + PasswordTimer.Reset(PasswordTimerElapsedMs); + Debug.Console(1, string.Format("PasswordManager.UpdatePassword: CTimer Reset")); + } + } + catch (Exception e) + { + var msg = string.Format("PasswordManager.UpdatePassword key-value[{0}, {1}] failed:\r{2}", key, password, e); + Debug.Console(1, msg); + } + } + + /// + /// CTimer callback function + /// + private void PasswordTimerElapsed() + { + try + { + PasswordTimer.Stop(); + Debug.Console(1, string.Format("PasswordManager.PasswordTimerElapsed: CTimer Stopped")); + OnBoolChange(false, 0, PasswordManagementConstants.PasswordUpdateBusyChange); + foreach (var pw in _passwords) + { + // if key exists, continue + if (Passwords.ContainsKey(pw.Key)) + { + Debug.Console(1, string.Format("PasswordManager.PasswordTimerElapsed: pw.key[{0}] = {1}", pw.Key, pw.Value)); + if (Passwords[pw.Key] != _passwords[pw.Key]) + { + Passwords[pw.Key] = _passwords[pw.Key]; + Debug.Console(1, string.Format("PasswordManager.PasswordTimerElapsed: Updated Password[{0} = {1}", pw.Key, Passwords[pw.Key])); + OnPasswordChange(Passwords[pw.Key], (ushort)pw.Key, PasswordManagementConstants.StringValueChange); + } + } + // else add the key & value + else + { + Passwords.Add(pw.Key, pw.Value); + } + } + OnUshrtChange((ushort)Passwords.Count, 0, PasswordManagementConstants.PasswordManagerCountChange); + } + catch (Exception e) + { + var msg = string.Format("PasswordManager.PasswordTimerElapsed failed:\r{0}", e); + Debug.Console(1, msg); + } + } + + /// + /// Method to change the default timer value, (default 5000ms/5s) + /// + /// + public void PasswordTimerMs(ushort time) + { + PasswordTimerElapsedMs = Convert.ToInt64(time); + } + + /// + /// Helper method for debugging to see what passwords are in the lists + /// + public void ListPasswords() + { + Debug.Console(0, "PasswordManager.ListPasswords:\r"); + foreach (var pw in Passwords) + Debug.Console(0, "Passwords[{0}]: {1}\r", pw.Key, pw.Value); + Debug.Console(0, "\n"); + foreach (var pw in _passwords) + Debug.Console(0, "_passwords[{0}]: {1}\r", pw.Key, pw.Value); + } + + /// + /// Protected boolean change event handler + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// Protected ushort change event handler + /// + /// + /// + /// + protected void OnUshrtChange(ushort value, ushort index, ushort type) + { + var handler = UshrtChange; + if (handler != null) + { + var args = new UshrtChangeEventArgs(value, type); + args.Index = index; + UshrtChange(this, args); + } + } + + /// + /// Protected string change event handler + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + + /// + /// Protected password change event handler + /// + /// + /// + /// + protected void OnPasswordChange(string value, ushort index, ushort type) + { + var handler = PasswordChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + PasswordChange(this, args); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj new file mode 100644 index 00000000..53a3c78a --- /dev/null +++ b/src/PepperDash.Core/PepperDash.Core.csproj @@ -0,0 +1,66 @@ + + + Library + + + PepperDash.Core + PepperDashCore + net472 + true + en + bin\$(Configuration)\ + False + True + PepperDash Core + PepperDash Technologies + git + https://github.com/PepperDash/PepperDashCore + crestron;4series; + false + $(Version) + true + + + full + TRACE;DEBUG;SERIES4 + + + pdbonly + bin\4Series\$(Configuration)\PepperDashCore.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.props b/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.props new file mode 100644 index 00000000..b28b35c5 --- /dev/null +++ b/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.props @@ -0,0 +1,18 @@ + + + 2.0.0-local + PepperDash Technologies + PepperDash Technologies + PepperDash Essentials + Copyright © 2025 + git + Crestron; 4series + ../../output + LICENSE.md + README.md + + + + + + diff --git a/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.targets b/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.targets new file mode 100644 index 00000000..d90a48ef --- /dev/null +++ b/src/PepperDash.Core/PepperDashCore.build/net472/PepperDashCore.targets @@ -0,0 +1,57 @@ + + + + true + build; + + + true + build; + + + true + build; + + + + $(TargetDir)$(TargetName).$(Version).$(TargetFramework).cplz + + + $(TargetDir)$(TargetName).$(Version).$(TargetFramework).cpz + + + + + + + + + + + + + + + + + + + + + + + + + > + + + + + + + + doNotUse + + + + diff --git a/src/PepperDash.Core/Properties/ControlSystem.cfg b/src/PepperDash.Core/Properties/ControlSystem.cfg new file mode 100644 index 00000000..f99176fb --- /dev/null +++ b/src/PepperDash.Core/Properties/ControlSystem.cfg @@ -0,0 +1,7 @@ + + + MC3 SSH +
ssh 10.0.0.15
+ Program01 + Internal Flash +
\ No newline at end of file diff --git a/src/PepperDash.Core/SystemInfo/EventArgs and Constants.cs b/src/PepperDash.Core/SystemInfo/EventArgs and Constants.cs new file mode 100644 index 00000000..cc71e303 --- /dev/null +++ b/src/PepperDash.Core/SystemInfo/EventArgs and Constants.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.SystemInfo +{ + /// + /// Constants + /// + public class SystemInfoConstants + { + /// + /// + /// + public const ushort BoolValueChange = 1; + /// + /// + /// + public const ushort CompleteBoolChange = 2; + /// + /// + /// + public const ushort BusyBoolChange = 3; + + /// + /// + /// + public const ushort UshortValueChange = 101; + + /// + /// + /// + public const ushort StringValueChange = 201; + /// + /// + /// + public const ushort ConsoleResponseChange = 202; + /// + /// + /// + public const ushort ProcessorUptimeChange = 203; + /// + /// + /// + public const ushort ProgramUptimeChange = 204; + + /// + /// + /// + public const ushort ObjectChange = 301; + /// + /// + /// + public const ushort ProcessorConfigChange = 302; + /// + /// + /// + public const ushort EthernetConfigChange = 303; + /// + /// + /// + public const ushort ControlSubnetConfigChange = 304; + /// + /// + /// + public const ushort ProgramConfigChange = 305; + } + + /// + /// Processor Change Event Args Class + /// + public class ProcessorChangeEventArgs : EventArgs + { + /// + /// + /// + public ProcessorInfo Processor { get; set; } + /// + /// + /// + public ushort Type { get; set; } + /// + /// + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public ProcessorChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + public ProcessorChangeEventArgs(ProcessorInfo processor, ushort type) + { + Processor = processor; + Type = type; + } + + /// + /// Constructor + /// + public ProcessorChangeEventArgs(ProcessorInfo processor, ushort type, ushort index) + { + Processor = processor; + Type = type; + Index = index; + } + } + + /// + /// Ethernet Change Event Args Class + /// + public class EthernetChangeEventArgs : EventArgs + { + /// + /// + /// + public EthernetInfo Adapter { get; set; } + /// + /// + /// + public ushort Type { get; set; } + /// + /// + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public EthernetChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public EthernetChangeEventArgs(EthernetInfo ethernet, ushort type) + { + Adapter = ethernet; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public EthernetChangeEventArgs(EthernetInfo ethernet, ushort type, ushort index) + { + Adapter = ethernet; + Type = type; + Index = index; + } + } + + /// + /// Control Subnet Chage Event Args Class + /// + public class ControlSubnetChangeEventArgs : EventArgs + { + /// + /// + /// + public ControlSubnetInfo Adapter { get; set; } + /// + /// + /// + public ushort Type { get; set; } + /// + /// + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public ControlSubnetChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + public ControlSubnetChangeEventArgs(ControlSubnetInfo controlSubnet, ushort type) + { + Adapter = controlSubnet; + Type = type; + } + + /// + /// Constructor overload + /// + public ControlSubnetChangeEventArgs(ControlSubnetInfo controlSubnet, ushort type, ushort index) + { + Adapter = controlSubnet; + Type = type; + Index = index; + } + } + + /// + /// Program Change Event Args Class + /// + public class ProgramChangeEventArgs : EventArgs + { + /// + /// + /// + public ProgramInfo Program { get; set; } + /// + /// + /// + public ushort Type { get; set; } + /// + /// + /// + public ushort Index { get; set; } + + /// + /// Constructor + /// + public ProgramChangeEventArgs() + { + + } + + /// + /// Constructor overload + /// + /// + /// + public ProgramChangeEventArgs(ProgramInfo program, ushort type) + { + Program = program; + Type = type; + } + + /// + /// Constructor overload + /// + /// + /// + /// + public ProgramChangeEventArgs(ProgramInfo program, ushort type, ushort index) + { + Program = program; + Type = type; + Index = index; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/SystemInfo/SystemInfoConfig.cs b/src/PepperDash.Core/SystemInfo/SystemInfoConfig.cs new file mode 100644 index 00000000..8dc3acaf --- /dev/null +++ b/src/PepperDash.Core/SystemInfo/SystemInfoConfig.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.SystemInfo +{ + /// + /// Processor info class + /// + public class ProcessorInfo + { + /// + /// + /// + public string Model { get; set; } + /// + /// + /// + public string SerialNumber { get; set; } + /// + /// + /// + public string Firmware { get; set; } + /// + /// + /// + public string FirmwareDate { get; set; } + /// + /// + /// + public string OsVersion { get; set; } + /// + /// + /// + public string RuntimeEnvironment { get; set; } + /// + /// + /// + public string DevicePlatform { get; set; } + /// + /// + /// + public string ModuleDirectory { get; set; } + /// + /// + /// + public string LocalTimeZone { get; set; } + /// + /// + /// + public string ProgramIdTag { get; set; } + + /// + /// Constructor + /// + public ProcessorInfo() + { + + } + } + + /// + /// Ethernet info class + /// + public class EthernetInfo + { + /// + /// + /// + public ushort DhcpIsOn { get; set; } + /// + /// + /// + public string Hostname { get; set; } + /// + /// + /// + public string MacAddress { get; set; } + /// + /// + /// + public string IpAddress { get; set; } + /// + /// + /// + public string Subnet { get; set; } + /// + /// + /// + public string Gateway { get; set; } + /// + /// + /// + public string Dns1 { get; set; } + /// + /// + /// + public string Dns2 { get; set; } + /// + /// + /// + public string Dns3 { get; set; } + /// + /// + /// + public string Domain { get; set; } + + /// + /// Constructor + /// + public EthernetInfo() + { + + } + } + + /// + /// Control subnet info class + /// + public class ControlSubnetInfo + { + /// + /// + /// + public ushort Enabled { get; set; } + /// + /// + /// + public ushort IsInAutomaticMode { get; set; } + /// + /// + /// + public string MacAddress { get; set; } + /// + /// + /// + public string IpAddress { get; set; } + /// + /// + /// + public string Subnet { get; set; } + /// + /// + /// + public string RouterPrefix { get; set; } + + /// + /// Constructor + /// + public ControlSubnetInfo() + { + + } + } + + /// + /// Program info class + /// + public class ProgramInfo + { + /// + /// + /// + public string Name { get; set; } + /// + /// + /// + public string Header { get; set; } + /// + /// + /// + public string System { get; set; } + /// + /// + /// + public string ProgramIdTag { get; set; } + /// + /// + /// + public string CompileTime { get; set; } + /// + /// + /// + public string Database { get; set; } + /// + /// + /// + public string Environment { get; set; } + /// + /// + /// + public string Programmer { get; set; } + + /// + /// Constructor + /// + public ProgramInfo() + { + + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/SystemInfo/SystemInfoToSimpl.cs b/src/PepperDash.Core/SystemInfo/SystemInfoToSimpl.cs new file mode 100644 index 00000000..6677b9ef --- /dev/null +++ b/src/PepperDash.Core/SystemInfo/SystemInfoToSimpl.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.SystemInfo +{ + /// + /// System Info class + /// + public class SystemInfoToSimpl + { + /// + /// Notifies of bool change + /// + public event EventHandler BoolChange; + /// + /// Notifies of string change + /// + public event EventHandler StringChange; + + /// + /// Notifies of processor change + /// + public event EventHandler ProcessorChange; + /// + /// Notifies of ethernet change + /// + public event EventHandler EthernetChange; + /// + /// Notifies of control subnet change + /// + public event EventHandler ControlSubnetChange; + /// + /// Notifies of program change + /// + public event EventHandler ProgramChange; + + /// + /// Constructor + /// + public SystemInfoToSimpl() + { + + } + + /// + /// Gets the current processor info + /// + public void GetProcessorInfo() + { + OnBoolChange(true, 0, SystemInfoConstants.BusyBoolChange); + + try + { + var processor = new ProcessorInfo(); + processor.Model = InitialParametersClass.ControllerPromptName; + processor.SerialNumber = CrestronEnvironment.SystemInfo.SerialNumber; + processor.ModuleDirectory = InitialParametersClass.ProgramDirectory.ToString(); + processor.ProgramIdTag = InitialParametersClass.ProgramIDTag; + processor.DevicePlatform = CrestronEnvironment.DevicePlatform.ToString(); + processor.OsVersion = CrestronEnvironment.OSVersion.Version.ToString(); + processor.RuntimeEnvironment = CrestronEnvironment.RuntimeEnvironment.ToString(); + processor.LocalTimeZone = CrestronEnvironment.GetTimeZone().Offset; + + // Does not return firmware version matching a "ver" command + // returns the "ver -v" 'CAB' version + // example return ver -v: + // RMC3 Cntrl Eng [v1.503.3568.25373 (Oct 09 2018), #4001E302] @E-00107f4420f0 + // Build: 14:05:46 Oct 09 2018 (3568.25373) + // Cab: 1.503.0070 + // Applications: 1.0.6855.21351 + // Updater: 1.4.24 + // Bootloader: 1.22.00 + // RMC3-SetupProgram: 1.003.0011 + // IOPVersion: FPGA [v09] slot:7 + // PUF: Unknown + //Firmware = CrestronEnvironment.OSVersion.Firmware; + //Firmware = InitialParametersClass.FirmwareVersion; + + // Use below logic to get actual firmware ver, not the 'CAB' returned by the above + // matches console return of a "ver" and on SystemInfo page + // example return ver: + // RMC3 Cntrl Eng [v1.503.3568.25373 (Oct 09 2018), #4001E302] @E-00107f4420f0 + var response = ""; + CrestronConsole.SendControlSystemCommand("ver", ref response); + processor.Firmware = ParseConsoleResponse(response, "Cntrl Eng", "[", "("); + processor.FirmwareDate = ParseConsoleResponse(response, "Cntrl Eng", "(", ")"); + + OnProcessorChange(processor, 0, SystemInfoConstants.ProcessorConfigChange); + } + catch (Exception e) + { + var msg = string.Format("GetProcessorInfo failed: {0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + + OnBoolChange(false, 0, SystemInfoConstants.BusyBoolChange); + } + + /// + /// Gets the current ethernet info + /// + public void GetEthernetInfo() + { + OnBoolChange(true, 0, SystemInfoConstants.BusyBoolChange); + + var adapter = new EthernetInfo(); + + try + { + // get lan adapter id + var adapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter); + + // get lan adapter info + var dhcpState = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_DHCP_STATE, adapterId); + if (!string.IsNullOrEmpty(dhcpState)) + adapter.DhcpIsOn = (ushort)(dhcpState.ToLower().Contains("on") ? 1 : 0); + + adapter.Hostname = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, adapterId); + adapter.MacAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_MAC_ADDRESS, adapterId); + adapter.IpAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, adapterId); + adapter.Subnet = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_MASK, adapterId); + adapter.Gateway = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_ROUTER, adapterId); + adapter.Domain = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, adapterId); + + // returns comma seperate list of dns servers with trailing comma + // example return: "8.8.8.8 (DHCP),8.8.4.4 (DHCP)," + string dns = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DNS_SERVER, adapterId); + if (dns.Contains(",")) + { + string[] dnsList = dns.Split(','); + for (var i = 0; i < dnsList.Length; i++) + { + if(i == 0) + adapter.Dns1 = !string.IsNullOrEmpty(dnsList[0]) ? dnsList[0] : "0.0.0.0"; + if(i == 1) + adapter.Dns2 = !string.IsNullOrEmpty(dnsList[1]) ? dnsList[1] : "0.0.0.0"; + if(i == 2) + adapter.Dns3 = !string.IsNullOrEmpty(dnsList[2]) ? dnsList[2] : "0.0.0.0"; + } + } + else + { + adapter.Dns1 = !string.IsNullOrEmpty(dns) ? dns : "0.0.0.0"; + adapter.Dns2 = "0.0.0.0"; + adapter.Dns3 = "0.0.0.0"; + } + + OnEthernetInfoChange(adapter, 0, SystemInfoConstants.EthernetConfigChange); + } + catch (Exception e) + { + var msg = string.Format("GetEthernetInfo failed: {0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + + OnBoolChange(false, 0, SystemInfoConstants.BusyBoolChange); + } + + /// + /// Gets the current control subnet info + /// + public void GetControlSubnetInfo() + { + OnBoolChange(true, 0, SystemInfoConstants.BusyBoolChange); + + var adapter = new ControlSubnetInfo(); + + try + { + // get cs adapter id + var adapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); + if (!adapterId.Equals(EthernetAdapterType.EthernetUnknownAdapter)) + { + adapter.Enabled = 1; + adapter.IsInAutomaticMode = (ushort)(CrestronEthernetHelper.IsControlSubnetInAutomaticMode ? 1 : 0); + adapter.MacAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_MAC_ADDRESS, adapterId); + adapter.IpAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, adapterId); + adapter.Subnet = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_MASK, adapterId); + adapter.RouterPrefix = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CONTROL_SUBNET_ROUTER_PREFIX, adapterId); + } + } + catch (Exception e) + { + adapter.Enabled = 0; + adapter.IsInAutomaticMode = 0; + adapter.MacAddress = "NA"; + adapter.IpAddress = "NA"; + adapter.Subnet = "NA"; + adapter.RouterPrefix = "NA"; + + var msg = string.Format("GetControlSubnetInfo failed: {0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + + OnControlSubnetInfoChange(adapter, 0, SystemInfoConstants.ControlSubnetConfigChange); + OnBoolChange(false, 0, SystemInfoConstants.BusyBoolChange); + } + + /// + /// Gets the program info by index + /// + /// + public void GetProgramInfoByIndex(ushort index) + { + if (index < 1 || index > 10) + return; + + OnBoolChange(true, 0, SystemInfoConstants.BusyBoolChange); + + var program = new ProgramInfo(); + + try + { + var response = ""; + CrestronConsole.SendControlSystemCommand(string.Format("progcomments:{0}", index), ref response); + + // no program loaded or running + if (response.Contains("Bad or Incomplete Command")) + { + program.Name = ""; + program.System = ""; + program.Programmer = ""; + program.CompileTime = ""; + program.Database = ""; + program.Environment = ""; + } + else + { + // SIMPL returns + program.Name = ParseConsoleResponse(response, "Program File", ":", "\x0D"); + program.System = ParseConsoleResponse(response, "System Name", ":", "\x0D"); + program.ProgramIdTag = ParseConsoleResponse(response, "Friendly Name", ":", "\x0D"); + program.Programmer = ParseConsoleResponse(response, "Programmer", ":", "\x0D"); + program.CompileTime = ParseConsoleResponse(response, "Compiled On", ":", "\x0D"); + program.Database = ParseConsoleResponse(response, "CrestronDB", ":", "\x0D"); + program.Environment = ParseConsoleResponse(response, "Source Env", ":", "\x0D"); + + // S# returns + if (program.System.Length == 0) + program.System = ParseConsoleResponse(response, "Application Name", ":", "\x0D"); + if (program.Database.Length == 0) + program.Database = ParseConsoleResponse(response, "PlugInVersion", ":", "\x0D"); + if (program.Environment.Length == 0) + program.Environment = ParseConsoleResponse(response, "Program Tool", ":", "\x0D"); + + } + + OnProgramChange(program, index, SystemInfoConstants.ProgramConfigChange); + } + catch (Exception e) + { + var msg = string.Format("GetProgramInfoByIndex failed: {0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + + OnBoolChange(false, 0, SystemInfoConstants.BusyBoolChange); + } + + /// + /// Gets the processor uptime and passes it to S+ + /// + public void RefreshProcessorUptime() + { + try + { + string response = ""; + CrestronConsole.SendControlSystemCommand("uptime", ref response); + var uptime = ParseConsoleResponse(response, "running for", "running for", "\x0D"); + OnStringChange(uptime, 0, SystemInfoConstants.ProcessorUptimeChange); + } + catch (Exception e) + { + var msg = string.Format("RefreshProcessorUptime failed:\r{0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + } + + /// + /// Gets the program uptime, by index, and passes it to S+ + /// + /// + public void RefreshProgramUptimeByIndex(int index) + { + try + { + string response = ""; + CrestronConsole.SendControlSystemCommand(string.Format("proguptime:{0}", index), ref response); + string uptime = ParseConsoleResponse(response, "running for", "running for", "\x0D"); + OnStringChange(uptime, (ushort)index, SystemInfoConstants.ProgramUptimeChange); + } + catch (Exception e) + { + var msg = string.Format("RefreshProgramUptimebyIndex({0}) failed:\r{1}", index, e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + } + + /// + /// Sends command to console, passes response back using string change event + /// + /// + public void SendConsoleCommand(string cmd) + { + if (string.IsNullOrEmpty(cmd)) + return; + + string response = ""; + CrestronConsole.SendControlSystemCommand(cmd, ref response); + if (!string.IsNullOrEmpty(response)) + { + if (response.EndsWith("\x0D\\x0A")) + response.Trim('\n'); + + OnStringChange(response, 0, SystemInfoConstants.ConsoleResponseChange); + } + } + + /// + /// private method to parse console messages + /// + /// + /// + /// + /// + /// + private string ParseConsoleResponse(string data, string line, string dataStart, string dataEnd) + { + var response = ""; + + if (string.IsNullOrEmpty(data) || string.IsNullOrEmpty(line) || string.IsNullOrEmpty(dataStart) || string.IsNullOrEmpty(dataEnd)) + return response; + + try + { + var linePos = data.IndexOf(line); + var startPos = data.IndexOf(dataStart, linePos) + dataStart.Length; + var endPos = data.IndexOf(dataEnd, startPos); + response = data.Substring(startPos, endPos - startPos).Trim(); + } + catch (Exception e) + { + var msg = string.Format("ParseConsoleResponse failed: {0}", e.Message); + CrestronConsole.PrintLine(msg); + //ErrorLog.Error(msg); + } + + return response; + } + + /// + /// Protected boolean change event handler + /// + /// + /// + /// + protected void OnBoolChange(bool state, ushort index, ushort type) + { + var handler = BoolChange; + if (handler != null) + { + var args = new BoolChangeEventArgs(state, type); + args.Index = index; + BoolChange(this, args); + } + } + + /// + /// Protected string change event handler + /// + /// + /// + /// + protected void OnStringChange(string value, ushort index, ushort type) + { + var handler = StringChange; + if (handler != null) + { + var args = new StringChangeEventArgs(value, type); + args.Index = index; + StringChange(this, args); + } + } + + /// + /// Protected processor config change event handler + /// + /// + /// + /// + protected void OnProcessorChange(ProcessorInfo processor, ushort index, ushort type) + { + var handler = ProcessorChange; + if (handler != null) + { + var args = new ProcessorChangeEventArgs(processor, type); + args.Index = index; + ProcessorChange(this, args); + } + } + + /// + /// Ethernet change event handler + /// + /// + /// + /// + protected void OnEthernetInfoChange(EthernetInfo ethernet, ushort index, ushort type) + { + var handler = EthernetChange; + if (handler != null) + { + var args = new EthernetChangeEventArgs(ethernet, type); + args.Index = index; + EthernetChange(this, args); + } + } + + /// + /// Control Subnet change event handler + /// + /// + /// + /// + protected void OnControlSubnetInfoChange(ControlSubnetInfo ethernet, ushort index, ushort type) + { + var handler = ControlSubnetChange; + if (handler != null) + { + var args = new ControlSubnetChangeEventArgs(ethernet, type); + args.Index = index; + ControlSubnetChange(this, args); + } + } + + /// + /// Program change event handler + /// + /// + /// + /// + protected void OnProgramChange(ProgramInfo program, ushort index, ushort type) + { + var handler = ProgramChange; + + if (handler != null) + { + var args = new ProgramChangeEventArgs(program, type); + args.Index = index; + ProgramChange(this, args); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/BouncyCertificate.cs b/src/PepperDash.Core/Web/BouncyCertificate.cs new file mode 100644 index 00000000..bf8b0f4c --- /dev/null +++ b/src/PepperDash.Core/Web/BouncyCertificate.cs @@ -0,0 +1,356 @@ +using Crestron.SimplSharp; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Prng; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities; +using Org.BouncyCastle.X509; +using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using X509KeyStorageFlags = System.Security.Cryptography.X509Certificates.X509KeyStorageFlags; +using X509ContentType = System.Security.Cryptography.X509Certificates.X509ContentType; +using Org.BouncyCastle.Crypto.Operators; +using BigInteger = Org.BouncyCastle.Math.BigInteger; +using X509Certificate = Org.BouncyCastle.X509.X509Certificate; + +namespace PepperDash.Core +{ + /// + /// Taken From https://github.com/rlipscombe/bouncy-castle-csharp/ + /// + internal class BouncyCertificate + { + public string CertificatePassword { get; set; } = "password"; + public X509Certificate2 LoadCertificate(string issuerFileName, string password) + { + // We need to pass 'Exportable', otherwise we can't get the private key. + var issuerCertificate = new X509Certificate2(issuerFileName, password, X509KeyStorageFlags.Exportable); + return issuerCertificate; + } + + public X509Certificate2 IssueCertificate(string subjectName, X509Certificate2 issuerCertificate, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = issuerCertificate.Subject; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + var issuerKeyPair = DotNetUtilities.GetKeyPair(issuerCertificate.PrivateKey); + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = new BigInteger(issuerCertificate.GetSerialNumber()); + + const bool isCertificateAuthority = false; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + public X509Certificate2 CreateCertificateAuthorityCertificate(string subjectName, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = subjectName; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + // It's self-signed, so these are the same. + var issuerKeyPair = subjectKeyPair; + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = serialNumber; // Self-signed, so it's the same serial number. + + const bool isCertificateAuthority = true; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + public X509Certificate2 CreateSelfSignedCertificate(string subjectName, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = subjectName; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + // It's self-signed, so these are the same. + var issuerKeyPair = subjectKeyPair; + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = serialNumber; // Self-signed, so it's the same serial number. + + const bool isCertificateAuthority = false; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + private SecureRandom GetSecureRandom() + { + // Since we're on Windows, we'll use the CryptoAPI one (on the assumption + // that it might have access to better sources of entropy than the built-in + // Bouncy Castle ones): + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + return random; + } + + private X509Certificate GenerateCertificate(SecureRandom random, + string subjectName, + AsymmetricCipherKeyPair subjectKeyPair, + BigInteger subjectSerialNumber, + string[] subjectAlternativeNames, + string issuerName, + AsymmetricCipherKeyPair issuerKeyPair, + BigInteger issuerSerialNumber, + bool isCertificateAuthority, + KeyPurposeID[] usages) + { + var certificateGenerator = new X509V3CertificateGenerator(); + + certificateGenerator.SetSerialNumber(subjectSerialNumber); + + var issuerDN = new X509Name(issuerName); + certificateGenerator.SetIssuerDN(issuerDN); + + // Note: The subject can be omitted if you specify a subject alternative name (SAN). + var subjectDN = new X509Name(subjectName); + certificateGenerator.SetSubjectDN(subjectDN); + + // Our certificate needs valid from/to values. + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(2); + + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // The subject's public key goes in the certificate. + certificateGenerator.SetPublicKey(subjectKeyPair.Public); + + AddAuthorityKeyIdentifier(certificateGenerator, issuerDN, issuerKeyPair, issuerSerialNumber); + AddSubjectKeyIdentifier(certificateGenerator, subjectKeyPair); + //AddBasicConstraints(certificateGenerator, isCertificateAuthority); + + if (usages != null && usages.Any()) + AddExtendedKeyUsage(certificateGenerator, usages); + + if (subjectAlternativeNames != null && subjectAlternativeNames.Any()) + AddSubjectAlternativeNames(certificateGenerator, subjectAlternativeNames); + + // Set the signature algorithm. This is used to generate the thumbprint which is then signed + // with the issuer's private key. We'll use SHA-256, which is (currently) considered fairly strong. + const string signatureAlgorithm = "SHA256WithRSA"; + + // The certificate is signed with the issuer's private key. + ISignatureFactory signatureFactory = new Asn1SignatureFactory(signatureAlgorithm, issuerKeyPair.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + return certificate; + } + + /// + /// The certificate needs a serial number. This is used for revocation, + /// and usually should be an incrementing index (which makes it easier to revoke a range of certificates). + /// Since we don't have anywhere to store the incrementing index, we can just use a random number. + /// + /// + /// + private BigInteger GenerateSerialNumber(SecureRandom random) + { + var serialNumber = + BigIntegers.CreateRandomInRange( + BigInteger.One, BigInteger.ValueOf(Int64.MaxValue), random); + return serialNumber; + } + + /// + /// Generate a key pair. + /// + /// The random number generator. + /// The key length in bits. For RSA, 2048 bits should be considered the minimum acceptable these days. + /// + private AsymmetricCipherKeyPair GenerateKeyPair(SecureRandom random, int strength) + { + var keyGenerationParameters = new KeyGenerationParameters(random, strength); + + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(keyGenerationParameters); + var subjectKeyPair = keyPairGenerator.GenerateKeyPair(); + return subjectKeyPair; + } + + /// + /// Add the Authority Key Identifier. According to http://www.alvestrand.no/objectid/2.5.29.35.html, this + /// identifies the public key to be used to verify the signature on this certificate. + /// In a certificate chain, this corresponds to the "Subject Key Identifier" on the *issuer* certificate. + /// The Bouncy Castle documentation, at http://www.bouncycastle.org/wiki/display/JA1/X.509+Public+Key+Certificate+and+Certification+Request+Generation, + /// shows how to create this from the issuing certificate. Since we're creating a self-signed certificate, we have to do this slightly differently. + /// + /// + /// + /// + /// + private void AddAuthorityKeyIdentifier(X509V3CertificateGenerator certificateGenerator, + X509Name issuerDN, + AsymmetricCipherKeyPair issuerKeyPair, + BigInteger issuerSerialNumber) + { + var authorityKeyIdentifierExtension = + new AuthorityKeyIdentifier( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(issuerKeyPair.Public), + new GeneralNames(new GeneralName(issuerDN)), + issuerSerialNumber); + certificateGenerator.AddExtension( + X509Extensions.AuthorityKeyIdentifier.Id, false, authorityKeyIdentifierExtension); + } + + /// + /// Add the "Subject Alternative Names" extension. Note that you have to repeat + /// the value from the "Subject Name" property. + /// + /// + /// + private void AddSubjectAlternativeNames(X509V3CertificateGenerator certificateGenerator, + IEnumerable subjectAlternativeNames) + { + var subjectAlternativeNamesExtension = + new DerSequence( + subjectAlternativeNames.Select(name => new GeneralName(GeneralName.DnsName, name)) + .ToArray()); + certificateGenerator.AddExtension( + X509Extensions.SubjectAlternativeName.Id, false, subjectAlternativeNamesExtension); + } + + /// + /// Add the "Extended Key Usage" extension, specifying (for example) "server authentication". + /// + /// + /// + private void AddExtendedKeyUsage(X509V3CertificateGenerator certificateGenerator, KeyPurposeID[] usages) + { + certificateGenerator.AddExtension( + X509Extensions.ExtendedKeyUsage.Id, false, new ExtendedKeyUsage(usages)); + } + + /// + /// Add the "Basic Constraints" extension. + /// + /// + /// + private void AddBasicConstraints(X509V3CertificateGenerator certificateGenerator, + bool isCertificateAuthority) + { + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints.Id, true, new BasicConstraints(isCertificateAuthority)); + } + + /// + /// Add the Subject Key Identifier. + /// + /// + /// + private void AddSubjectKeyIdentifier(X509V3CertificateGenerator certificateGenerator, + AsymmetricCipherKeyPair subjectKeyPair) + { + var subjectKeyIdentifierExtension = + new SubjectKeyIdentifier( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(subjectKeyPair.Public)); + certificateGenerator.AddExtension( + X509Extensions.SubjectKeyIdentifier.Id, false, subjectKeyIdentifierExtension); + } + + private X509Certificate2 ConvertCertificate(X509Certificate certificate, + AsymmetricCipherKeyPair subjectKeyPair, + SecureRandom random) + { + // Now to convert the Bouncy Castle certificate to a .NET certificate. + // See http://web.archive.org/web/20100504192226/http://www.fkollmann.de/v2/post/Creating-certificates-using-BouncyCastle.aspx + // ...but, basically, we create a PKCS12 store (a .PFX file) in memory, and add the public and private key to that. + var store = new Pkcs12StoreBuilder().Build(); + + // What Bouncy Castle calls "alias" is the same as what Windows terms the "friendly name". + string friendlyName = certificate.SubjectDN.ToString(); + + // Add the certificate. + var certificateEntry = new X509CertificateEntry(certificate); + store.SetCertificateEntry(friendlyName, certificateEntry); + + // Add the private key. + store.SetKeyEntry(friendlyName, new AsymmetricKeyEntry(subjectKeyPair.Private), new[] { certificateEntry }); + + // Convert it to an X509Certificate2 object by saving/loading it from a MemoryStream. + // It needs a password. Since we'll remove this later, it doesn't particularly matter what we use. + + var stream = new MemoryStream(); + store.Save(stream, CertificatePassword.ToCharArray(), random); + + var convertedCertificate = + new X509Certificate2(stream.ToArray(), + CertificatePassword, + X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + return convertedCertificate; + } + + public void WriteCertificate(X509Certificate2 certificate, string outputDirectory, string certName) + { + // This password is the one attached to the PFX file. Use 'null' for no password. + // Create PFX (PKCS #12) with private key + try + { + var pfx = certificate.Export(X509ContentType.Pfx, CertificatePassword); + File.WriteAllBytes(string.Format("{0}.pfx", Path.Combine(outputDirectory, certName)), pfx); + } + catch (Exception ex) + { + CrestronConsole.PrintLine(string.Format("Failed to write x509 cert pfx\r\n{0}", ex.Message)); + } + // Create Base 64 encoded CER (public key only) + using (var writer = new StreamWriter($"{Path.Combine(outputDirectory, certName)}.cer", false)) + { + try + { + var contents = string.Format("-----BEGIN CERTIFICATE-----\r\n{0}\r\n-----END CERTIFICATE-----", Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)); + writer.Write(contents); + } + catch (Exception ex) + { + CrestronConsole.PrintLine(string.Format("Failed to write x509 cert cer\r\n{0}", ex.Message)); + } + } + } + public bool AddCertToStore(X509Certificate2 cert, System.Security.Cryptography.X509Certificates.StoreName st, System.Security.Cryptography.X509Certificates.StoreLocation sl) + { + bool bRet = false; + + try + { + var store = new System.Security.Cryptography.X509Certificates.X509Store(st, sl); + store.Open(System.Security.Cryptography.X509Certificates.OpenFlags.ReadWrite); + store.Add(cert); + + store.Close(); + bRet = true; + } + catch (Exception ex) + { + CrestronConsole.PrintLine(string.Format("AddCertToStore Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace)); + } + + return bRet; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/RequestHandlers/DefaultRequestHandler.cs b/src/PepperDash.Core/Web/RequestHandlers/DefaultRequestHandler.cs new file mode 100644 index 00000000..ca19cf2f --- /dev/null +++ b/src/PepperDash.Core/Web/RequestHandlers/DefaultRequestHandler.cs @@ -0,0 +1,17 @@ +using Crestron.SimplSharp.WebScripting; + +namespace PepperDash.Core.Web.RequestHandlers +{ + /// + /// Web API default request handler + /// + public class DefaultRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + public DefaultRequestHandler() + : base(true) + { } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestAsyncHandler.cs b/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestAsyncHandler.cs new file mode 100644 index 00000000..b1170031 --- /dev/null +++ b/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestAsyncHandler.cs @@ -0,0 +1,163 @@ +using Crestron.SimplSharp.WebScripting; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace PepperDash.Core.Web.RequestHandlers +{ + public abstract class WebApiBaseRequestAsyncHandler:IHttpCwsHandler + { + private readonly Dictionary> _handlers; + protected readonly bool EnableCors; + + /// + /// Constructor + /// + protected WebApiBaseRequestAsyncHandler(bool enableCors) + { + EnableCors = enableCors; + + _handlers = new Dictionary> + { + {"CONNECT", HandleConnect}, + {"DELETE", HandleDelete}, + {"GET", HandleGet}, + {"HEAD", HandleHead}, + {"OPTIONS", HandleOptions}, + {"PATCH", HandlePatch}, + {"POST", HandlePost}, + {"PUT", HandlePut}, + {"TRACE", HandleTrace} + }; + } + + /// + /// Constructor + /// + protected WebApiBaseRequestAsyncHandler() + : this(false) + { + } + + /// + /// Handles CONNECT method requests + /// + /// + protected virtual async Task HandleConnect(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles DELETE method requests + /// + /// + protected virtual async Task HandleDelete(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles GET method requests + /// + /// + protected virtual async Task HandleGet(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles HEAD method requests + /// + /// + protected virtual async Task HandleHead(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles OPTIONS method requests + /// + /// + protected virtual async Task HandleOptions(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles PATCH method requests + /// + /// + protected virtual async Task HandlePatch(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles POST method requests + /// + /// + protected virtual async Task HandlePost(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles PUT method requests + /// + /// + protected virtual async Task HandlePut(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles TRACE method requests + /// + /// + protected virtual async Task HandleTrace(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Process request + /// + /// + public void ProcessRequest(HttpCwsContext context) + { + if (!_handlers.TryGetValue(context.Request.HttpMethod, out Func handler)) + { + return; + } + + if (EnableCors) + { + context.Response.Headers.Add("Access-Control-Allow-Origin", "*"); + context.Response.Headers.Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); + } + + var handlerTask = handler(context); + + handlerTask.GetAwaiter().GetResult(); + } + } +} diff --git a/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestHandler.cs b/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestHandler.cs new file mode 100644 index 00000000..99e4aa93 --- /dev/null +++ b/src/PepperDash.Core/Web/RequestHandlers/WebApiBaseRequestHandler.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using Crestron.SimplSharp.WebScripting; + +namespace PepperDash.Core.Web.RequestHandlers +{ + /// + /// CWS Base Handler, implements IHttpCwsHandler + /// + public abstract class WebApiBaseRequestHandler : IHttpCwsHandler + { + private readonly Dictionary> _handlers; + protected readonly bool EnableCors; + + /// + /// Constructor + /// + protected WebApiBaseRequestHandler(bool enableCors) + { + EnableCors = enableCors; + + _handlers = new Dictionary> + { + {"CONNECT", HandleConnect}, + {"DELETE", HandleDelete}, + {"GET", HandleGet}, + {"HEAD", HandleHead}, + {"OPTIONS", HandleOptions}, + {"PATCH", HandlePatch}, + {"POST", HandlePost}, + {"PUT", HandlePut}, + {"TRACE", HandleTrace} + }; + } + + /// + /// Constructor + /// + protected WebApiBaseRequestHandler() + : this(false) + { + } + + /// + /// Handles CONNECT method requests + /// + /// + protected virtual void HandleConnect(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles DELETE method requests + /// + /// + protected virtual void HandleDelete(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles GET method requests + /// + /// + protected virtual void HandleGet(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles HEAD method requests + /// + /// + protected virtual void HandleHead(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles OPTIONS method requests + /// + /// + protected virtual void HandleOptions(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles PATCH method requests + /// + /// + protected virtual void HandlePatch(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles POST method requests + /// + /// + protected virtual void HandlePost(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles PUT method requests + /// + /// + protected virtual void HandlePut(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Handles TRACE method requests + /// + /// + protected virtual void HandleTrace(HttpCwsContext context) + { + context.Response.StatusCode = 501; + context.Response.StatusDescription = "Not Implemented"; + context.Response.End(); + } + + /// + /// Process request + /// + /// + public void ProcessRequest(HttpCwsContext context) + { + Action handler; + + if (!_handlers.TryGetValue(context.Request.HttpMethod, out handler)) + { + return; + } + + if (EnableCors) + { + context.Response.Headers.Add("Access-Control-Allow-Origin", "*"); + context.Response.Headers.Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); + } + + handler(context); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs new file mode 100644 index 00000000..cf45b361 --- /dev/null +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Crestron.SimplSharp; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Core.Web +{ + /// + /// Web API server + /// + public class WebApiServer : IKeyName + { + private const string SplusKey = "Uninitialized Web API Server"; + private const string DefaultName = "Web API Server"; + private const string DefaultBasePath = "/api"; + + private const uint DebugTrace = 0; + private const uint DebugInfo = 1; + private const uint DebugVerbose = 2; + + private readonly CCriticalSection _serverLock = new CCriticalSection(); + private HttpCwsServer _server; + + /// + /// Web API server key + /// + public string Key { get; private set; } + + /// + /// Web API server name + /// + public string Name { get; private set; } + + /// + /// CWS base path, will default to "/api" if not set via initialize method + /// + public string BasePath { get; private set; } + + /// + /// Indicates CWS is registered with base path + /// + public bool IsRegistered { get; private set; } + + /// + /// Http request handler + /// + //public IHttpCwsHandler HttpRequestHandler + //{ + // get { return _server.HttpRequestHandler; } + // set + // { + // if (_server == null) return; + // _server.HttpRequestHandler = value; + // } + //} + + /// + /// Received request event handler + /// + //public event EventHandler ReceivedRequestEvent + //{ + // add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); } + // remove { _server.ReceivedRequestEvent -= new HttpCwsRequestEventHandler(value); } + //} + + /// + /// Constructor for S+. Make sure to set necessary properties using init method + /// + public WebApiServer() + : this(SplusKey, DefaultName, null) + { + } + + /// + /// Constructor + /// + /// + /// + public WebApiServer(string key, string basePath) + : this(key, DefaultName, basePath) + { + } + + /// + /// Constructor + /// + /// + /// + /// + public WebApiServer(string key, string name, string basePath) + { + Key = key; + Name = string.IsNullOrEmpty(name) ? DefaultName : name; + BasePath = string.IsNullOrEmpty(basePath) ? DefaultBasePath : basePath; + + if (_server == null) _server = new HttpCwsServer(BasePath); + + _server.setProcessName(Key); + _server.HttpRequestHandler = new DefaultRequestHandler(); + + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + CrestronEnvironment.EthernetEventHandler += CrestronEnvironment_EthernetEventHandler; + } + + /// + /// Program status event handler + /// + /// + void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType != eProgramStatusEventType.Stopping) return; + + Debug.Console(DebugInfo, this, "Program stopping. stopping server"); + + Stop(); + } + + /// + /// Ethernet event handler + /// + /// + void CrestronEnvironment_EthernetEventHandler(EthernetEventArgs ethernetEventArgs) + { + // Re-enable the server if the link comes back up and the status should be connected + if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered) + { + Debug.Console(DebugInfo, this, "Ethernet link up. Server is alreedy registered."); + return; + } + + Debug.Console(DebugInfo, this, "Ethernet link up. Starting server"); + + Start(); + } + + /// + /// Initializes CWS class + /// + public void Initialize(string key, string basePath) + { + Key = key; + BasePath = string.IsNullOrEmpty(basePath) ? DefaultBasePath : basePath; + } + + /// + /// Adds a route to CWS + /// + public void AddRoute(HttpCwsRoute route) + { + if (route == null) + { + Debug.Console(DebugInfo, this, "Failed to add route, route parameter is null"); + return; + } + + _server.Routes.Add(route); + + } + + /// + /// Removes a route from CWS + /// + /// + public void RemoveRoute(HttpCwsRoute route) + { + if (route == null) + { + Debug.Console(DebugInfo, this, "Failed to remote route, orute parameter is null"); + return; + } + + _server.Routes.Remove(route); + } + + /// + /// Returns a list of the current routes + /// + public HttpCwsRouteCollection GetRouteCollection() + { + return _server.Routes; + } + + /// + /// Starts CWS instance + /// + public void Start() + { + try + { + _serverLock.Enter(); + + if (_server == null) + { + Debug.Console(DebugInfo, this, "Server is null, unable to start"); + return; + } + + if (IsRegistered) + { + Debug.Console(DebugInfo, this, "Server has already been started"); + return; + } + + IsRegistered = _server.Register(); + + Debug.Console(DebugInfo, this, "Starting server, registration {0}", IsRegistered ? "was successful" : "failed"); + } + catch (Exception ex) + { + Debug.Console(DebugInfo, this, "Start Exception Message: {0}", ex.Message); + Debug.Console(DebugVerbose, this, "Start Exception StackTrace: {0}", ex.StackTrace); + if (ex.InnerException != null) + Debug.Console(DebugVerbose, this, "Start Exception InnerException: {0}", ex.InnerException); + } + finally + { + _serverLock.Leave(); + } + } + + /// + /// Stop CWS instance + /// + public void Stop() + { + try + { + _serverLock.Enter(); + + if (_server == null) + { + Debug.Console(DebugInfo, this, "Server is null or has already been stopped"); + return; + } + + IsRegistered = _server.Unregister() == false; + + Debug.Console(DebugInfo, this, "Stopping server, unregistration {0}", IsRegistered ? "failed" : "was successful"); + + _server.Dispose(); + _server = null; + } + catch (Exception ex) + { + Debug.Console(DebugInfo, this, "Server Stop Exception Message: {0}", ex.Message); + Debug.Console(DebugVerbose, this, "Server Stop Exception StackTrace: {0}", ex.StackTrace); + if (ex.InnerException != null) + Debug.Console(DebugVerbose, this, "Server Stop Exception InnerException: {0}", ex.InnerException); + } + finally + { + _serverLock.Leave(); + } + } + + /// + /// Received request handler + /// + /// + /// This is here for development and testing + /// + /// + /// + public void ReceivedRequestEventHandler(object sender, HttpCwsRequestEventArgs args) + { + try + { + var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented); + Debug.Console(DebugVerbose, this, "RecieveRequestEventHandler Context:\x0d\x0a{0}", j); + } + catch (Exception ex) + { + Debug.Console(DebugInfo, this, "ReceivedRequestEventHandler Exception Message: {0}", ex.Message); + Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception StackTrace: {0}", ex.StackTrace); + if (ex.InnerException != null) + Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception InnerException: {0}", ex.InnerException); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/WebApi/Presets/Preset.cs b/src/PepperDash.Core/WebApi/Presets/Preset.cs new file mode 100644 index 00000000..bdbc5820 --- /dev/null +++ b/src/PepperDash.Core/WebApi/Presets/Preset.cs @@ -0,0 +1,87 @@ +using System; + +namespace PepperDash.Core.WebApi.Presets +{ + /// + /// Represents a preset + /// + public class Preset + { + /// + /// ID of preset + /// + public int Id { get; set; } + + /// + /// User ID + /// + public int UserId { get; set; } + + /// + /// Room Type ID + /// + public int RoomTypeId { get; set; } + + /// + /// Preset Name + /// + public string PresetName { get; set; } + + /// + /// Preset Number + /// + public int PresetNumber { get; set; } + + /// + /// Preset Data + /// + public string Data { get; set; } + + /// + /// Constructor + /// + public Preset() + { + PresetName = ""; + PresetNumber = 1; + Data = "{}"; + } + } + + /// + /// + /// + public class PresetReceivedEventArgs : EventArgs + { + /// + /// True when the preset is found + /// + public bool LookupSuccess { get; private set; } + + /// + /// S+ helper + /// + public ushort ULookupSuccess { get { return (ushort)(LookupSuccess ? 1 : 0); } } + + /// + /// The preset + /// + public Preset Preset { get; private set; } + + /// + /// For Simpl+ + /// + public PresetReceivedEventArgs() { } + + /// + /// Constructor + /// + /// + /// + public PresetReceivedEventArgs(Preset preset, bool success) + { + LookupSuccess = success; + Preset = preset; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/WebApi/Presets/User.cs b/src/PepperDash.Core/WebApi/Presets/User.cs new file mode 100644 index 00000000..c82824f6 --- /dev/null +++ b/src/PepperDash.Core/WebApi/Presets/User.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp; + +namespace PepperDash.Core.WebApi.Presets +{ + /// + /// + /// + public class User + { + /// + /// + /// + public int Id { get; set; } + + /// + /// + /// + public string ExternalId { get; set; } + + /// + /// + /// + public string FirstName { get; set; } + + /// + /// + /// + public string LastName { get; set; } + } + + + /// + /// + /// + public class UserReceivedEventArgs : EventArgs + { + /// + /// True when user is found + /// + public bool LookupSuccess { get; private set; } + + /// + /// For stupid S+ + /// + public ushort ULookupSuccess { get { return (ushort)(LookupSuccess ? 1 : 0); } } + + /// + /// + /// + public User User { get; private set; } + + /// + /// For Simpl+ + /// + public UserReceivedEventArgs() { } + + /// + /// Constructor + /// + /// + /// + public UserReceivedEventArgs(User user, bool success) + { + LookupSuccess = success; + User = user; + } + } + + /// + /// + /// + public class UserAndRoomMessage + { + /// + /// + /// + public int UserId { get; set; } + + /// + /// + /// + public int RoomTypeId { get; set; } + + /// + /// + /// + public int PresetNumber { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs new file mode 100644 index 00000000..0a9317bf --- /dev/null +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -0,0 +1,273 @@ +using System; +using Crestron.SimplSharp; // For Basic SIMPL# Classes +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.Net.Http; +using Crestron.SimplSharp.Net.Https; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PepperDash.Core.JsonToSimpl; + + +namespace PepperDash.Core.WebApi.Presets +{ + /// + /// Passcode client for the WebApi + /// + public class WebApiPasscodeClient : IKeyed + { + /// + /// Notifies when user received + /// + public event EventHandler UserReceived; + + /// + /// Notifies when Preset received + /// + public event EventHandler PresetReceived; + + /// + /// Unique identifier for this instance + /// + public string Key { get; private set; } + + //string JsonMasterKey; + + /// + /// An embedded JsonToSimpl master object. + /// + JsonToSimplGenericMaster J2SMaster; + + string UrlBase; + + string DefaultPresetJsonFilePath; + + User CurrentUser; + + Preset CurrentPreset; + + + /// + /// SIMPL+ can only execute the default constructor. If you have variables that require initialization, please + /// use an Initialize method + /// + public WebApiPasscodeClient() + { + } + + /// + /// Initializes the instance + /// + /// + /// + /// + /// + public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath) + { + Key = key; + //JsonMasterKey = jsonMasterKey; + UrlBase = urlBase; + DefaultPresetJsonFilePath = defaultPresetJsonFilePath; + + J2SMaster = new JsonToSimplGenericMaster(); + J2SMaster.SaveCallback = this.SaveCallback; + J2SMaster.Initialize(jsonMasterKey); + } + + /// + /// Gets the user for a passcode + /// + /// + public void GetUserForPasscode(string passcode) + { + // Bullshit duplicate code here... These two cases should be the same + // except for https/http and the certificate ignores + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/users/dopin"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + var jo = new JObject(); + jo.Add("pin", passcode); + req.ContentString = jo.ToString(); + + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + var resp = client.Dispatch(req); + var handler = UserReceived; + if (resp.Code == 200) + { + //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); + var user = JsonConvert.DeserializeObject(resp.ContentString); + CurrentUser = user; + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(user, true)); + } + else + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(null, false)); + } + + /// + /// + /// + /// + /// + public void GetPresetForThisUser(int roomTypeId, int presetNumber) + { + if (CurrentUser == null) + { + CrestronConsole.PrintLine("GetPresetForThisUser no user loaded"); + return; + } + + var msg = new UserAndRoomMessage + { + UserId = CurrentUser.Id, + RoomTypeId = roomTypeId, + PresetNumber = presetNumber + }; + + var handler = PresetReceived; + try + { + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + req.ContentString = JsonConvert.SerializeObject(msg); + + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + + // ask for the preset + var resp = client.Dispatch(req); + if (resp.Code == 200) // got it + { + //Debug.Console(1, this, "Received: {0}", resp.ContentString); + var preset = JsonConvert.DeserializeObject(resp.ContentString); + CurrentPreset = preset; + + //if there's no preset data, load the template + if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) + { + //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); + LoadDefaultPresetData(); + return; + } + + J2SMaster.LoadWithJson(preset.Data); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(preset, true)); + } + else // no existing preset + { + CurrentPreset = new Preset(); + LoadDefaultPresetData(); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); + } + } + catch (HttpException e) + { + var resp = e.Response; + Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code); + LoadDefaultPresetData(); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); + } + } + + void LoadDefaultPresetData() + { + CurrentPreset = null; + if (!File.Exists(DefaultPresetJsonFilePath)) + { + Debug.Console(0, this, "Cannot load default preset file. Saving will not work"); + return; + } + using (StreamReader sr = new StreamReader(DefaultPresetJsonFilePath)) + { + try + { + var data = sr.ReadToEnd(); + J2SMaster.SetJsonWithoutEvaluating(data); + CurrentPreset = new Preset() { Data = data, UserId = CurrentUser.Id }; + } + catch (Exception e) + { + Debug.Console(0, this, "Error reading default preset JSON: \r{0}", e); + } + } + } + + /// + /// + /// + /// + /// + public void SavePresetForThisUser(int roomTypeId, int presetNumber) + { + if (CurrentPreset == null) + LoadDefaultPresetData(); + //return; + + //// A new preset needs to have its numbers set + //if (CurrentPreset.IsNewPreset) + //{ + CurrentPreset.UserId = CurrentUser.Id; + CurrentPreset.RoomTypeId = roomTypeId; + CurrentPreset.PresetNumber = presetNumber; + //} + J2SMaster.Save(); // Will trigger callback when ready + } + + /// + /// After save operation on JSON master happens, send it to server + /// + /// + void SaveCallback(string json) + { + CurrentPreset.Data = json; + + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase)); + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + req.ContentString = JsonConvert.SerializeObject(CurrentPreset); + + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + try + { + var resp = client.Dispatch(req); + + // 201=created + // 204=empty content + if (resp.Code == 201) + CrestronConsole.PrintLine("Preset added"); + else if (resp.Code == 204) + CrestronConsole.PrintLine("Preset updated"); + else if (resp.Code == 209) + CrestronConsole.PrintLine("Preset already exists. Cannot save as new."); + else + CrestronConsole.PrintLine("Preset save failed: {0}\r", resp.Code, resp.ContentString); + } + catch (HttpException e) + { + + CrestronConsole.PrintLine("Preset save exception {0}", e.Response.Code); + } + } + } +} diff --git a/src/PepperDash.Core/XSigUtility/Serialization/IXSigSerialization.cs b/src/PepperDash.Core/XSigUtility/Serialization/IXSigSerialization.cs new file mode 100644 index 00000000..8303731e --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Serialization/IXSigSerialization.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using PepperDash.Core.Intersystem.Tokens; + +namespace PepperDash.Core.Intersystem.Serialization +{ + /// + /// Interface to determine XSig serialization for an object. + /// + public interface IXSigSerialization + { + /// + /// Serialize the sig data + /// + /// + IEnumerable Serialize(); + + /// + /// Deserialize the sig data + /// + /// + /// + /// + T Deserialize(IEnumerable tokens) where T : class, IXSigSerialization; + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Serialization/XSigSerializationException.cs b/src/PepperDash.Core/XSigUtility/Serialization/XSigSerializationException.cs new file mode 100644 index 00000000..8f3fc047 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Serialization/XSigSerializationException.cs @@ -0,0 +1,28 @@ +using System; + +namespace PepperDash.Core.Intersystem.Serialization +{ + /// + /// Class to handle this specific exception type + /// + public class XSigSerializationException : Exception + { + /// + /// default constructor + /// + public XSigSerializationException() { } + + /// + /// constructor with message + /// + /// + public XSigSerializationException(string message) : base(message) { } + + /// + /// constructor with message and innner exception + /// + /// + /// + public XSigSerializationException(string message, Exception inner) : base(message, inner) { } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Tokens/XSigAnalogToken.cs b/src/PepperDash.Core/XSigUtility/Tokens/XSigAnalogToken.cs new file mode 100644 index 00000000..58473362 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Tokens/XSigAnalogToken.cs @@ -0,0 +1,88 @@ +using System; + +namespace PepperDash.Core.Intersystem.Tokens +{ + /// + /// Represents an XSigAnalogToken + /// + public sealed class XSigAnalogToken : XSigToken, IFormattable + { + private readonly ushort _value; + + /// + /// Constructor + /// + /// + /// + public XSigAnalogToken(int index, ushort value) + : base(index) + { + // 10-bits available for analog encoded data + if (index >= 1024 || index < 0) + throw new ArgumentOutOfRangeException("index"); + + _value = value; + } + + /// + /// + /// + public ushort Value + { + get { return _value; } + } + + /// + /// + /// + public override XSigTokenType TokenType + { + get { return XSigTokenType.Analog; } + } + + /// + /// + /// + /// + public override byte[] GetBytes() + { + return new[] { + (byte)(0xC0 | ((Value & 0xC000) >> 10) | (Index - 1 >> 7)), + (byte)((Index - 1) & 0x7F), + (byte)((Value & 0x3F80) >> 7), + (byte)(Value & 0x7F) + }; + } + + /// + /// + /// + /// + /// + public override XSigToken GetTokenWithOffset(int offset) + { + if (offset == 0) return this; + return new XSigAnalogToken(Index + offset, Value); + } + + /// + /// + /// + /// + public override string ToString() + { + return Index + " = 0x" + Value.ToString("X4"); + } + + /// + /// + /// + /// + /// + /// + public string ToString(string format, IFormatProvider formatProvider) + { + return Value.ToString(format, formatProvider); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Tokens/XSigDigitalToken.cs b/src/PepperDash.Core/XSigUtility/Tokens/XSigDigitalToken.cs new file mode 100644 index 00000000..70ccc852 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Tokens/XSigDigitalToken.cs @@ -0,0 +1,85 @@ +using System; + +namespace PepperDash.Core.Intersystem.Tokens +{ + /// + /// Represents an XSigDigitalToken + /// + public sealed class XSigDigitalToken : XSigToken + { + private readonly bool _value; + + /// + /// + /// + /// + /// + public XSigDigitalToken(int index, bool value) + : base(index) + { + // 12-bits available for digital encoded data + if (index >= 4096 || index < 0) + throw new ArgumentOutOfRangeException("index"); + + _value = value; + } + + /// + /// + /// + public bool Value + { + get { return _value; } + } + + /// + /// + /// + public override XSigTokenType TokenType + { + get { return XSigTokenType.Digital; } + } + + /// + /// + /// + /// + public override byte[] GetBytes() + { + return new[] { + (byte)(0x80 | (Value ? 0 : 0x20) | ((Index - 1) >> 7)), + (byte)((Index - 1) & 0x7F) + }; + } + + /// + /// + /// + /// + /// + public override XSigToken GetTokenWithOffset(int offset) + { + if (offset == 0) return this; + return new XSigDigitalToken(Index + offset, Value); + } + + /// + /// + /// + /// + public override string ToString() + { + return Index + " = " + (Value ? "High" : "Low"); + } + + /// + /// + /// + /// + /// + public string ToString(IFormatProvider formatProvider) + { + return Value.ToString(formatProvider); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Tokens/XSigSerialToken.cs b/src/PepperDash.Core/XSigUtility/Tokens/XSigSerialToken.cs new file mode 100644 index 00000000..25ee3fd0 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Tokens/XSigSerialToken.cs @@ -0,0 +1,81 @@ +using System; +using System.Text; + +namespace PepperDash.Core.Intersystem.Tokens +{ + /// + /// Represents an XSigSerialToken + /// + public sealed class XSigSerialToken : XSigToken + { + private readonly string _value; + + /// + /// Constructor + /// + /// + /// + public XSigSerialToken(int index, string value) + : base(index) + { + // 10-bits available for serial encoded data + if (index >= 1024 || index < 0) + throw new ArgumentOutOfRangeException("index"); + + _value = value; + } + + /// + /// + /// + public string Value + { + get { return _value; } + } + + /// + /// + /// + public override XSigTokenType TokenType + { + get { return XSigTokenType.Serial; } + } + + /// + /// + /// + /// + public override byte[] GetBytes() + { + var serialBytes = String.IsNullOrEmpty(Value) ? new byte[0] : Encoding.GetEncoding(28591).GetBytes(Value); + + var xsig = new byte[serialBytes.Length + 3]; + xsig[0] = (byte)(0xC8 | (Index - 1 >> 7)); + xsig[1] = (byte)((Index - 1) & 0x7F); + xsig[xsig.Length - 1] = 0xFF; + + Buffer.BlockCopy(serialBytes, 0, xsig, 2, serialBytes.Length); + return xsig; + } + + /// + /// + /// + /// + /// + public override XSigToken GetTokenWithOffset(int offset) + { + if (offset == 0) return this; + return new XSigSerialToken(Index + offset, Value); + } + + /// + /// + /// + /// + public override string ToString() + { + return Index + " = \"" + Value + "\""; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Tokens/XSigToken.cs b/src/PepperDash.Core/XSigUtility/Tokens/XSigToken.cs new file mode 100644 index 00000000..4c00a2ed --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Tokens/XSigToken.cs @@ -0,0 +1,45 @@ +namespace PepperDash.Core.Intersystem.Tokens +{ + /// + /// Represents the base class for all XSig datatypes. + /// + public abstract class XSigToken + { + private readonly int _index; + + /// + /// Constructs an XSigToken with the specified index. + /// + /// Index for the data. + protected XSigToken(int index) + { + _index = index; + } + + /// + /// XSig 1-based index. + /// + public int Index + { + get { return _index; } + } + + /// + /// XSigToken type. + /// + public abstract XSigTokenType TokenType { get; } + + /// + /// Generates the XSig bytes for the corresponding token. + /// + /// XSig byte array. + public abstract byte[] GetBytes(); + + /// + /// Returns a new token if necessary with an updated index based on the specified offset. + /// + /// Offset to adjust the index with. + /// XSigToken + public abstract XSigToken GetTokenWithOffset(int offset); + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/Tokens/XSigTokenType.cs b/src/PepperDash.Core/XSigUtility/Tokens/XSigTokenType.cs new file mode 100644 index 00000000..26d6c123 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/Tokens/XSigTokenType.cs @@ -0,0 +1,23 @@ +namespace PepperDash.Core.Intersystem.Tokens +{ + /// + /// XSig token types. + /// + public enum XSigTokenType + { + /// + /// Digital signal datatype. + /// + Digital, + + /// + /// Analog signal datatype. + /// + Analog, + + /// + /// Serial signal datatype. + /// + Serial + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/XSigHelpers.cs b/src/PepperDash.Core/XSigUtility/XSigHelpers.cs new file mode 100644 index 00000000..4ea6f634 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/XSigHelpers.cs @@ -0,0 +1,239 @@ +using System; +using System.Linq; +using Crestron.SimplSharp.CrestronIO; +using PepperDash.Core.Intersystem.Serialization; +using PepperDash.Core.Intersystem.Tokens; + +/* + Digital (2 bytes) + 10C##### 0####### (mask = 11000000_10000000b -> 0xC080) + + Analog (4 bytes) + 11aa0### 0####### (mask = 11001000_10000000b -> 0xC880) + 0aaaaaaa 0aaaaaaa + + Serial (Variable length) + 11001### 0####### (mask = 11111000_10000000b -> 0xF880) + dddddddd ........ <- up to 252 bytes of serial data (255 - 3) + 11111111 <- denotes end of data +*/ + +namespace PepperDash.Core.Intersystem +{ + /// + /// Helper methods for creating XSig byte sequences compatible with the Intersystem Communications (ISC) symbol. + /// + /// + /// Indexing is not from the start of each signal type but rather from the beginning of the first defined signal + /// the Intersystem Communications (ISC) symbol. + /// + public static class XSigHelpers + { + /// + /// Forces all outputs to 0. + /// + /// Bytes in XSig format for clear outputs trigger. + public static byte[] ClearOutputs() + { + return new byte[] { 0xFC }; + } + + /// + /// Evaluate all inputs and re-transmit any digital, analog, and permanent serail signals not set to 0. + /// + /// Bytes in XSig format for send status trigger. + public static byte[] SendStatus() + { + return new byte[] { 0xFD }; + } + + /// + /// Get bytes for an IXSigStateResolver object. + /// + /// XSig state resolver. + /// Bytes in XSig format for each token within the state representation. + public static byte[] GetBytes(IXSigSerialization xSigSerialization) + { + return GetBytes(xSigSerialization, 0); + } + + /// + /// Get bytes for an IXSigStateResolver object, with a specified offset. + /// + /// XSig state resolver. + /// Offset to which the data will be aligned. + /// Bytes in XSig format for each token within the state representation. + public static byte[] GetBytes(IXSigSerialization xSigSerialization, int offset) + { + var tokens = xSigSerialization.Serialize(); + if (tokens == null) return new byte[0]; + using (var memoryStream = new MemoryStream()) + { + using (var tokenWriter = new XSigTokenStreamWriter(memoryStream)) + tokenWriter.WriteXSigData(xSigSerialization, offset); + + return memoryStream.ToArray(); + } + } + + /// + /// Get bytes for a single digital signal. + /// + /// 1-based digital index + /// Digital data to be encoded + /// Bytes in XSig format for digtial information. + public static byte[] GetBytes(int index, bool value) + { + return GetBytes(index, 0, value); + } + + /// + /// Get bytes for a single digital signal. + /// + /// 1-based digital index + /// Index offset. + /// Digital data to be encoded + /// Bytes in XSig format for digtial information. + public static byte[] GetBytes(int index, int offset, bool value) + { + return new XSigDigitalToken(index + offset, value).GetBytes(); + } + + /// + /// Get byte sequence for multiple digital signals. + /// + /// Starting index of the sequence. + /// Digital signal value array. + /// Byte sequence in XSig format for digital signal information. + public static byte[] GetBytes(int startIndex, bool[] values) + { + return GetBytes(startIndex, 0, values); + } + + /// + /// Get byte sequence for multiple digital signals. + /// + /// Starting index of the sequence. + /// Index offset. + /// Digital signal value array. + /// Byte sequence in XSig format for digital signal information. + public static byte[] GetBytes(int startIndex, int offset, bool[] values) + { + // Digital XSig data is 2 bytes per value + const int fixedLength = 2; + var bytes = new byte[values.Length * fixedLength]; + for (var i = 0; i < values.Length; i++) + Buffer.BlockCopy(GetBytes(startIndex++, offset, values[i]), 0, bytes, i * fixedLength, fixedLength); + + return bytes; + } + + /// + /// Get bytes for a single analog signal. + /// + /// 1-based analog index + /// Analog data to be encoded + /// Bytes in XSig format for analog signal information. + public static byte[] GetBytes(int index, ushort value) + { + return GetBytes(index, 0, value); + } + + /// + /// Get bytes for a single analog signal. + /// + /// 1-based analog index + /// Index offset. + /// Analog data to be encoded + /// Bytes in XSig format for analog signal information. + public static byte[] GetBytes(int index, int offset, ushort value) + { + return new XSigAnalogToken(index + offset, value).GetBytes(); + } + + /// + /// Get byte sequence for multiple analog signals. + /// + /// Starting index of the sequence. + /// Analog signal value array. + /// Byte sequence in XSig format for analog signal information. + public static byte[] GetBytes(int startIndex, ushort[] values) + { + return GetBytes(startIndex, 0, values); + } + + /// + /// Get byte sequence for multiple analog signals. + /// + /// Starting index of the sequence. + /// Index offset. + /// Analog signal value array. + /// Byte sequence in XSig format for analog signal information. + public static byte[] GetBytes(int startIndex, int offset, ushort[] values) + { + // Analog XSig data is 4 bytes per value + const int fixedLength = 4; + var bytes = new byte[values.Length * fixedLength]; + for (var i = 0; i < values.Length; i++) + Buffer.BlockCopy(GetBytes(startIndex++, offset, values[i]), 0, bytes, i * fixedLength, fixedLength); + + return bytes; + } + + /// + /// Get bytes for a single serial signal. + /// + /// 1-based serial index + /// Serial data to be encoded + /// Bytes in XSig format for serial signal information. + public static byte[] GetBytes(int index, string value) + { + return GetBytes(index, 0, value); + } + + /// + /// Get bytes for a single serial signal. + /// + /// 1-based serial index + /// Index offset. + /// Serial data to be encoded + /// Bytes in XSig format for serial signal information. + public static byte[] GetBytes(int index, int offset, string value) + { + return new XSigSerialToken(index + offset, value).GetBytes(); + } + + /// + /// Get byte sequence for multiple serial signals. + /// + /// Starting index of the sequence. + /// Serial signal value array. + /// Byte sequence in XSig format for serial signal information. + public static byte[] GetBytes(int startIndex, string[] values) + { + return GetBytes(startIndex, 0, values); + } + + /// + /// Get byte sequence for multiple serial signals. + /// + /// Starting index of the sequence. + /// Index offset. + /// Serial signal value array. + /// Byte sequence in XSig format for serial signal information. + public static byte[] GetBytes(int startIndex, int offset, string[] values) + { + // Serial XSig data is not fixed-length like the other formats + var dstOffset = 0; + var bytes = new byte[values.Sum(v => v.Length + 3)]; + for (var i = 0; i < values.Length; i++) + { + var data = GetBytes(startIndex++, offset, values[i]); + Buffer.BlockCopy(data, 0, bytes, dstOffset, data.Length); + dstOffset += data.Length; + } + + return bytes; + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/XSigTokenStreamReader.cs b/src/PepperDash.Core/XSigUtility/XSigTokenStreamReader.cs new file mode 100644 index 00000000..9d70d02e --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/XSigTokenStreamReader.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using Crestron.SimplSharp.CrestronIO; +using PepperDash.Core.Intersystem.Serialization; +using PepperDash.Core.Intersystem.Tokens; + +namespace PepperDash.Core.Intersystem +{ + /// + /// XSigToken stream reader. + /// + public sealed class XSigTokenStreamReader : IDisposable + { + private readonly Stream _stream; + private readonly bool _leaveOpen; + + /// + /// + /// XSigToken stream reader constructor. + /// + /// Input stream to read from. + /// Stream is null. + /// Stream cannot be read from. + public XSigTokenStreamReader(Stream stream) + : this(stream, false) { } + + /// + /// XSigToken stream reader constructor. + /// + /// Input stream to read from. + /// Determines whether to leave the stream open or not. + /// Stream is null. + /// Stream cannot be read from. + public XSigTokenStreamReader(Stream stream, bool leaveOpen) + { + if (stream == null) + throw new ArgumentNullException("stream"); + if (!stream.CanRead) + throw new ArgumentException("The specified stream cannot be read from."); + + _stream = stream; + _leaveOpen = leaveOpen; + } + + /// + /// Reads a 16-bit unsigned integer from the specified stream using Big Endian byte order. + /// + /// Input stream + /// Result + /// True if successful, otherwise false. + public static bool TryReadUInt16BE(Stream stream, out ushort value) + { + value = 0; + if (stream.Length < 2) + return false; + + var buffer = new byte[2]; + stream.Read(buffer, 0, 2); + value = (ushort)((buffer[0] << 8) | buffer[1]); + return true; + } + + /// + /// Read XSig token from the stream. + /// + /// XSigToken + /// Offset is less than 0. + public XSigToken ReadXSigToken() + { + ushort prefix; + if (!TryReadUInt16BE(_stream, out prefix)) + return null; + + if ((prefix & 0xF880) == 0xC800) // Serial data + { + var index = ((prefix & 0x0700) >> 1) | (prefix & 0x7F); + var n = 0; + const int maxSerialDataLength = 252; + var chars = new char[maxSerialDataLength]; + int ch; + while ((ch = _stream.ReadByte()) != 0xFF) + { + if (ch == -1) // Reached end of stream without end of data marker + return null; + + chars[n++] = (char)ch; + } + + return new XSigSerialToken((ushort)(index + 1), new string(chars, 0, n)); + } + + if ((prefix & 0xC880) == 0xC000) // Analog data + { + ushort data; + if (!TryReadUInt16BE(_stream, out data)) + return null; + + var index = ((prefix & 0x0700) >> 1) | (prefix & 0x7F); + var value = ((prefix & 0x3000) << 2) | ((data & 0x7F00) >> 1) | (data & 0x7F); + return new XSigAnalogToken((ushort)(index + 1), (ushort)value); + } + + if ((prefix & 0xC080) == 0x8000) // Digital data + { + var index = ((prefix & 0x1F00) >> 1) | (prefix & 0x7F); + var value = (prefix & 0x2000) == 0; + return new XSigDigitalToken((ushort)(index + 1), value); + } + + return null; + } + + /// + /// Reads all available XSig tokens from the stream. + /// + /// XSigToken collection. + public IEnumerable ReadAllXSigTokens() + { + var tokens = new List(); + XSigToken token; + while ((token = ReadXSigToken()) != null) + tokens.Add(token); + + return tokens; + } + + /// + /// Attempts to deserialize all XSig data within the stream from the current position. + /// + /// Type to deserialize the information to. + /// Deserialized object. + public T DeserializeStream() + where T : class, IXSigSerialization, new() + { + return new T().Deserialize(ReadAllXSigTokens()); + } + + /// + /// Disposes of the internal stream if specified to not leave open. + /// + public void Dispose() + { + if (!_leaveOpen) + _stream.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/XSigUtility/XSigTokenStreamWriter.cs b/src/PepperDash.Core/XSigUtility/XSigTokenStreamWriter.cs new file mode 100644 index 00000000..934f2c29 --- /dev/null +++ b/src/PepperDash.Core/XSigUtility/XSigTokenStreamWriter.cs @@ -0,0 +1,136 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Crestron.SimplSharp.CrestronIO; +using PepperDash.Core.Intersystem.Serialization; +using PepperDash.Core.Intersystem.Tokens; + +namespace PepperDash.Core.Intersystem +{ + /// + /// XSigToken stream writer. + /// + public sealed class XSigTokenStreamWriter : IDisposable + { + private readonly Stream _stream; + private readonly bool _leaveOpen; + + /// + /// + /// XSigToken stream writer constructor. + /// + /// Input stream to write to. + /// Stream is null. + /// Stream cannot be written to. + public XSigTokenStreamWriter(Stream stream) + : this(stream, false) { } + + /// + /// XSigToken stream writer constructor. + /// + /// Input stream to write to. + /// Determines whether to leave the stream open or not. + /// Stream is null. + /// Stream cannot be written to. + public XSigTokenStreamWriter(Stream stream, bool leaveOpen) + { + if (stream == null) + throw new ArgumentNullException("stream"); + if (!stream.CanWrite) + throw new ArgumentException("The specified stream cannot be written to."); + + _stream = stream; + _leaveOpen = leaveOpen; + } + + /// + /// Write XSig data gathered from an IXSigStateResolver to the stream. + /// + /// IXSigStateResolver object. + public void WriteXSigData(IXSigSerialization xSigSerialization) + { + WriteXSigData(xSigSerialization, 0); + } + + /// + /// Write XSig data gathered from an IXSigStateResolver to the stream. + /// + /// IXSigStateResolver object. + /// Index offset for each XSigToken. + public void WriteXSigData(IXSigSerialization xSigSerialization, int offset) + { + if (xSigSerialization == null) + throw new ArgumentNullException("xSigSerialization"); + + var tokens = xSigSerialization.Serialize(); + WriteXSigData(tokens, offset); + } + + /// + /// Write XSigToken to the stream. + /// + /// XSigToken object. + public void WriteXSigData(XSigToken token) + { + WriteXSigData(token, 0); + } + + /// + /// Write XSigToken to the stream. + /// + /// XSigToken object. + /// Index offset for each XSigToken. + public void WriteXSigData(XSigToken token, int offset) + { + WriteXSigData(new[] { token }, offset); + } + + /// + /// Writes an array of XSigTokens to the stream. + /// + /// XSigToken objects. + public void WriteXSigData(XSigToken[] tokens) + { + WriteXSigData(tokens.AsEnumerable()); + } + + /// + /// Write an enumerable collection of XSigTokens to the stream. + /// + /// XSigToken objects. + public void WriteXSigData(IEnumerable tokens) + { + WriteXSigData(tokens, 0); + } + + /// + /// Write an enumerable collection of XSigTokens to the stream. + /// + /// XSigToken objects. + /// Index offset for each XSigToken. + public void WriteXSigData(IEnumerable tokens, int offset) + { + if (offset < 0) + throw new ArgumentOutOfRangeException("offset", "Offset must be greater than or equal to 0."); + + if (tokens != null) + { + foreach (var token in tokens) + { + if (token == null) continue; + var bytes = token.GetTokenWithOffset(offset).GetBytes(); + _stream.Write(bytes, 0, bytes.Length); + } + } + } + + /// + /// Disposes of the internal stream if specified to not leave open. + /// + public void Dispose() + { + if (!_leaveOpen) + _stream.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj index b31ce8c6..b1406d69 100644 --- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj +++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj @@ -25,7 +25,6 @@ - @@ -33,4 +32,7 @@ + + + \ No newline at end of file diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj index 966d778f..49c05762 100644 --- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj +++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj @@ -24,10 +24,10 @@ pdbonly + - \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj index cc619d5b..8739c95b 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj +++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj @@ -33,9 +33,9 @@ - + false runtime diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj index c32f226f..1e887728 100644 --- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj +++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj @@ -38,7 +38,6 @@ - @@ -53,6 +52,7 @@ + false runtime diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj index 660daaa4..04ae0d86 100644 --- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj +++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj @@ -48,9 +48,9 @@ - +