//****************************************************************************************************** // PIRTInputAdapter.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: // ---------------------------------------------------------------------------------------------------- // 08/19/2012 - Ryan McCoy // Generated original version of source code. // 06/18/2014 - J. Ritchie Carroll // Updated code to use PIConnection instance. // 12/17/2014 - J. Ritchie Carroll // Updated to use AF-SDK // //****************************************************************************************************** using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using GSF; using GSF.Collections; using GSF.Diagnostics; using GSF.Threading; using GSF.TimeSeries; using GSF.TimeSeries.Adapters; using OSIsoft.AF; using OSIsoft.AF.Asset; using OSIsoft.AF.Data; using OSIsoft.AF.PI; namespace PIAdapters { /// /// Uses PI event pipes to deliver real-time PI data to GSF host /// [Description("OSI-PI: Reads real-time measurements from an OSI-PI server using AF-SDK.")] public class PIRTInputAdapter : InputAdapterBase { #region [ Members ] // Nested Types private class DataUpdateObserver : IObserver { public event EventHandler> DataUpdated; public event EventHandler Completed; private readonly Action m_exceptionHandler; public DataUpdateObserver(Action exceptionHandler) { m_exceptionHandler = exceptionHandler; } public void OnCompleted() => Completed?.Invoke(this, EventArgs.Empty); public void OnError(Exception error) => m_exceptionHandler?.Invoke(error); public void OnNext(AFDataPipeEvent value) { if (value.Action != AFDataPipeAction.Delete) DataUpdated?.Invoke(this, new EventArgs(value.Value)); } } // Fields private readonly ConcurrentDictionary m_tagKeyMap; // Map PI tag ID to GSFSchema measurement keys private readonly ShortSynchronizedOperation m_restartConnection; // Restart connection operation private readonly ShortSynchronizedOperation m_readEvents; // Reads PI events private readonly System.Timers.Timer m_eventTimer; // Timer to trigger event reader private PIConnection m_connection; // PI server connection private PIDataPipe m_dataPipe; // PI data pipe private List m_dataPoints; // Last list of subscribed data points private readonly DataUpdateObserver m_dataUpdateObserver; // Custom observer class for handling point updates private Ticks m_lastReceivedTimestamp; // Last received timestamp from PI event pipe private double m_lastReceivedValue; private bool m_disposed; #endregion #region [ Constructors ] /// /// Receives real-time updates from PI /// public PIRTInputAdapter() { m_tagKeyMap = new ConcurrentDictionary(); m_restartConnection = new ShortSynchronizedOperation(Start); m_readEvents = new ShortSynchronizedOperation(ReadEvents); m_dataUpdateObserver = new DataUpdateObserver(ex => OnProcessException(MessageLevel.Error, ex)); m_dataUpdateObserver.DataUpdated += m_dataUpdateObserver_DataUpdated; m_eventTimer = new System.Timers.Timer(1000.0D); m_eventTimer.Elapsed += m_eventTimer_Elapsed; m_eventTimer.AutoReset = true; m_eventTimer.Enabled = false; } #endregion #region [ Properties ] /// /// Returns false to indicate that this does NOT connect asynchronously /// protected override bool UseAsyncConnect => false; /// /// Returns false to indicate that this does NOT support temporal processing. /// Temporal processing is supported in a separate adapter that is not driven by event pipes. /// public override bool SupportsTemporalProcessing => false; /// /// Gets or sets the measurements that this has been requested to provide /// public override MeasurementKey[] RequestedOutputMeasurementKeys { get => base.RequestedOutputMeasurementKeys; set { base.RequestedOutputMeasurementKeys = value; if (value is not null && value.Any()) SubscribeToPointUpdates(value); } } /// /// Gets or sets the name of the PI server for the adapter's PI connection. /// [ConnectionStringParameter, Description("Defines the name of the PI server for the adapter's PI connection.")] public string ServerName { get; set; } /// /// Gets or sets the name of the PI user ID for the adapter's PI connection. /// [ConnectionStringParameter, Description("Defines the name of the PI user ID for the adapter's PI connection."), DefaultValue("")] public string UserName { get; set; } /// /// Gets or sets the password used for the adapter's PI connection. /// [ConnectionStringParameter, Description("Defines the password used for the adapter's PI connection."), DefaultValue("")] public string Password { get; set; } /// /// Gets or sets the timeout interval (in milliseconds) for the adapter's connection /// [ConnectionStringParameter, Description("Defines the timeout interval (in milliseconds) for the adapter's connection"), DefaultValue(PIConnection.DefaultConnectTimeout)] public int ConnectTimeout { get; set; } /// /// Last timestamp received from PI /// public DateTime LastReceivedTimestamp => m_lastReceivedTimestamp; /// /// Returns the status of the adapter /// public override string Status { get { StringBuilder status = new(); status.Append(base.Status); status.AppendFormat(" Last Received Timestamp: {0}", m_lastReceivedTimestamp.ToString("MM/dd/yyyy HH:mm:ss.fff")); status.AppendLine(); status.AppendFormat(" Last Received Value: {0:N3}", m_lastReceivedValue); status.AppendLine(); status.AppendFormat(" Latency of Last Received: {0}", (DateTime.UtcNow.Ticks - m_lastReceivedTimestamp).ToElapsedTimeString(3)); status.AppendLine(); if (m_dataPoints is not null) { status.AppendFormat(" PI Data Point Count: {0:N0}: {1}...", m_dataPoints.Count, m_dataPoints.Select(p => p.ID.ToString()).Take(5).ToDelimitedString(", ")); 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) return; try { if (!disposing) return; if (m_connection is not null) { m_connection.Disconnected -= m_connection_Disconnected; m_connection.Dispose(); m_connection = null; } m_dataPipe?.Dispose(); if (m_eventTimer is not null) { m_eventTimer.Elapsed -= m_eventTimer_Elapsed; m_eventTimer.Dispose(); } } finally { m_disposed = true; // Prevent duplicate dispose. base.Dispose(disposing); // Call base class Dispose(). } } /// /// Reads values from the connection string and prepares this for connecting to PI /// public override void Initialize() { base.Initialize(); //m_measurements = new List(); Dictionary settings = Settings; if (!settings.TryGetValue(nameof(ServerName), out string setting)) throw new InvalidOperationException("Server name is a required setting for PI connections. Please add a server in the format servername=myservername to the connection string."); ServerName = setting; if (settings.TryGetValue(nameof(UserName), out setting)) UserName = setting; else UserName = null; if (settings.TryGetValue(nameof(Password), out setting)) Password = setting; else Password = null; if (settings.TryGetValue(nameof(ConnectTimeout), out setting) && int.TryParse(setting, out int value)) ConnectTimeout = value; else ConnectTimeout = PIConnection.DefaultConnectTimeout; } /// /// Connects to the configured PI server. /// protected override void AttemptConnection() { m_connection = new PIConnection { ServerName = ServerName, UserName = UserName, Password = Password, ConnectTimeout = ConnectTimeout }; m_connection.Disconnected += m_connection_Disconnected; m_connection.Open(); m_dataPipe = new PIDataPipe(AFDataPipeType.Snapshot); m_dataPipe.Subscribe(m_dataUpdateObserver); if (AutoStart && OutputMeasurements is not null && OutputMeasurements.Any()) SubscribeToPointUpdates(this.OutputMeasurementKeys()); } /// /// Disconnects from the configured PI server if a connection is open /// protected override void AttemptDisconnection() { m_eventTimer.Enabled = false; if (m_dataPoints is not null) { m_dataPoints.Clear(); m_dataPoints = null; } if (m_dataPipe is not null) { m_dataPipe.Dispose(); m_dataPipe = null; } if (m_connection is not null) { m_connection.Dispose(); m_connection = null; } } /// /// Returns brief status with measurements processed /// /// /// public override string GetShortStatus(int maxLength) => $"Received {ProcessedMeasurements} measurements from PI...".CenterText(maxLength); private void SubscribeToPointUpdates(MeasurementKey[] keys) { OnStatusMessage(MessageLevel.Info, $"Subscribing to updates for {keys.Length} measurements..."); var query = from row in DataSource.Tables["ActiveMeasurements"].AsEnumerable() from key in keys where row["ID"].ToString() == key.ToString() select new { Key = key, AlternateTag = row["AlternateTag"].ToString(), PointTag = row["PointTag"].ToString() }; List dataPoints = new(); foreach (var row in query) { string tagName = row.PointTag; if (!string.IsNullOrWhiteSpace(row.AlternateTag)) tagName = row.AlternateTag; #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Looking up point tag '{tagName}'..."); #endif PIPoint point = GetPIPoint(m_connection.Server, tagName); if (point is not null) { #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Found point tag '{tagName}'..."); #endif dataPoints.Add(point); m_tagKeyMap[point.ID] = row.Key; } #if DEBUG else { OnStatusMessage(MessageLevel.Debug, $"DEBUG: Failed to find point tag '{tagName}'..."); } #endif } // Remove sign-ups for any existing point list if (m_dataPoints is not null) m_dataPipe.RemoveSignups(m_dataPoints); // Sign up for updates on selected points AFListResults initialEvents = m_dataPipe.AddSignupsWithInitEvents(dataPoints); #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Initial event count = {initialEvents.Results.Count}..."); #endif foreach (AFDataPipeEvent item in initialEvents.Results) { #if DEBUG OnStatusMessage(MessageLevel.Debug, "DEBUG: Found initial event for action..."); #endif if (item.Action != AFDataPipeAction.Delete) m_dataUpdateObserver_DataUpdated(this, new EventArgs(item.Value)); } m_dataPoints = dataPoints; m_eventTimer.Enabled = true; } private PIPoint GetPIPoint(PIServer server, string tagName) { PIPoint.TryFindPIPoint(server, tagName, out PIPoint point); return point; } private void m_eventTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { #if DEBUG OnStatusMessage(MessageLevel.Debug, "DEBUG: Timer elapsed..."); #endif m_readEvents.TryRunOnceAsync(); } private void ReadEvents() { if (m_dataPipe is null) return; #if DEBUG OnStatusMessage(MessageLevel.Debug, "DEBUG: Data pipe called for next 100 GetUpdateEvents..."); #endif AFListResults updateEvents = m_dataPipe.GetUpdateEvents(100); #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Update event count = {updateEvents.Count}..."); #endif foreach (AFDataPipeEvent item in updateEvents.Results) { #if DEBUG OnStatusMessage(MessageLevel.Debug, "DEBUG: Found update event for action..."); #endif if (item.Action != AFDataPipeAction.Delete) m_dataUpdateObserver_DataUpdated(this, new EventArgs(item.Value)); } } // PI data updated handler private void m_dataUpdateObserver_DataUpdated(object sender, EventArgs e) { #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Data observer event handler called with a new value: {Convert.ToDouble(e.Argument.Value):N3}..."); #endif AFValue value = e.Argument; #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Data observer event handler looking up point ID {value?.PIPoint.ID:N0} in table..."); #endif if (value is not null && m_tagKeyMap.TryGetValue(value.PIPoint.ID, out MeasurementKey key)) { #if DEBUG OnStatusMessage(MessageLevel.Debug, $"DEBUG: Data observer event handler found point ID {value.PIPoint.ID:N0} in table: {key}..."); #endif Measurement measurement = new(); measurement.Metadata = key.Metadata; measurement.Value = Convert.ToDouble(value.Value); measurement.Timestamp = value.Timestamp.UtcTime; OnNewMeasurements(new[] { measurement }); m_lastReceivedTimestamp = measurement.Timestamp; m_lastReceivedValue = measurement.Value; } #if DEBUG else { OnStatusMessage(MessageLevel.Debug, $"DEBUG: Data observer event handler did not find point ID {value.PIPoint.ID:N0} in table..."); } #endif } // Since we may get a plethora of these requests, we use a synchronized operation to restart once private void m_connection_Disconnected(object sender, EventArgs e) => m_restartConnection.RunOnceAsync(); #endregion } }