//****************************************************************************************************** // Dnp3InputAdapter.cs - Gbtc // // Copyright © 2012, Grid Protection Alliance. All Rights Reserved. // // Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See // the NOTICE file distributed with this work for additional information regarding copyright ownership. // The GPA licenses this file to you under the MIT License (MIT), the "License"; you may // not use this file except in compliance with the License. You may obtain a copy of the License at: // // http://www.opensource.org/licenses/MIT // // Unless agreed to in writing, the subject software distributed under the License is distributed on an // "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the // License for the specific language governing permissions and limitations. // // Code Modification History: // ---------------------------------------------------------------------------------------------------- // 10/05/2012 - Adam Crain // Generated original version of source code. // 03/27/2014 - Adam Crain // Updated to used latest opendnp3 code. // 03/28/2014 - J. Ritchie Carroll // Attached to an adapter instance to proxy IDNP3Manager singleton log messages. // //****************************************************************************************************** using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Text; using System.Xml.Serialization; using Automatak.DNP3.Adapter; using Automatak.DNP3.Interface; using GSF; using GSF.Diagnostics; using GSF.IO; using GSF.TimeSeries.Adapters; namespace DNP3Adapters { /// /// Input adapter that reads measurements from a remote dnp3 endpoint. /// [Description("DNP3: Reads measurements from a remote dnp3 endpoint")] public class DNP3InputAdapter : InputAdapterBase { #region [ Members ] // Nested Types private class ChannelListener : IChannelListener { private readonly Action m_onStateChange; public ChannelListener(Action onStateChange) => m_onStateChange = onStateChange; public void OnStateChange(ChannelState state) => m_onStateChange(state); } // Class used to proxy dnp3 manager log entries to Iaon session private class IaonProxyLogHandler : ILogHandler { /// /// Handler for log entries. /// /// to handle. public void Log(LogEntry entry) { // We avoid race conditions by always making sure access to status proxy is locked - this only // contends with adapter initialization and disposal so contention will not be normal lock (s_adapters) { if (s_statusProxy is null || s_statusProxy.m_disposed) return; if ((entry.filter.Flags & LogFilters.ERROR) > 0) { // Expose errors through exception processor InvalidOperationException exception = new InvalidOperationException(FormatLogEntry(entry)); s_statusProxy.OnProcessException(MessageLevel.Error, exception); } else { // For other messages, we just expose as a normal status string message = FormatLogEntry(entry); if ((entry.filter.Flags & LogFilters.WARNING) > 0) s_statusProxy.OnStatusMessage(MessageLevel.Warning, message); else if ((entry.filter.Flags & LogFilters.DEBUG) > 0) s_statusProxy.OnStatusMessage(MessageLevel.Debug, message); else s_statusProxy.OnStatusMessage(MessageLevel.Info, message); } } } private static string FormatLogEntry(LogEntry entry) { StringBuilder entryText = new StringBuilder(); entryText.Append(entry.message); entryText.AppendFormat(" ({0})", LogFilters.GetFilterString(entry.filter.Flags)); if (!string.IsNullOrWhiteSpace(entry.location)) entryText.AppendFormat(" @ {0}", entry.location); return entryText.ToString(); } } // Constants private const double DefaultPollingInterval = 2.0D; private const double DefaultTimestampDifferentiation = 1.0D; // Fields private string m_commsFilePath; // Filename for the communication configuration file private string m_mappingFilePath; // Filename for the measurement mapping configuration file private TimeSpan m_pollingInterval; // Interval, in seconds, at which the adapter will poll the DNP3 device private MasterConfiguration m_masterConfig; // Configuration for the master set during the Initialize call private MeasurementMap m_measurementMap; // Configuration for the measurement map set during the Initialize call private TimeSeriesSOEHandler m_soeHandler; // Time-series sequence of events handler private IChannel m_channel; // Communications channel set during the AttemptConnection call and used in AttemptDisconnect private bool m_active; // Flag that determines if the port/master has been added so that the resource can be cleaned up private bool m_disposed; #endregion #region [ Constructors ] /// /// Creates a new instance of the class. /// public DNP3InputAdapter() { m_pollingInterval = TimeSpan.FromSeconds(DefaultPollingInterval); } #endregion #region [ Properties ] /// /// Gets or sets the name of the XML file from which the communication parameters will be read. /// [ConnectionStringParameter] [Description("Define the name of the XML file from which the communication configuration will be read. Include fully qualified path if file name is not in installation folder.")] public string CommsFilePath { get => m_commsFilePath; set => m_commsFilePath = value; } /// /// Gets or sets the name of the XML file from which the measurement mapping is read. /// [ConnectionStringParameter] [Description("Define the name of the XML file from which the communication configuration will be read. Include fully qualified path if file name is not in installation folder.")] public string MappingFilePath { get => m_mappingFilePath; set => m_mappingFilePath = value; } /// /// Gets or sets the interval, in seconds, at which the adapter will poll the DNP3 device. /// [ConnectionStringParameter] [Description("Define the interval, in seconds, at which the adapter will poll the DNP3 device.")] [DefaultValue(DefaultPollingInterval)] public double PollingInterval { get => m_pollingInterval.TotalSeconds; set => m_pollingInterval = TimeSpan.FromSeconds(value); } /// /// Gets or sets the time interval, in milliseconds, to insert between consecutive /// data points for a given signal that were received at the exact same time. /// [ConnectionStringParameter] [Description("Define the time interval, in milliseconds, to insert between consecutive data points for with the same ID and timestamp.")] [DefaultValue(DefaultTimestampDifferentiation)] public double TimestampDifferentiation { get => m_soeHandler?.TimestampDifferentiation.TotalMilliseconds ?? 0.0D; set { if (m_soeHandler != null) m_soeHandler.TimestampDifferentiation = TimeSpan.FromMilliseconds(value); } } /// /// Gets the flag indicating if this adapter supports temporal processing. /// public override bool SupportsTemporalProcessing => false; /// /// Gets flag that determines if the data input connects asynchronously. /// /// /// Derived classes should return true when data input source is connects asynchronously, otherwise return false. /// protected override bool UseAsyncConnect => false; #endregion #region [ Methods ] /// /// Releases the unmanaged resources used by the object and optionally releases the managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected override void Dispose(bool disposing) { if (m_disposed) return; try { if (!disposing) return; if (m_active) { m_active = false; if (m_channel != null) { // Shutdown the communications channel m_channel.Shutdown(); m_channel = null; } } // Detach from the time-series sequence of events new measurements event if (m_soeHandler != null) { m_soeHandler.NewMeasurements -= OnNewMeasurements; m_soeHandler = null; } lock (s_adapters) { // Remove this adapter from the available list s_adapters.Remove(this); // See if we are disposing the status proxy instance if (ReferenceEquals(s_statusProxy, this)) { // Attempt to find a new status proxy s_statusProxy = s_adapters.Count > 0 ? s_adapters[0] : null; } } } finally { m_disposed = true; // Prevent duplicate dispose. base.Dispose(disposing); // Call base class Dispose(). } } /// /// Initializes /// public override void Initialize() { Dictionary settings = Settings; base.Initialize(); if (!settings.TryGetValue("CommsFilePath", out m_commsFilePath) || string.IsNullOrWhiteSpace(m_commsFilePath)) throw new ArgumentException($"The required {nameof(CommsFilePath)} parameter was not specified"); m_commsFilePath = FilePath.GetAbsolutePath(m_commsFilePath); if (!File.Exists(m_commsFilePath)) throw new FileNotFoundException($"The specified {nameof(CommsFilePath)} parameter \"{m_commsFilePath}\" was not found"); if (!settings.TryGetValue("MappingFilePath", out m_mappingFilePath) || string.IsNullOrWhiteSpace(m_mappingFilePath)) throw new ArgumentException($"The required {nameof(MappingFilePath)} parameter was not specified"); m_mappingFilePath = FilePath.GetAbsolutePath(m_mappingFilePath); if (!File.Exists(m_mappingFilePath)) throw new FileNotFoundException($"The specified {nameof(MappingFilePath)} parameter \"{m_mappingFilePath}\" was not found"); if (settings.TryGetValue("PollingInterval", out string setting) && double.TryParse(setting, out double pollingInterval)) PollingInterval = pollingInterval; m_masterConfig = ReadConfig(m_commsFilePath); m_measurementMap = ReadConfig(m_mappingFilePath); m_soeHandler = new TimeSeriesSOEHandler(new MeasurementLookup(m_measurementMap)); m_soeHandler.NewMeasurements += OnNewMeasurements; // The TimestampDifferentiation property is a pass-through to the m_soeHandler.TimestampDifferentiation, // so m_soeHandler must be initialized before reading this property from the connection string if (settings.TryGetValue("TimestampDifferentiation", out setting) && double.TryParse(setting, out double timestampDifferentiation)) TimestampDifferentiation = timestampDifferentiation; else TimestampDifferentiation = DefaultTimestampDifferentiation; m_soeHandler.TimestampDifferentiation = TimeSpan.FromMilliseconds(TimestampDifferentiation); lock (s_adapters) { // Add adapter to list of available adapters s_adapters.Add(this); // If no adapter has been designated as the status proxy, might as well be this one if (s_statusProxy is null) s_statusProxy = this; } } /// /// Attempts to connect to data input source. /// /// /// Derived classes should attempt connection to data input source here. Any exceptions thrown /// by this implementation will result in restart of the connection cycle. /// protected override void AttemptConnection() { TcpClientConfig tcpConfig = m_masterConfig.client; string portName = tcpConfig.address + ":" + tcpConfig.port; TimeSpan minRetry = TimeSpan.FromMilliseconds(tcpConfig.minRetryMs); TimeSpan maxRetry = TimeSpan.FromMilliseconds(tcpConfig.maxRetryMs); TimeSpan reconnectDelay = TimeSpan.FromMilliseconds(tcpConfig.reconnectDelayMs); ChannelRetry channelRetry = new ChannelRetry(minRetry, maxRetry, reconnectDelay); IChannelListener channelListener = new ChannelListener(state => OnStatusMessage(MessageLevel.Info, portName + " - Channel state change: " + state)); IChannel channel = s_manager.AddTCPClient(portName, tcpConfig.level, channelRetry, new[] { new IPEndpoint(tcpConfig.address, tcpConfig.port) }, channelListener); m_channel = channel; IMaster master = channel.AddMaster(portName, m_soeHandler, DefaultMasterApplication.Instance, m_masterConfig.master); if (m_pollingInterval > TimeSpan.Zero) master.AddClassScan(ClassField.AllClasses, m_pollingInterval, m_soeHandler, TaskConfig.Default); master.Enable(); m_active = true; } /// /// Attempts to disconnect from data input source. /// /// /// Derived classes should attempt disconnect from data input source here. Any exceptions thrown /// by this implementation will be reported to host via event. /// protected override void AttemptDisconnection() { if (!m_active) return; m_active = false; if (m_channel != null) { m_channel.Shutdown(); m_channel = null; } } /// /// Gets a short one-line status of this . /// /// Maximum number of available characters for display. /// A short one-line summary of the current status of this . public override string GetShortStatus(int maxLength) => $"Received {ProcessedMeasurements:N0} measurements so far...".CenterText(maxLength); #endregion #region [ Static ] // Static Fields // DNP3 manager shared across all of the DNP3 input adapters, concurrency level defaults to number of processors private static readonly IDNP3Manager s_manager; // We maintain a list of dnp3 adapters that can be used as status adapters for proxying messages from manager private static readonly List s_adapters; private static DNP3InputAdapter s_statusProxy; // Static Constructor static DNP3InputAdapter() { s_adapters = new List(); s_manager = DNP3ManagerFactory.CreateManager(Environment.ProcessorCount, new IaonProxyLogHandler()); } // Static Methods private static T ReadConfig(string path) { XmlSerializer serializer = new XmlSerializer(typeof(T)); using (TextReader reader = new StreamReader(FilePath.GetAbsolutePath(path))) { return (T)serializer.Deserialize(reader); } } #endregion } }