//******************************************************************************************************
// OutputAdapter.cs - Gbtc
//
// Copyright © 2010, 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:
// ----------------------------------------------------------------------------------------------------
// 03/14/2017 - J. Ritchie Carroll
// Generated original version of source code.
//
//******************************************************************************************************
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Text;
using System.Threading;
using System.Timers;
using GSF;
using GSF.Collections;
using GSF.Data;
using GSF.Diagnostics;
using GSF.IO;
using GSF.Threading;
using GSF.TimeSeries;
using GSF.TimeSeries.Adapters;
using InStep.eDNA.EzDNAApiNet;
using SerializationFormat = GSF.SerializationFormat;
using Timer = System.Timers.Timer;
namespace eDNAAdapters
{
///
/// Exports measurements to eDNA if the point tag or alternate tag corresponds to a eDNA point's tag name.
///
[Description("eDNA: Archives measurements to an eDNA server")]
public class OutputAdapter : OutputAdapterBase
{
#region [ Members ]
// Nested Types
[Serializable]
private class Point
{
private static readonly RadixCodec Base36 = RadixCodec.Radix36;
///
/// Base-36 encoded string of point ID value.
///
public readonly string ID;
///
/// Point data type, i.e., analog or digital.
///
public readonly DataType Type;
public Point(uint id, DataType type)
{
// Encode ID value to base-36 string
ID = Base36.Encode(id);
Type = type;
}
///
/// Point ID integer value decoded from base-36 string.
///
public uint IDValue => Base36.Decode(ID);
}
// Constants
private const string DataCacheSuffix = "_eDNAData.cache";
private const string MetaCacheSuffix = "_eDNAMeta.cache";
// Fields
private uint m_dataConnection; // eDNA server connection handle for data
private uint m_metaConnection; // eDNA server connection handle for meta-data
private readonly Timer m_connectionMonitor; // eDNA server connection monitor, optional
private readonly ConcurrentDictionary m_pointMap; // Persisted measurement signal ID to point map
private readonly LongSynchronizedOperation m_savePointMapCache; // Save point map cache operation
private Dictionary m_lastArchiveTimes; // Cache of last point archive times
private DateTime m_lastMetadataRefresh; // Tracks time of last meta-data refresh
private long m_processedMeasurements; // Total number of measurements processed so far
private volatile bool m_refreshingMetadata; // Flag that determines if meta-data is currently refreshing
private double m_metadataRefreshProgress; // Current meta-data refresh progress
private Ticks m_lastPointMapFlushTime; // Last time of point map file flush
private bool m_disposed; // Flag that determines if class is disposed
#endregion
#region [ Constructors ]
///
/// Creates a new
///
public OutputAdapter()
{
m_dataConnection = uint.MaxValue;
m_metaConnection = uint.MaxValue;
m_connectionMonitor = new Timer
{
Enabled = false,
AutoReset = true
};
m_connectionMonitor.Elapsed += m_connectionMonitor_Elapsed;
m_pointMap = new ConcurrentDictionary();
m_savePointMapCache = new LongSynchronizedOperation(SavePointMapCache, ex => OnProcessException(MessageLevel.Warning, ex));
m_savePointMapCache.IsBackground = false;
m_lastMetadataRefresh = DateTime.MinValue;
}
#endregion
#region [ Properties ]
///
/// Returns true to indicate that this is sending measurements to a historian, i.e., eDNA.
///
public override bool OutputIsForArchive => true;
///
/// Returns false to indicate that this will connect synchronously based on eDNA API operation.
///
protected override bool UseAsyncConnect => false;
///
/// Gets or sets the name of the eDNA primary server for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the name of the eDNA primary server for the adapter's connection.")]
public string PrimaryServer { get; set; }
///
/// Gets or sets the eDNA primary port for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the eDNA primary port for the adapter's connection.")]
[DefaultValue(Default.PrimaryPort)]
public ushort PrimaryPort { get; set; } = Default.PrimaryPort;
///
/// Gets or sets the name of the eDNA secondary server for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the name of the eDNA secondary server for the adapter's connection.")]
[DefaultValue(Default.SecondaryServer)]
public string SecondaryServer { get; set; } = Default.SecondaryServer;
///
/// Gets or sets the eDNA secondary port for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the eDNA secondary port for the adapter's connection.")]
[DefaultValue(Default.SecondaryPort)]
public ushort SecondaryPort { get; set; } = Default.SecondaryPort;
///
/// Gets or sets the eDNA site for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the eDNA site for the adapter's connection.")]
public string Site { get; set; }
///
/// Gets or sets the eDNA service for the adapter's connection.
///
[ConnectionStringParameter]
[Description("Defines the eDNA service for the adapter's connection.")]
public string Service { get; set; }
///
/// Gets or sets flag that determines if eDNA client API should acknowledge all process errors or protocol errors.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if eDNA client API should acknowledge all process errors or protocol errors.")]
[DefaultValue(Default.AcknowledgeDataPackets)]
public bool AcknowledgeDataPackets { get; set; } = Default.AcknowledgeDataPackets;
///
/// Gets or sets flag that determines if eDNA client API should enable its memory queue.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if eDNA client API should enable its memory queue.")]
[DefaultValue(Default.EnableQueuing)]
public bool EnableQueuing { get; set; } = Default.EnableQueuing;
///
/// Gets or sets flag that determines if eDNA client API should enable use of a local file system cache.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if eDNA client API should enable use of a local file system cache.")]
[DefaultValue(Default.EnableCaching)]
public bool EnableCaching { get; set; } = Default.EnableCaching;
///
/// Gets or sets the filename to be used for point map cache.
///
[ConnectionStringParameter]
[Description("Defines the path and filename to be used for the eDNA client API cache. Leave blank for cache name to target host working directory and be same as adapter name.")]
[DefaultValue(Default.LocalCacheFileName)]
public string LocalCacheFileName { get; set; } = Default.LocalCacheFileName;
///
/// Gets or sets flag that determines if eDNA client API cache should be cleared on startup.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if eDNA client API cache should be cleared on startup.")]
[DefaultValue(Default.ClearCacheOnStartup)]
public bool ClearCacheOnStartup { get; set; } = Default.ClearCacheOnStartup;
///
/// Gets or sets whether or not this adapter should automatically manage meta-data for eDNA points.
///
[ConnectionStringParameter]
[Description("Determines if this adapter should automatically manage meta-data for eDNA points (recommended).")]
[DefaultValue(Default.RunMetadataSync)]
public bool RunMetadataSync { get; set; } = Default.RunMetadataSync;
///
/// Gets or sets whether or not this adapter should automatically manage meta-data for eDNA points.
///
[ConnectionStringParameter]
[Description("Determines if this adapter should automatically create new tags when managing meta-data for eDNA points (recommended). Value will only be considered when RunMetadataSync is True.")]
[DefaultValue(Default.AutoCreateTags)]
public bool AutoCreateTags { get; set; } = Default.AutoCreateTags;
///
/// Gets or sets whether or not this adapter should automatically manage meta-data for eDNA points.
///
[ConnectionStringParameter]
[Description("Determines if this adapter should automatically update existing tags when managing meta-data for eDNA points (recommended). This will make the time-series library host application the master for maintaining eDNA tag meta-data (like the tag name); otherwise, when False, eDNA will be the master. Value will only be considered when RunMetadataSync is True.")]
[DefaultValue(Default.AutoUpdateTags)]
public bool AutoUpdateTags { get; set; } = Default.AutoUpdateTags;
///
/// Gets or sets the number of tag name prefixes, e.g., "SOURCE!", applied by subscriptions to remove from eDNA tag names.
///
[ConnectionStringParameter]
[Description("Defines the number of tag name prefixes applied by subscriptions, e.g., \"SOURCE!\", to remove from eDNA tag names. Value will only be considered when RunMetadataSync is True.")]
[DefaultValue(Default.TagNamePrefixRemoveCount)]
public int TagNamePrefixRemoveCount { get; set; } = Default.TagNamePrefixRemoveCount;
///
/// Gets or sets the filename to be used for point map cache.
///
[ConnectionStringParameter]
[Description("Defines the path and filename to be used for the point map cache file name. Leave blank for cache name to target host working directory and to be same as adapter name with a \"_PointMap.cache\" extension.")]
[DefaultValue(Default.PointMapCacheFileName)]
public string PointMapCacheFileName { get; set; } = Default.PointMapCacheFileName;
///
/// Gets or sets the default string to represent the digital set state.
///
[ConnectionStringParameter]
[Description("Defines the default string to represent the digital set state.")]
[DefaultValue(Default.DigitalSetString)]
public string DigitalSetString { get; set; } = Default.DigitalSetString;
///
/// Gets or sets the default string to represent the digital cleared state.
///
[ConnectionStringParameter]
[Description("Defines the default string to represent the digital cleared state.")]
[DefaultValue(Default.DigitalClearedString)]
public string DigitalClearedString { get; set; } = Default.DigitalClearedString;
///
/// Gets or sets flag that determines if eDNA client API INI configuration file should be validated to exist before starting adapter. When set to True, adapter will fail to start when INI file does not exist.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if eDNA client API INI configuration file should be validated to exist before starting adapter. When set to True, adapter will fail to start when INI file does not exist.")]
[DefaultValue(Default.ValidateINIFileExists)]
public bool ValidateINIFileExists { get; set; } = Default.ValidateINIFileExists;
///
/// Gets or sets the maximum time resolution, in seconds, for data points being archived, e.g., a value 1.0 would mean that data would be archived no more than once per second. A zero value indicates that all data should be archived.
///
[ConnectionStringParameter]
[Description("Defines the maximum time resolution, in seconds, for data points being archived, e.g., a value 1.0 would mean that data would be archived no more than once per second. A zero value indicates that all data should be archived.")]
[DefaultValue(Default.MaximumPointResolution)]
public double MaximumPointResolution { get; set; } = Default.MaximumPointResolution;
///
/// Gets or sets the interval, in milliseconds, to monitor eDNA connection. A zero value will disable the monitor.
///
[ConnectionStringParameter]
[Description("Defines the interval, in milliseconds, to monitor an eDNA connection. A zero value will disable the monitor.")]
[DefaultValue(Default.ConnectionMonitoringInterval)]
public int ConnectionMonitoringInterval { get; set; } = Default.ConnectionMonitoringInterval;
///
/// Gets or sets the timeout, in milliseconds, for writing data to eDNA connection. A value of -1 will wait indefinitely.
///
[ConnectionStringParameter]
[Description("Defines the timeout, in milliseconds, for writing data to eDNA connection. A value of -1 will wait indefinitely.")]
[DefaultValue(Default.WriteTimeout)]
public int WriteTimeout { get; set; } = Default.WriteTimeout;
///
/// Gets or sets flag that determines if each bit of digital words are expanded to individual points. Set to False to treat words as analog values.
///
[ConnectionStringParameter]
[Description("Defines flag that determines if each bit of digital words are expanded to individual points. Set to False to treat words as analog values.")]
[DefaultValue(Default.ExpandDigitalWordBits)]
public bool ExpandDigitalWordBits { get; set; } = Default.ExpandDigitalWordBits;
///
/// Gets the detailed status of the eDNA output adapter.
///
public override string Status
{
get
{
StringBuilder status = new StringBuilder();
status.Append(base.Status);
status.AppendLine();
status.AppendFormat(" eDNA primary server: {0}:{1}", PrimaryServer ?? "[undefined]", PrimaryPort);
status.AppendLine();
if (!string.IsNullOrWhiteSpace(SecondaryServer))
{
status.AppendFormat(" eDNA secondary server: {0}:{1}", SecondaryServer, SecondaryPort);
status.AppendLine();
}
status.AppendFormat(" eDNA site: {0}", Site);
status.AppendLine();
status.AppendFormat(" eDNA service: {0}", Service);
status.AppendLine();
status.AppendFormat(" Connected to server: {0}", m_dataConnection == uint.MaxValue ? "No" : $"Yes, handle = {m_dataConnection}");
status.AppendLine();
status.AppendFormat(" Monitoring interval: {0:N0}ms{1}", ConnectionMonitoringInterval, ConnectionMonitoringInterval <= 0 ? " - monitoring disabled" : "");
status.AppendLine();
status.AppendFormat(" Write timeout: {0}", WriteTimeout == Timeout.Infinite ? "No timeout defined" : $"{WriteTimeout:N0}ms");
status.AppendLine();
status.AppendFormat(" Expanding 16-bit digitals: {0}", ExpandDigitalWordBits);
status.AppendLine();
status.AppendFormat(" Maximum point resolution: {0:N3} seconds{1}", MaximumPointResolution, MaximumPointResolution <= 0.0D ? " - all data will be archived" : "");
status.AppendLine();
status.AppendFormat("Validating INI file exists: {0}", ValidateINIFileExists);
status.AppendLine();
status.AppendFormat("Acknowledging data packets: {0}", AcknowledgeDataPackets);
status.AppendLine();
status.AppendFormat(" Memory queuing enabled: {0}", EnableQueuing);
status.AppendLine();
status.AppendFormat(" Local cache enabled: {0}", EnableCaching);
status.AppendLine();
if (EnableCaching)
{
status.AppendFormat(" Clearing cache on startup: {0}", ClearCacheOnStartup);
status.AppendLine();
status.AppendFormat(" Local cache file: {0}", FilePath.TrimFileName($"{LocalCacheFileName}{DataCacheSuffix}", 51));
status.AppendLine();
}
status.AppendFormat(" Meta-data sync enabled: {0}", RunMetadataSync);
status.AppendLine();
if (RunMetadataSync)
{
status.AppendFormat(" Auto-create tags: {0}", AutoCreateTags);
status.AppendLine();
status.AppendFormat(" Auto-update tags: {0}", AutoUpdateTags);
status.AppendLine();
status.AppendFormat(" Tag prefixes to remove: {0:N0}", TagNamePrefixRemoveCount);
status.AppendLine();
}
status.AppendFormat(" Point map cache file: {0}", FilePath.TrimFileName(PointMapCacheFileName.ToNonNullString("[undefined]"), 51));
status.AppendLine();
status.AppendFormat(" Point map cache size: {0:N0} mappings", m_pointMap.Count);
status.AppendLine();
if (RunMetadataSync)
{
status.AppendFormat(" Meta-data connection: {0}", m_metaConnection == uint.MaxValue ? "Not connected" : $"Established, handle = {m_metaConnection}");
status.AppendLine();
status.AppendFormat(" Meta-data sync process: {0}, {1:0.00%} complete", m_refreshingMetadata ? "Active" : "Idle", m_metadataRefreshProgress);
status.AppendLine();
}
status.AppendFormat(" Points archived: {0:N0} - {1:#,##0}/second", Interlocked.Read(ref m_processedMeasurements), Interlocked.Read(ref m_processedMeasurements) / RunTime);
status.AppendLine();
return status.ToString();
}
}
#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)
{
try
{
if (disposing)
{
m_connectionMonitor.Elapsed -= m_connectionMonitor_Elapsed;
m_connectionMonitor.Dispose();
}
}
finally
{
m_disposed = true; // Prevent duplicate dispose.
base.Dispose(disposing); // Call base class Dispose().
}
}
}
///
/// Returns a brief status of this
///
/// Maximum number of characters in the status string
/// Status
public override string GetShortStatus(int maxLength)
{
return $"Archived {m_processedMeasurements:N0} measurements to eDNA.".CenterText(maxLength);
}
///
/// Initializes this .
///
public override void Initialize()
{
base.Initialize();
Dictionary settings = Settings;
string setting;
ushort ushortVal;
int intVal;
double doubleVal;
if (settings.TryGetValue(nameof(PrimaryServer), out setting) && !string.IsNullOrWhiteSpace(setting))
PrimaryServer = setting;
else
throw new ArgumentException($"Cannot initialize adapter: required connection string parameter \"{nameof(PrimaryServer)}\" is not defined.");
if (settings.TryGetValue(nameof(PrimaryPort), out setting) && ushort.TryParse(setting, out ushortVal))
PrimaryPort = ushortVal;
if (settings.TryGetValue(nameof(SecondaryServer), out setting) && !string.IsNullOrWhiteSpace(setting))
SecondaryServer = setting;
if (settings.TryGetValue(nameof(SecondaryPort), out setting) && ushort.TryParse(setting, out ushortVal))
SecondaryPort = ushortVal;
if (settings.TryGetValue(nameof(Site), out setting) && !string.IsNullOrWhiteSpace(setting))
Site = setting;
else
throw new ArgumentException($"Cannot initialize adapter: required connection string parameter \"{nameof(Site)}\" is not defined.");
if (settings.TryGetValue(nameof(Service), out setting) && !string.IsNullOrWhiteSpace(setting))
Service = setting;
else
throw new ArgumentException($"Cannot initialize adapter: required connection string parameter \"{nameof(Service)}\" is not defined.");
if (settings.TryGetValue(nameof(AcknowledgeDataPackets), out setting) && !string.IsNullOrWhiteSpace(setting))
AcknowledgeDataPackets = setting.ParseBoolean();
if (settings.TryGetValue(nameof(EnableQueuing), out setting) && !string.IsNullOrWhiteSpace(setting))
EnableQueuing = setting.ParseBoolean();
if (settings.TryGetValue(nameof(EnableCaching), out setting) && !string.IsNullOrWhiteSpace(setting))
EnableCaching = setting.ParseBoolean();
if (!settings.TryGetValue(nameof(LocalCacheFileName), out setting) || string.IsNullOrWhiteSpace(setting))
setting = Name;
LocalCacheFileName = FilePath.GetAbsolutePath(setting);
if (settings.TryGetValue(nameof(ClearCacheOnStartup), out setting) && !string.IsNullOrWhiteSpace(setting))
ClearCacheOnStartup = setting.ParseBoolean();
if (settings.TryGetValue(nameof(RunMetadataSync), out setting) && !string.IsNullOrWhiteSpace(setting))
RunMetadataSync = setting.ParseBoolean();
if (settings.TryGetValue(nameof(AutoCreateTags), out setting) && !string.IsNullOrWhiteSpace(setting))
AutoCreateTags = setting.ParseBoolean();
if (settings.TryGetValue(nameof(AutoUpdateTags), out setting) && !string.IsNullOrWhiteSpace(setting))
AutoUpdateTags = setting.ParseBoolean();
if (settings.TryGetValue(nameof(TagNamePrefixRemoveCount), out setting) && int.TryParse(setting, out intVal) && intVal >= 0)
TagNamePrefixRemoveCount = intVal;
if (!settings.TryGetValue(nameof(PointMapCacheFileName), out setting) || string.IsNullOrWhiteSpace(setting))
setting = $"{Name}_PointMap.cache";
PointMapCacheFileName = FilePath.GetAbsolutePath(setting);
if (settings.TryGetValue(nameof(DigitalSetString), out setting) && !string.IsNullOrWhiteSpace(setting))
DigitalSetString = setting;
if (settings.TryGetValue(nameof(DigitalClearedString), out setting) && !string.IsNullOrWhiteSpace(setting))
DigitalClearedString = setting;
if (settings.TryGetValue(nameof(ValidateINIFileExists), out setting) && !string.IsNullOrWhiteSpace(setting))
ValidateINIFileExists = setting.ParseBoolean();
if (ValidateINIFileExists)
{
string iniFile = string.Format(Default.IniFilePathFormat, Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData));
if (!File.Exists(iniFile))
throw new FileNotFoundException($"Cannot initialize adapter: eDNA client API INI configuration file \"{iniFile}\" does not exist.");
}
if (settings.TryGetValue(nameof(MaximumPointResolution), out setting) && double.TryParse(setting, out doubleVal) && doubleVal >= 0.0D)
MaximumPointResolution = doubleVal;
if (MaximumPointResolution > 0.0D)
m_lastArchiveTimes = new Dictionary();
if (settings.TryGetValue(nameof(ConnectionMonitoringInterval), out setting) && int.TryParse(setting, out intVal) && intVal >= 0)
ConnectionMonitoringInterval = intVal;
if (settings.TryGetValue(nameof(WriteTimeout), out setting) && int.TryParse(setting, out intVal) && intVal >= -1)
WriteTimeout = intVal;
InternalProcessQueue.ProcessTimeout = WriteTimeout;
if (settings.TryGetValue(nameof(ExpandDigitalWordBits), out setting) && !string.IsNullOrWhiteSpace(setting))
ExpandDigitalWordBits = setting.ParseBoolean();
}
private int ConnectToDNAService(out uint connection, string cacheSuffix)
{
string fileName = $"{LocalCacheFileName}{cacheSuffix}";
string cacheFileName = FilePath.GetFileName(fileName);
string cachePath = FilePath.GetDirectoryName(fileName);
int result = LinkMX.eDnaMxUniversalInitialize(out connection, AcknowledgeDataPackets, EnableQueuing, EnableCaching, int.MaxValue, cacheFileName, cachePath);
if (result != 0)
throw new EzDNAApiNetException($"Failed to initialize connection to eDNA Universal Service: {(LinkMXReturnStatus)result}", result);
if (EnableCaching && ClearCacheOnStartup)
{
result = LinkMX.eDnaMxDeleteCacheFiles(connection);
if (result != 0)
OnProcessException(MessageLevel.Warning, new EzDNAApiNetException($"Failed to delete local cache files: {(LinkMXReturnStatus)result}", result));
}
return LinkMX.eDnaMxUniversalDataConnect(connection, PrimaryServer, PrimaryPort, SecondaryServer, SecondaryPort);
}
///
/// Connects to the configured eDNA server.
///
protected override void AttemptConnection()
{
m_processedMeasurements = 0;
// Initialize connection eDNA client API
int result = ConnectToDNAService(out m_dataConnection, DataCacheSuffix);
string connectionInfo = $"{PrimaryServer}:{PrimaryPort}{(string.IsNullOrWhiteSpace(SecondaryServer) ? "" : $" / {SecondaryServer}:{SecondaryPort}")}";
switch (result)
{
case 0:
OnStatusMessage(MessageLevel.Info, $"Connected to eDNA service on {connectionInfo} - connection handle = {m_dataConnection}");
break;
case 1:
OnStatusMessage(MessageLevel.Info, $"Connected to primary eDNA service on {PrimaryServer}:{PrimaryPort} - connection handle = {m_dataConnection}");
break;
case 2:
OnStatusMessage(MessageLevel.Info, $"Connected to secondary eDNA service on {SecondaryServer}:{SecondaryPort} - connection handle = {m_dataConnection}");
break;
default:
throw new EzDNAApiNetException($"Failed to connect to eDNA service on {connectionInfo}: {(LinkMXReturnStatus)result}", result);
}
if (ConnectionMonitoringInterval > 0)
{
m_connectionMonitor.Interval = ConnectionMonitoringInterval;
m_connectionMonitor.Start();
}
}
///
/// Called when data output source connection is established.
///
protected override void OnConnected()
{
base.OnConnected();
// Refresh meta-data once connected
RefreshMetadata();
}
///
/// Closes this connections to the eDNA server.
///
protected override void AttemptDisconnection()
{
m_connectionMonitor.Stop();
if (m_dataConnection < uint.MaxValue)
LinkMX.eDnaMxUniversalCloseSocketSoft(m_dataConnection);
if (m_metaConnection < uint.MaxValue)
LinkMX.eDnaMxUniversalCloseSocketSoft(m_metaConnection);
m_dataConnection = uint.MaxValue;
m_metaConnection = uint.MaxValue;
}
///
/// Sorts measurements and sends them to the configured eDNA server in batches
///
/// Measurements to queue
protected override void ProcessMeasurements(IMeasurement[] measurements)
{
if ((object)measurements == null || measurements.Length <= 0 || m_dataConnection == uint.MaxValue)
return;
bool addedRecords = false;
int result;
foreach (IMeasurement measurement in measurements)
{
Guid signalID = measurement.Key.SignalID;
Point point;
// If adapter gets disabled while executing this thread - go ahead and exit
if (!Enabled)
return;
// Lookup point mapping for this measurement - if no mapping was found, move on to next measurement
if (!m_pointMap.TryGetValue(signalID, out point) || (object)point == null)
continue;
// Verify maximum per point archive resolution
if ((object)m_lastArchiveTimes != null && (measurement.Timestamp - m_lastArchiveTimes.GetOrAdd(signalID, measurement.Timestamp)).ToSeconds() < MaximumPointResolution)
continue;
// Separate seconds from milliseconds and convert seconds to have a Unix base value, i.e., midnight 1/1/1970
Ticks baselinedTicks = measurement.Timestamp.BaselinedTimestamp(BaselineTimeInterval.Second);
int seconds = (int)new UnixTimeTag(baselinedTicks).Value;
ushort milliseconds = (ushort)(measurement.Timestamp - baselinedTicks).ToMilliseconds();
double value = measurement.AdjustedValue;
// Queue measurement record to eDNA
if (point.Type == DataType.Digital)
{
ushort word = (ushort)value;
for (int i = 0; i < 16; i++)
{
ushort bit = (ushort)BitExtensions.BitVal(i);
bool digitalIsSet = (word & bit) > 0;
result = LinkMX.eDnaMxAddRec(m_dataConnection, $"{point.ID}-{(byte)i:x}", seconds, milliseconds, measurement.StateFlags.MapToStatus(DataType.Digital, digitalIsSet), digitalIsSet ? 1.0D : 0.0D);
if (result != 0)
OnProcessException(MessageLevel.Warning, new EzDNAApiNetException($"Failed to write measurement \"{measurement.Key}\" bit {i} to eDNA point \"{string.Format(Default.PointIDFormat, Site, Service, point.ID)}\": LinkMX.eDnaMxAddRec Exception: {(LinkMXReturnStatus)result}", result));
}
}
else
{
result = LinkMX.eDnaMxAddRec(m_dataConnection, point.ID, seconds, milliseconds, measurement.StateFlags.MapToStatus(), value);
if (result != 0)
OnProcessException(MessageLevel.Warning, new EzDNAApiNetException($"Failed to write measurement \"{measurement.Key}\" to eDNA point \"{string.Format(Default.PointIDFormat, Site, Service, point.ID)}\": LinkMX.eDnaMxAddRec Exception: {(LinkMXReturnStatus)result}", result));
}
if ((object)m_lastArchiveTimes != null)
m_lastArchiveTimes[signalID] = measurement.Timestamp;
Interlocked.Increment(ref m_processedMeasurements);
addedRecords = true;
}
// Commit new records
if (addedRecords)
{
result = LinkMX.eDnaMxFlushUniversalRecord(m_dataConnection, (int)LinkMXConstants.SET_REC);
if (result != 0)
throw new EzDNAApiNetException($"LinkMX.eDnaMxFlushUniversalRecord Exception: {(LinkMXReturnStatus)result}", result);
}
}
///
/// Sends updated meta-data to eDNA.
///
protected override void ExecuteMetadataRefresh()
{
if (!Initialized)
return;
try
{
// Lookup the data source table for active measurements
DataTable measurements = null;
if (DataSource?.Tables.Contains("ActiveMeasurements") ?? false)
measurements = DataSource.Tables["ActiveMeasurements"];
if ((object)measurements == null)
{
OnStatusMessage(MessageLevel.Warning, "Could not find \"ActiveMeasurements\" data source table - meta-data refresh canceled.");
return;
}
// Get inputs for this adapter
MeasurementKey[] inputMeasurements = InputMeasurementKeys;
if ((object)inputMeasurements == null || inputMeasurements.Length == 0)
{
OnStatusMessage(MessageLevel.Warning, "eDNA historian output adapter is not configured to receive any input measurements - meta-data refresh canceled.");
return;
}
// This meta-data "load" is performed out-of-band with the meta-data "save" operation that
// follows to reduce read-write contention with the eDNA database service
Dictionary metadataCache = QueryMetaDataForInputs(inputMeasurements, measurements);
int result;
// Connect to eDNA separately for meta-data refresh, this way meta-data refresh does not contend
// with time-series data updates. Have to maintain this connection as long as main connection is
// active because closing this connection will also close the main connection :p
if (m_metaConnection == uint.MaxValue)
{
result = ConnectToDNAService(out m_metaConnection, MetaCacheSuffix);
if (result < 0 || result > 2)
{
m_metaConnection = uint.MaxValue;
throw new EzDNAApiNetException($"Failed to connect to eDNA service for meta-data refresh: {(LinkMXReturnStatus)result}", result);
}
}
int previousMeasurementReportingInterval = MeasurementReportingInterval;
DateTime latestUpdateTime = DateTime.MinValue;
m_refreshingMetadata = RunMetadataSync;
// For first runs, don't report archived points until eDNA meta-data has been established
MeasurementReportingInterval = 0;
// Attempt to load point map from existing cache, if any
LoadPointMapCache();
if (m_pointMap.Count > 0)
MeasurementReportingInterval = previousMeasurementReportingInterval;
// Refresh initial point mappings (much of the meta-data may already exist)
RefreshPointMap(metadataCache, inputMeasurements);
// Exit now if meta-data is maintained by eDNA
if (!RunMetadataSync)
{
MeasurementReportingInterval = previousMeasurementReportingInterval;
return;
}
try
{
OnStatusMessage(MessageLevel.Info, "Beginning meta-data refresh...");
int processed = 0, total = inputMeasurements.Length;
AdoDataConnection database = null;
foreach (MeasurementKey key in inputMeasurements)
{
// If adapter gets disabled while executing this thread - go ahead and exit
if (!Enabled)
return;
// Lookup meta-data for currently defined input measurement
Guid signalID = key.SignalID;
DataRow[] rows = measurements.Select($"SignalID='{signalID}'");
DateTime updateTime;
Metadata metadata;
Point point;
// It's unlikely that we would encounter an undefined measurement, but if so move on to the next one
if (rows.Length <= 0)
{
m_pointMap.TryRemove(signalID, out point);
continue;
}
DataRow row = rows[0];
// Define data type for field
DataType dataType = row["SignalType"].ToString().Equals("DIGI", StringComparison.OrdinalIgnoreCase) ? DataType.Digital : DataType.Analog;
// Get point tag-name as defined in meta-data, adjusting as needed per TagNamePrefixRemoveCount
string tagName = GetAdjustedTagName(row["PointTag"].ToNonNullString().Trim());
// Use alternate tag if one is defined - note that digitals are an exception since they typically use this field for special labeling
if (!string.IsNullOrWhiteSpace(row["AlternateTag"].ToString()) && dataType != DataType.Digital)
tagName = row["AlternateTag"].ToString().Trim();
// It's unlikely that no tag name is defined for measurement, but if so move on to the next one
if (string.IsNullOrWhiteSpace(tagName))
{
m_pointMap.TryRemove(signalID, out point);
continue;
}
// Attempt to lookup eDNA point in point map
m_pointMap.TryGetValue(signalID, out point);
// Attempt to lookup eDNA metadata in cache
metadataCache.TryGetValue(signalID, out metadata);
// Determine last update time for this meta-data record
try
{
// See if ActiveMeasurements contains updated on column
if (measurements.Columns.Contains("UpdatedOn"))
{
updateTime = Convert.ToDateTime(row["UpdatedOn"]);
}
else
{
// Attempt to lookup last update time for record
if ((object)database == null)
database = new AdoDataConnection("systemSettings");
updateTime = Convert.ToDateTime(database.Connection.ExecuteScalar($"SELECT UpdatedOn FROM Measurement WHERE SignalID = '{signalID}'"));
}
}
catch (Exception)
{
updateTime = DateTime.UtcNow;
}
// Tracked latest update time in meta-data, this will become last meta-data refresh time
if (updateTime > latestUpdateTime)
latestUpdateTime = updateTime;
// If digital words are not being expanded as bits, treat them as analogs
if (dataType == DataType.Digital && !ExpandDigitalWordBits)
dataType = DataType.Analog;
// Attempt to create new eDNA point if it doesn't exist, or update it if measurement meta-data was updated or tag name doesn't match
if (AutoCreateTags && (object)point == null || AutoUpdateTags && (object)point != null && (updateTime > m_lastMetadataRefresh || !(metadata?.ReferenceField01.Equals(tagName, StringComparison.InvariantCultureIgnoreCase) ?? false) || point.Type != dataType))
{
try
{
// Create new point if needed
if ((object)point == null || point.Type != dataType)
point = new Point((uint)key.ID, dataType);
string units = "";
string pointType = dataType == DataType.Digital ? "DI" : "AI";
string digitalSet = dataType == DataType.Digital ? DigitalSetString : "";
string digitalCleared = dataType == DataType.Digital ? DigitalClearedString : "";
int values = dataType == DataType.Digital ? 16 : 1;
if (measurements.Columns.Contains("EngineeringUnits"))
units = row["EngineeringUnits"].ToNonNullString();
if (dataType == DataType.Digital)
{
if (measurements.Columns.Contains("DigitalSetString"))
{
string value = row["DigitalSetString"].ToString();
if (!string.IsNullOrWhiteSpace(value))
digitalSet = value;
}
if (measurements.Columns.Contains("DigitalClearedString"))
{
string value = row["DigitalClearedString"].ToString();
if (!string.IsNullOrWhiteSpace(value))
digitalCleared = value;
}
}
// Update point map
m_pointMap[signalID] = point;
// ReSharper disable once LoopVariableIsNeverChangedInsideLoop
for (int i = 0; i < values; i++)
{
string pointID = point.ID; // 8 chars < reliable base-36 encoded point ID
string longID = signalID.ToString(); // 60 chars < reliable signal ID (primary Guid)
string description = tagName; // 24 chars < truncated tag name
string extendedID = $"A{key.ID}"; // 128 chars < reliable point ID (not encoded)
string extendedDescription = $"{row["Description"]}"; // 224 chars < truncated description
string referenceField01 = tagName; // 250 chars < reliable tag name
string referenceField02 = $"{row["SignalReference"]}"; // 250 chars < reliable signal reference
string referenceField03 = $"{row["SignalType"]}"; // 250 chars < reliable signal type acronym
string referenceField04 = $"{row["Device"]}"; // 250 chars < reliable device acronym
string referenceField05 = $"{key}"; // 250 chars < reliable measurement key (Source:ID)
string referenceField06 = $"{row["Latitude"]},{row["Longitude"]}"; // 250 chars < reliable lat,long
string referenceField07 = $"{row["Company"]}"; // 250 chars < reliable company acronym
string referenceField08 = $"{row["Protocol"]}"; // 250 chars < reliable protocol acronym
string referenceField09 = metadata?.ReferenceField09 ?? ""; // 250 chars < spare reference 1
string referenceField10 = metadata?.ReferenceField10 ?? ""; // 250 chars < spare reference 2
if (dataType == DataType.Digital)
{
// Digital point ID with max 6 character base-36 encoded ID value, dash and hex bit suffix is no more than 8 bytes:
pointID = $"{pointID}-{(byte)i:x}"; // Format digital encoded point ID as "-"
extendedID = $"D{key.ID}-{i}"; // Format digital non encoded point ID as "D-"
description = referenceField01 = $"{description}-{i}"; // Suffix digital tag name with "-"
extendedDescription = $"Bit {i} of {extendedDescription}"; // Prefix digital description with bit number info
}
// Add new or update meta-data record, critical time-series library (TSL) mappings are as follows:
// LongID = Measurement.SignalID (used as primary TSL tag identifier)
// ExtendedID = "A{Measurement.PointID}" or "D{Measurement.PointID}-{bitN}"
// ReferenceField01 = Measurement.PointTag (or Measurement.AlternateTag if defined)
// ReferenceField05 = MeasurementKey (Source:ID)
result = LinkMX.eDnaMxAddConfigRec(m_metaConnection, pointID, longID, description, units, pointType,
false, 0, digitalSet, digitalCleared, false, 0.0D, false, 0.0D, false, 0.0D, false, 0.0D, false,
0.0D, false, 0.0D, true, false, 1, 0, int.MaxValue, 0.0D, 0, extendedID, extendedDescription);
if (result != 0)
throw new EzDNAApiNetException($"LinkMX.eDnaMxAddConfigRec Exception: {(LinkMXReturnStatus)result}", result);
result = LinkMX.eDnaMxFlushUniversalRecord(m_metaConnection, (int)LinkMXConstants.SET_CONFIGURATION_REC);
if (result != 0)
throw new EzDNAApiNetException($"LinkMX.eDnaMxFlushUniversalRecord Exception: {(LinkMXReturnStatus)result}", result);
// Update configuration reference fields that hold reliable meta-data mappings
result = LinkMX.eDnaMxAddConfigReferenceFields(m_metaConnection, pointID, longID, extendedID,
referenceField01, referenceField02, referenceField03, referenceField04, referenceField05,
referenceField06, referenceField07, referenceField08, referenceField09, referenceField10);
if (result != 0)
throw new EzDNAApiNetException($"LinkMX.eDnaMxAddConfigReferenceFields Exception: {(LinkMXReturnStatus)result}", result);
result = LinkMX.eDnaMxFlushUniversalRecord(m_metaConnection, (int)LinkMXConstants.SET_CONFIGURATION_REF_FIELDS);
if (result != 0)
throw new EzDNAApiNetException($"LinkMX.eDnaMxFlushUniversalRecord Exception: {(LinkMXReturnStatus)result}", result);
}
}
catch (Exception ex)
{
OnProcessException(MessageLevel.Warning, new InvalidOperationException($"Failed to add or update eDNA meta-data{(point == null ? "" : $" \"{string.Format(Default.PointIDFormat, Site, Service, point.ID)}\"")} for measurement \"[{key}]: {tagName}\": {ex.Message}", ex));
}
}
processed++;
m_metadataRefreshProgress = processed / (double)total;
if (processed % 500 == 0)
OnStatusMessage(MessageLevel.Info, $"Updated {processed:N0} eDNA tags and associated meta-data, {m_metadataRefreshProgress:0.00%} complete...");
// Queue up a save operation for point map cache
m_savePointMapCache.RunOnceAsync();
}
if ((object)database != null)
database.Dispose();
}
catch (Exception ex)
{
OnProcessException(MessageLevel.Warning, ex);
}
finally
{
m_refreshingMetadata = false;
}
if (Enabled)
{
m_lastMetadataRefresh = latestUpdateTime > DateTime.MinValue ? latestUpdateTime : DateTime.UtcNow;
OnStatusMessage(MessageLevel.Info, "Completed meta-data refresh successfully.");
}
// Restore original measurement reporting interval
MeasurementReportingInterval = previousMeasurementReportingInterval;
}
catch (Exception ex)
{
OnProcessException(MessageLevel.Warning, new InvalidOperationException($"Failed during meta-data refresh: {ex.Message}"));
}
}
private void LoadPointMapCache()
{
if (!File.Exists(PointMapCacheFileName))
return;
// Attempt to load point map from existing cache file
lock (m_savePointMapCache)
{
try
{
using (FileStream pointMapCache = File.Open(PointMapCacheFileName, FileMode.Open, FileAccess.Read, FileShare.Read))
{
// Deserialize last point map for quicker startup
Dictionary pointMap = Serialization.Deserialize>(pointMapCache, SerializationFormat.Binary);
// Copy cached set to active point map
foreach (KeyValuePair kvp in pointMap)
m_pointMap[kvp.Key] = kvp.Value;
OnStatusMessage(MessageLevel.Info, $"Loaded {pointMap.Count:N0} mappings from point map cache.");
// Use last write time of file as the last meta-data refresh time - rough value is OK
if (m_lastMetadataRefresh == DateTime.MinValue)
m_lastMetadataRefresh = File.GetLastWriteTimeUtc(PointMapCacheFileName);
}
}
catch (Exception ex)
{
OnProcessException(MessageLevel.Info, new InvalidOperationException($"Failed to load point map cache from file \"{PointMapCacheFileName}\"': {ex.Message}", ex));
}
}
}
// Do not call method directly, invoke via synchronized operation: m_savePointMapCache.RunOnceAsync();
private void SavePointMapCache()
{
if (m_pointMap.Count <= 0 || !Enabled)
return;
int spanSinceLastFlush = (int)(DateTime.UtcNow.Ticks - m_lastPointMapFlushTime).ToMilliseconds();
if (spanSinceLastFlush < 0)
spanSinceLastFlush = 0;
// Don't flush file more than once per 5 seconds
if (spanSinceLastFlush < 5000)
{
int count = 0, total = (5000 - spanSinceLastFlush) / 100;
while (Enabled && count < total)
{
count++;
Thread.Sleep(100);
}
}
// Cache point map for faster future eDNA adapter startup
lock (m_savePointMapCache)
{
try
{
using (FileStream pointMapCache = File.Create(PointMapCacheFileName))
Serialization.Serialize(m_pointMap.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), SerializationFormat.Binary, pointMapCache);
OnStatusMessage(MessageLevel.Info, $"Saved {m_pointMap.Count:N0} mappings to point map cache.");
}
catch (Exception ex)
{
OnProcessException(MessageLevel.Info, new InvalidOperationException($"Failed to save point map cache to file \"{PointMapCacheFileName}\": {ex.Message}", ex));
}
}
m_lastPointMapFlushTime = DateTime.UtcNow.Ticks;
}
///
/// Scan eDNA meta-data intersecting defined time-series meta-data filtering for inputs defined for this adapter.
///
/// Input measurements keys defined for this output adapter.
/// Available time-series meta-data table.
/// Matching eDNA meta-data map keyed by time-series signal ID.
private Dictionary QueryMetaDataForInputs(MeasurementKey[] inputMeasurements, DataTable measurements)
{
Dictionary metadataCache = new Dictionary();
// Get meta-data count
int count = 0, total = Metadata.Count(Site, Service);
// Fall back on measurement count if meta-data API returns zero
if (total < 0)
total = inputMeasurements.Length;
OnStatusMessage(MessageLevel.Info, $"Querying {total:N0} eDNA \"{Site}.{Service}\" meta-data records for matching inputs...");
Ticks startTime = DateTime.UtcNow.Ticks;
// Scan all meta-data for records that match adapter inputs. A full scan is needed because some key TSL
// meta-data mappings are stored in non-key reference fields and API functions perform an O(n) operation
// for lookups into non-key fields - so one full scan is better than a full scan per adapter input
foreach (Metadata record in Metadata.Query(new Metadata(/* search all */)))
{
Guid signalID;
bool foundMatch = false;
// LongID = Measurement.SignalID (used as primary TSL tag identifier)
if (!string.IsNullOrWhiteSpace(record.LongID) && Guid.TryParse(record.LongID, out signalID))
{
// See if an input exists that matches this meta-data record
if (inputMeasurements.Any(key => key.SignalID == signalID))
{
metadataCache[signalID] = record;
foundMatch = true;
}
}
// ReferenceField01 = Measurement.PointTag (or Measurement.AlternateTag if defined)
if (!foundMatch && !string.IsNullOrWhiteSpace(record.ReferenceField01))
{
DataRow[] rows;
// Lookup tag name in AlternateTag field first, this should take precedence if defined
rows = measurements.Select($"AlternateTag='{record.ReferenceField01}'");
if (rows.Length == 0)
{
// Lookup tag name in PointTag field
rows = measurements.Select($"PointTag='{record.ReferenceField01}'");
if (rows.Length > 0)
foundMatch = true;
}
else
{
foundMatch = true;
}
// If a match was found for tag name, attempt to parse its signal ID
if (foundMatch && Guid.TryParse(rows[0]["SignalID"].ToString(), out signalID))
{
// See if an input exists that matches this meta-data record
if (inputMeasurements.Any(key => key.SignalID == signalID))
metadataCache[signalID] = record;
}
}
if (count++ % 500 == 0 && count > 1)
OnStatusMessage(MessageLevel.Info, $"Queried {count:N0} eDNA \"{Site}.{Service}\" meta-data records, {metadataCache.Count:N0} matches, {count / (double)total:0.00%} complete...");
}
if (metadataCache.Count > 0)
OnStatusMessage(MessageLevel.Info, $"Loaded {metadataCache.Count:N0} matching eDNA \"{Site}.{Service}\" meta-data records in {(DateTime.UtcNow.Ticks - startTime).ToElapsedTimeString(3)}.");
else
OnStatusMessage(MessageLevel.Info, $"Found no matching eDNA \"{Site}.{Service}\" meta-data records - scan time: {(DateTime.UtcNow.Ticks - startTime).ToElapsedTimeString(3)}.");
return metadataCache;
}
private void RefreshPointMap(Dictionary metadataCache, MeasurementKey[] inputMeasurements)
{
OnStatusMessage(MessageLevel.Info, "Refreshing point mappings...");
List signalIDs = new List();
foreach (MeasurementKey key in inputMeasurements)
{
Guid signalID = key.SignalID;
Metadata metadata;
if (!m_pointMap.ContainsKey(signalID) && metadataCache.TryGetValue(signalID, out metadata))
m_pointMap[signalID] = new Point((uint)key.ID, metadata.DataType);
signalIDs.Add(signalID);
}
if (signalIDs.Count > 0)
{
// Determine which tags no longer exist
Point removedPoint;
HashSet signalIDsToRemove = new HashSet(m_pointMap.Keys);
// If there are existing tags that are not part of new updates, these need to be removed
signalIDsToRemove.ExceptWith(signalIDs);
if (signalIDsToRemove.Count > 0)
{
foreach (Guid signalID in signalIDsToRemove)
m_pointMap.TryRemove(signalID, out removedPoint);
OnStatusMessage(MessageLevel.Info, $"Detected {signalIDsToRemove.Count:N0} tags that have been removed from eDNA output - primary point map has been updated...");
}
if (m_pointMap.Count == 0)
OnStatusMessage(MessageLevel.Warning, "No eDNA tags were mapped to measurements - no point map exists so no points will be archived.");
}
else
{
if (m_pointMap.Count > 0)
OnStatusMessage(MessageLevel.Warning, $"No eDNA tags were mapped to measurements - existing point map with {m_pointMap.Count:N0} defined mappings remains in use.");
else
OnStatusMessage(MessageLevel.Warning, "No eDNA tags were mapped to measurements - no point map exists so no points will be archived.");
}
}
private string GetAdjustedTagName(string tagName)
{
if (TagNamePrefixRemoveCount < 1)
return tagName;
for (int i = 0; i < TagNamePrefixRemoveCount; i++)
{
int prefixIndex = tagName.IndexOf('!');
if (prefixIndex > -1 && prefixIndex + 1 < tagName.Length)
tagName = tagName.Substring(prefixIndex + 1);
else
break;
}
return tagName;
}
private void m_connectionMonitor_Elapsed(object sender, ElapsedEventArgs e)
{
bool reconnect = true;
if (LinkMX.ISeDnaMxUniversalConnected(m_dataConnection))
{
// Verify connection is up at socket level, API is not always accurate
IPAddress parseIP(string host)
{
if (string.IsNullOrWhiteSpace(host))
return IPAddress.None;
try
{
IPHostEntry result = Dns.GetHostEntry(host);
return result.AddressList.FirstOrDefault() ?? IPAddress.None;
}
catch
{
return IPAddress.TryParse(host, out IPAddress hostAddress) ? hostAddress : IPAddress.None;
}
}
IPAddress primaryIP = parseIP(PrimaryServer);
IPAddress secondaryIP = parseIP(SecondaryServer);
bool isEstablished(TcpConnectionInformation connection, IPAddress address, int port) =>
connection.RemoteEndPoint.Address.Equals(address) &&
connection.RemoteEndPoint.Port == port &&
connection.State == TcpState.Established;
reconnect = !IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections().Any(connection =>
isEstablished(connection, primaryIP, PrimaryPort) || isEstablished(connection, secondaryIP, SecondaryPort));
}
if (reconnect)
{
OnStatusMessage(MessageLevel.Warning, "Detected no active socket connections to eDNA service, restarting connection cycle...");
Start();
}
}
#endregion
}
}