//******************************************************************************************************
// 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
}
}