diff --git a/Source/Applications/openPDC/openPDC/App.config b/Source/Applications/openPDC/openPDC/App.config
index 92621ae074..0e8701249f 100755
--- a/Source/Applications/openPDC/openPDC/App.config
+++ b/Source/Applications/openPDC/openPDC/App.config
@@ -44,7 +44,7 @@
-
+
diff --git a/Source/Applications/openPDC/openPDC/AppDebug.config b/Source/Applications/openPDC/openPDC/AppDebug.config
index 7cd19db338..977f5a2425 100644
--- a/Source/Applications/openPDC/openPDC/AppDebug.config
+++ b/Source/Applications/openPDC/openPDC/AppDebug.config
@@ -46,7 +46,7 @@
-
+
diff --git a/Source/Applications/openPDC/openPDC/Startup.cs b/Source/Applications/openPDC/openPDC/Startup.cs
index 79f1c06b0a..a173bbd935 100644
--- a/Source/Applications/openPDC/openPDC/Startup.cs
+++ b/Source/Applications/openPDC/openPDC/Startup.cs
@@ -21,11 +21,6 @@
//
//******************************************************************************************************
-using System;
-using System.Security;
-using System.Web.Http;
-using System.Web.Http.Cors;
-using System.Web.Http.ExceptionHandling;
using GSF.IO;
using GSF.Web;
using GSF.Web.Hosting;
@@ -36,9 +31,16 @@
using ModbusAdapters;
using Newtonsoft.Json;
using openPDC.Adapters;
-using Owin;
using openPDC.Model;
+using Owin;
using PhasorWebUI;
+using Swashbuckle.Application;
+using System;
+using System.IO;
+using System.Security;
+using System.Web.Http;
+using System.Web.Http.Cors;
+using System.Web.Http.ExceptionHandling;
namespace openPDC
{
@@ -53,6 +55,11 @@ public override void Handle(ExceptionHandlerContext context)
public class Startup
{
+ ///
+ /// Gets the authentication options used for the hosted web server.
+ ///
+ public static AuthenticationOptions AuthenticationOptions { get; } = new AuthenticationOptions();
+
public void Configuration(IAppBuilder app)
{
// Add Content-Security Headers
@@ -62,7 +69,9 @@ public void Configuration(IAppBuilder app)
{
await next();
- if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
+ bool isSwaggerPath = context.Request.Path.Value?.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) == true;
+
+ if (!isSwaggerPath && !context.Response.Headers.ContainsKey("Content-Security-Policy"))
context.Response.Headers.Add("Content-Security-Policy", ["default-src: 'self'"]);
if (context.Request.Scheme == "https" && !context.Response.Headers.ContainsKey("Strict-Transport-Security"))
@@ -77,15 +86,16 @@ public void Configuration(IAppBuilder app)
}
});
- // Modify the JSON serializer to serialize dates as UTC - otherwise, timezone will not be appended
- // to date strings and browsers will select whatever timezone suits them
+ // Modify the JSON serializer to serialize dates as UTC - otherwise, timezone will not
+ // be appended to date strings and browsers will select whatever timezone suits them
JsonSerializerSettings settings = JsonUtility.CreateDefaultSerializerSettings();
settings.DateTimeZoneHandling = DateTimeZoneHandling.Utc;
JsonSerializer serializer = JsonSerializer.Create(settings);
GlobalHost.DependencyResolver.Register(typeof(JsonSerializer), () => serializer);
AppModel model = Program.Host.Model;
- // Load security hub into application domain before establishing SignalR hub configuration, initializing default status and exception handlers
+ // Load security hub into application domain before establishing SignalR hub
+ // configuration, initializing default status and exception handlers
try
{
using (new SecurityHub(
@@ -119,7 +129,7 @@ public void Configuration(IAppBuilder app)
Program.Host.LogException
))
{
- WebExtensions.AddEmbeddedResourceAssembly(hub.GetType().Assembly);
+ WebExtensions.AddEmbeddedResourceAssembly(hub.GetType().Assembly);
}
}
catch (Exception ex)
@@ -145,9 +155,8 @@ public void Configuration(IAppBuilder app)
// Enable GSF role-based security authentication
app.UseAuthentication(AuthenticationOptions);
- // Enable cross-domain scripting default policy - controllers can manually
- // apply "EnableCors" attribute to class or an action to override default
- // policy configured here
+ // Enable cross-domain scripting default policy - controllers can manually apply
+ // "EnableCors" attribute to class or an action to override default policy configured here
try
{
if (!string.IsNullOrWhiteSpace(model.Global.DefaultCorsOrigins))
@@ -180,6 +189,12 @@ public void Configuration(IAppBuilder app)
{
using (new GrafanaController()) { }
+ using (new DeviceController()) { }
+
+ using (new PhasorController()) { }
+
+ using (new DevicePhasorController()) { }
+
httpConfig.Routes.MapHttpRoute(
name: "CustomAPIs",
routeTemplate: "api/{controller}/{action}/{id}",
@@ -194,6 +209,30 @@ public void Configuration(IAppBuilder app)
// Set configuration to use reflection to setup routes
httpConfig.MapHttpAttributeRoutes();
+ // Configure Swagger UI for custom API documentation
+ try
+ {
+ httpConfig.EnableSwagger(c =>
+ {
+ c.SingleApiVersion("v1", "openPDC Custom APIs")
+ .Description("REST API endpoints for device management and phasor data in openPDC.");
+
+ string baseDir = AppDomain.CurrentDomain.BaseDirectory;
+
+ string openPDCXml = Path.Combine(baseDir, "openPDC.xml");
+ if (File.Exists(openPDCXml))
+ c.IncludeXmlComments(openPDCXml);
+
+ string adaptersXml = Path.Combine(baseDir, "openPDC.Adapters.xml");
+ if (File.Exists(adaptersXml))
+ c.IncludeXmlComments(adaptersXml);
+ }).EnableSwaggerUi();
+ }
+ catch (Exception ex)
+ {
+ Program.Host.LogException(new InvalidOperationException($"Failed to initialize Swagger: {ex.Message}", ex));
+ }
+
// Load the WebPageController class and assign its routes
app.UseWebApi(httpConfig);
@@ -208,8 +247,8 @@ private void Load_ModbusAssembly()
{
try
{
- // Wrap class reference in lambda function to force
- // assembly load errors to occur within the try-catch
+ // Wrap class reference in lambda function to force assembly load errors to occur
+ // within the try-catch
new Action(() =>
{
// Make embedded resources of Modbus poller available to web server
@@ -224,12 +263,7 @@ private void Load_ModbusAssembly()
Program.Host.LogException(new InvalidOperationException($"Failed to load Modbus assembly: {ex.Message}", ex));
}
}
-
- // Static Properties
- ///
- /// Gets the authentication options used for the hosted web server.
- ///
- public static AuthenticationOptions AuthenticationOptions { get; } = new AuthenticationOptions();
+ // Static Properties
}
-}
+}
\ No newline at end of file
diff --git a/Source/Applications/openPDC/openPDC/openPDC.csproj b/Source/Applications/openPDC/openPDC/openPDC.csproj
index 04a9641bf6..d85c4e1332 100755
--- a/Source/Applications/openPDC/openPDC/openPDC.csproj
+++ b/Source/Applications/openPDC/openPDC/openPDC.csproj
@@ -57,6 +57,7 @@
..\..\..\..\Build\Output\Debug\Applications\openPDC\
DEBUG;TRACE
prompt
+ ..\..\..\..\Build\Output\Debug\Applications\openPDC\openPDC.xml
4
false
AllRules.ruleset
@@ -73,6 +74,7 @@
4
AllRules.ruleset
false
+ ..\..\..\..\Build\Output\Release\Applications\openPDC\openPDC.xml
true
@@ -118,6 +120,10 @@
AllRules.ruleset
+
+ False
+ ..\..\..\Dependencies\GSF\Swashbuckle.Core.dll
+
False
..\..\..\Dependencies\GSF\AjaxMin.dll
diff --git a/Source/Applications/openPDC/openPDCSetup/openPDCSetup.wxs b/Source/Applications/openPDC/openPDCSetup/openPDCSetup.wxs
index 5b41089bf9..a1367d0655 100755
--- a/Source/Applications/openPDC/openPDCSetup/openPDCSetup.wxs
+++ b/Source/Applications/openPDC/openPDCSetup/openPDCSetup.wxs
@@ -1131,6 +1131,11 @@
+
+
+
+
+
diff --git a/Source/Dependencies/GSF/Swashbuckle.Core.dll b/Source/Dependencies/GSF/Swashbuckle.Core.dll
new file mode 100644
index 0000000000..4ae8c87de2
Binary files /dev/null and b/Source/Dependencies/GSF/Swashbuckle.Core.dll differ
diff --git a/Source/Libraries/openPDC.Adapters/Constants/StringConstant.cs b/Source/Libraries/openPDC.Adapters/Constants/StringConstant.cs
new file mode 100644
index 0000000000..05c0b95992
--- /dev/null
+++ b/Source/Libraries/openPDC.Adapters/Constants/StringConstant.cs
@@ -0,0 +1,14 @@
+namespace openPDC.Adapters.Constants
+{
+ internal static class StringConstant
+ {
+ #region [ Constants ]
+
+ internal const string Acronym = "Acronym";
+ internal const string DeviceID = "DeviceID";
+ internal const string SourceIndex = "SourceIndex";
+ internal const string SystemSettings = "systemSettings";
+
+ #endregion [ Constants ]
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Adapters/DeviceController.cs b/Source/Libraries/openPDC.Adapters/DeviceController.cs
new file mode 100644
index 0000000000..0acf7782c5
--- /dev/null
+++ b/Source/Libraries/openPDC.Adapters/DeviceController.cs
@@ -0,0 +1,1072 @@
+using GSF.Communication;
+using GSF.Data;
+using GSF.Data.Model;
+using GSF.Diagnostics;
+using GSF.PhasorProtocols;
+using GSF.Security.Model;
+using GSF.Web.Shared.Model;
+using openPDC.Adapters.Constants;
+using openPDC.Model;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using System.Web.Http;
+using System.Web.Http.Description;
+using System.Xml.Linq;
+
+namespace openPDC.Adapters
+{
+ ///
+ /// Controller for Device (PMU) operations in openPDC. Provides endpoints to query data from
+ /// devices registered in the system.
+ ///
+ public class DeviceController : ApiController
+ {
+ #region [ Members ]
+
+ private const int RetryBaseDelayMs = 1000;
+ private const int RetryMaxAttempts = 3;
+ private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(DeviceController), MessageClass.Application);
+
+ #endregion [ Members ]
+
+ #region [ Properties ]
+
+ ///
+ /// Gets the DataContext for database operations.
+ ///
+ private static AdoDataConnection DataContext
+ {
+ get
+ {
+ return new AdoDataConnection(StringConstant.SystemSettings);
+ }
+ }
+
+ #endregion [ Properties ]
+
+ #region [ Methods ]
+
+ ///
+ /// Gets all devices (PMUs) in the system.
+ ///
+ /// List of all registered devices.
+ /// Returns the list of devices
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetAllDevices()
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevices), "Querying all devices");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym);
+
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevices), $"Returned {devices.Count()} devices");
+ return Ok(devices);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetAllDevices), "Error querying devices", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets a specific device by Acronym.
+ ///
+ /// Device (PMU) acronym.
+ /// Specified device.
+ /// Returns the device
+ /// Device not found
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(DeviceDetail))]
+ public IHttpActionResult GetDeviceByAcronym(string acronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceByAcronym), $"Querying device with acronym: {acronym}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ RecordRestriction restriction = new("Acronym = {0}", acronym);
+ var device = deviceTable.QueryRecords(restriction: restriction).FirstOrDefault();
+
+ if (device == null)
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDeviceByAcronym), $"Device not found: {acronym}");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceByAcronym), $"Device found: {acronym}");
+ return Ok(device);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDeviceByAcronym), $"Error querying device {acronym}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets devices by company.
+ ///
+ /// Company acronym.
+ /// List of devices from the specified company.
+ /// Returns the list of devices
+ /// No devices found for the company
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetDevicesByCompany(string companyAcronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByCompany), $"Querying devices for company: {companyAcronym}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ RecordRestriction restriction = new("CompanyAcronym = {0}", companyAcronym);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym, restriction: restriction).ToList();
+
+ if (!devices.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDevicesByCompany), $"No devices found for company: {companyAcronym}");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByCompany), $"Returned {devices.Count} devices from company {companyAcronym}");
+ return Ok(devices);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDevicesByCompany), $"Error querying devices for company {companyAcronym}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets devices by protocol.
+ ///
+ /// Protocol name (e.g.: IeeeC37_118V1, SEL Fast Message).
+ /// List of devices using the specified protocol.
+ /// Returns the list of devices
+ /// No devices found for the protocol
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetDevicesByProtocol(string protocolName)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByProtocol), $"Querying devices for protocol: {protocolName}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ RecordRestriction restriction = new("ProtocolName = {0}", protocolName);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym, restriction: restriction).ToList();
+
+ if (!devices.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDevicesByProtocol), $"No devices found for protocol: {protocolName}");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByProtocol), $"Returned {devices.Count} devices for protocol {protocolName}");
+ return Ok(devices);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDevicesByProtocol), $"Error querying devices for protocol {protocolName}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets enabled or disabled devices.
+ ///
+ /// true for enabled, false for disabled.
+ /// List of devices filtered by status.
+ /// Returns the list of devices
+ /// No devices found with the specified status
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetDevicesByStatus(bool enabled)
+ {
+ try
+ {
+ string status = enabled ? "enabled" : "disabled";
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByStatus), $"Querying {status} devices");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ RecordRestriction restriction = new("Enabled = {0}", enabled ? 1 : 0);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym, restriction: restriction).ToList();
+
+ if (!devices.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDevicesByStatus), $"No {status} devices found");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesByStatus), $"Returned {devices.Count} {status} devices");
+ return Ok(devices);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDevicesByStatus), $"Error querying devices by status", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ [HttpGet]
+ public HttpResponseMessage Index()
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ ///
+ /// Update or Insert device.
+ ///
+ /// The device to update or insert.
+ /// Device created or updated successfully
+ /// Internal error processing the request
+ [HttpPost]
+ public IHttpActionResult UpsertDevice(Device device)
+ {
+ try
+ {
+ var deviceIdInDatabase = ExecuteWithRetry(() => UpsertDeviceRecord(device), nameof(UpsertDevice));
+ Log.Publish(MessageLevel.Info, nameof(UpsertDevice), $"Device {device.Acronym} upserted successfully");
+
+ return Ok(deviceIdInDatabase);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(UpsertDevice), $"Error upserting device", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Update or Insert a device using a .PmuConnection file generated by PMU Connection
+ /// Tester. Connects to the PMU to retrieve its configuration frame (device name, phasors)
+ /// exactly like the openPDCManager Input Device Wizard "Request Configuration" button.
+ /// Expects multipart/form-data: file (.PmuConnection), acronym (required), c (optional).
+ /// For concentrators with multiple PMUs, all child devices are saved under the provided acronym.
+ ///
+ /// Device(s) and phasors created or updated successfully
+ /// Invalid request or unable to connect to PMU
+ /// Internal error processing the request
+ [HttpPost]
+ public async Task UpsertDeviceByPmuConnectionFile()
+ {
+ try
+ {
+ var validRequest = await ValidateRequest();
+
+ ConnectionSettings settings;
+
+ using (var stream = new MemoryStream(validRequest.FileBytes))
+ settings = ParsePmuConnectionFile(stream, validRequest.Acronym);
+
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile),
+ $"Parsed: Protocol={settings.PhasorProtocol}, Transport={settings.TransportProtocol}, " +
+ $"PmuID={settings.PmuID}, FrameRate={settings.FrameRate}");
+
+ // Connect to the PMU and request its configuration frame, mirroring the
+ // openPDCManager "Request Configuration" flow.
+ string frameParserConnectionString = BuildFrameParserConnectionString(settings);
+
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile),
+ $"Requesting configuration frame from: {settings.ConnectionString}");
+
+ (int savedDeviceCount, string resultAcronym) = await ExecuteWithRetryAsync(async () =>
+ {
+ IConfigurationFrame configFrame = await RequestConfigurationFrameAsync(frameParserConnectionString);
+
+ if (configFrame == null)
+ throw new TimeoutException(
+ "Did not receive a configuration frame from the PMU within the timeout period.");
+
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile),
+ $"Received configuration frame with {configFrame.Cells.Count} device(s)");
+
+ int count = await ProcessConfigurationFrame(settings, configFrame, validRequest);
+ return (count, validRequest.Acronym);
+ }, nameof(UpsertDeviceByPmuConnectionFile));
+
+ return Ok(new { devices = savedDeviceCount, acronym = resultAcronym });
+ }
+ catch (TimeoutException)
+ {
+ return BadRequest("Did not receive a configuration frame from the PMU. " +
+ "Verify the connection parameters and that the device is reachable.");
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(UpsertDeviceByPmuConnectionFile),
+ "Error upserting device from .PmuConnection file", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Builds the MultiProtocolFrameParser connection string from deserialized .PmuConnection settings.
+ /// Format: phasorProtocol=X;accessID=Y;transportProtocol=Z;server=A;port=B;...
+ ///
+ private static string BuildFrameParserConnectionString(ConnectionSettings settings)
+ {
+ var sb = new StringBuilder();
+
+ sb.Append($"phasorProtocol={settings.PhasorProtocol};");
+
+ if (settings.PmuID > 0)
+ sb.Append($"accessID={settings.PmuID};");
+
+ sb.Append($"transportProtocol={settings.TransportProtocol};");
+
+ if (!string.IsNullOrEmpty(settings.ConnectionString))
+ sb.Append(settings.ConnectionString.TrimEnd(';'));
+
+ return sb.ToString().TrimEnd(';');
+ }
+
+ private static T ExecuteWithRetry(Func operation, string callerName)
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ return operation();
+ }
+ catch (Exception ex) when (IsTransientException(ex) && attempt < RetryMaxAttempts)
+ {
+ int delayMs = RetryBaseDelayMs * (int)Math.Pow(2, attempt - 1);
+ Log.Publish(MessageLevel.Warning, callerName,
+ $"Attempt {attempt}/{RetryMaxAttempts} failed ({ex.GetType().Name}): {ex.Message}. Retrying in {delayMs}ms...");
+ System.Threading.Thread.Sleep(delayMs);
+ }
+ }
+ }
+
+ private static async Task ExecuteWithRetryAsync(Func> operation, string callerName)
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ return await operation();
+ }
+ catch (Exception ex) when (IsTransientException(ex) && attempt < RetryMaxAttempts)
+ {
+ int delayMs = RetryBaseDelayMs * (int)Math.Pow(2, attempt - 1);
+ Log.Publish(MessageLevel.Warning, callerName,
+ $"Attempt {attempt}/{RetryMaxAttempts} failed ({ex.GetType().Name}): {ex.Message}. Retrying in {delayMs}ms...");
+ await Task.Delay(delayMs);
+ }
+ }
+ }
+
+ ///
+ /// Retrieves the protocol ID for the given PhasorProtocol enum value.
+ ///
+ private static int? GetProtocolID(PhasorProtocol phasorProtocol)
+ {
+ using AdoDataConnection context = DataContext;
+ TableOperations protocolTable = new(context);
+ var protocol = protocolTable.QueryRecordWhere("Acronym = {0}", phasorProtocol.ToString());
+ return protocol?.ID;
+ }
+
+ private static bool IsTransientException(Exception ex) =>
+ ex is TimeoutException ||
+ ex is System.Net.Sockets.SocketException ||
+ ex is System.IO.IOException ||
+ ex is System.Data.Common.DbException ||
+ ex is InvalidOperationException;
+
+ ///
+ /// Parses the SOAP XML of a .PmuConnection file and returns the connection settings. The
+ /// file is produced by PMU Connection Tester via SoapFormatter; reading the XML directly
+ /// avoids a dependency on the old TVA serialization assemblies.
+ ///
+ private static ConnectionSettings ParsePmuConnectionFile(Stream fileStream, string acronym)
+ {
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile), $"Parsing .PmuConnection file for device: {acronym}");
+
+ XDocument doc = XDocument.Load(fileStream);
+
+ XElement settingsElement = doc.Descendants()
+ .FirstOrDefault(e => e.Name.LocalName == "ConnectionSettings");
+
+ if (settingsElement == null)
+ throw new InvalidDataException(
+ "Invalid .PmuConnection file: ConnectionSettings element not found");
+
+ string GetValue(string localName) => settingsElement.Elements().FirstOrDefault(e => e.Name.LocalName == localName)?.Value;
+
+ int ParseInt(string localName, int fallback = 0)
+ {
+ string val = GetValue(localName);
+ return int.TryParse(val, out int result) ? result : fallback;
+ }
+
+ Enum.TryParse(GetValue("PhasorProtocol"), out PhasorProtocol phasorProtocol);
+ Enum.TryParse(GetValue("TransportProtocol"), out TransportProtocol transportProtocol);
+
+ return new ConnectionSettings
+ {
+ PhasorProtocol = phasorProtocol,
+ TransportProtocol = transportProtocol,
+ ConnectionString = GetValue("ConnectionString"),
+ PmuID = ParseInt("PmuID"),
+ FrameRate = ParseInt("FrameRate", 30)
+ };
+ }
+
+ private static string PmuSignalDescription(string suffix) => suffix switch
+ {
+ "FQ" => "Frequency",
+ "DF" => "Frequency Delta (dF/dT)",
+ "SF" => "Status Flags",
+ _ => suffix
+ };
+
+ ///
+ /// Connects to a PMU/PDC using MultiProtocolFrameParser (same engine as openPDC adapters)
+ /// and waits up to for its configuration frame. Returns
+ /// null on timeout.
+ ///
+ private static async Task RequestConfigurationFrameAsync(
+ string connectionString, int timeoutSeconds = 240)
+ {
+ var tcs = new TaskCompletionSource();
+
+ using var parser = new MultiProtocolFrameParser();
+ parser.ConnectionString = connectionString;
+ parser.AutoStartDataParsingSequence = true;
+ parser.SkipDisableRealTimeData = true;
+ parser.MaximumConnectionAttempts = 3;
+
+ parser.ReceivedConfigurationFrame += (sender, e) =>
+ tcs.TrySetResult(e.Argument);
+
+ parser.ConnectionException += (sender, e) =>
+ {
+ // Argument2 is the attempt number; fail only after the last attempt.
+ if (e.Argument2 >= parser.MaximumConnectionAttempts)
+ tcs.TrySetException(new InvalidOperationException(
+ $"Connection failed after {parser.MaximumConnectionAttempts} attempt(s): {e.Argument1?.Message}"));
+ };
+
+ try
+ {
+ parser.Start();
+
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds)));
+
+ if (completed != tcs.Task)
+ return null; // timeout
+
+ return await tcs.Task; // re-throws if faulted via ConnectionException
+ }
+ finally
+ {
+ parser.Stop();
+ }
+ }
+
+ ///
+ /// Resolves human-readable identifiers (acronym/name) into database IDs. Returns null for
+ /// each field whose identifier was empty or not found; logs a warning on a miss so the
+ /// caller is aware without aborting the whole import.
+ ///
+ private static DeviceMetadata ResolveDeviceMetadata(DeviceMetadata validRequest)
+ {
+ using AdoDataConnection context = DataContext;
+
+ TableOperations companyTable = new(context);
+ RecordRestriction restrictionCompany = new("Acronym = {0}", validRequest.CompanyAcronym);
+ var company = companyTable.QueryRecords(restriction: restrictionCompany).FirstOrDefault();
+
+ TableOperations historianTable = new(context);
+ RecordRestriction restrictionHistorian = new("Acronym = {0}", validRequest.HistorianAcronym);
+ var historian = historianTable.QueryRecords(restriction: restrictionHistorian).FirstOrDefault();
+
+ TableOperations vendorDeviceTable = new(context);
+ RecordRestriction restrictionVendorDevice = new("Name = {0}", validRequest.VendorDeviceName);
+ var vendorDevice = vendorDeviceTable.QueryRecords(restriction: restrictionVendorDevice).FirstOrDefault();
+
+ TableOperations interconnectionTable = new(context);
+ RecordRestriction restrictionInterconnection = new("Name = {0}", validRequest.InterconnectionName);
+ var interconnection = interconnectionTable.QueryRecords(restriction: restrictionInterconnection).FirstOrDefault();
+
+ var deviceMetadata = new DeviceMetadata
+ {
+ CompanyID = company?.ID,
+ HistorianID = historian?.ID,
+ VendorDeviceID = vendorDevice?.ID,
+ InterconnectionID = interconnection?.ID
+ };
+
+ return deviceMetadata;
+ }
+
+ ///
+ /// Converts a PMU station name into a valid openPDC device acronym (uppercase, alphanumeric
+ /// + underscore only).
+ ///
+ private static string SanitizeAcronym(string stationName)
+ {
+ if (string.IsNullOrWhiteSpace(stationName))
+ return "PMU_UNKNOWN";
+
+ return Regex.Replace(stationName.ToUpperInvariant().Trim(), @"[^A-Z0-9_]", "_")
+ .TrimStart('_');
+ }
+
+ ///
+ /// Inserts a new measurement or updates the existing one matched by SignalReference.
+ /// Preserves the SignalID (GUID) of existing records on update.
+ ///
+ private static void UpsertMeasurement(TableOperations measurementTable, Measurement measurement)
+ {
+ var existing = measurementTable.QueryRecordWhere("SignalReference = {0}", measurement.SignalReference);
+
+ if (existing == null)
+ {
+ measurement.SignalID = Guid.NewGuid();
+ measurementTable.AddNewRecord(measurement);
+ }
+ else
+ {
+ measurement.SignalID = existing.SignalID;
+ measurementTable.UpdateRecord(measurement, new RecordRestriction("SignalReference = {0}", measurement.SignalReference));
+ }
+ }
+
+ ///
+ /// Builds a Device object for the parent/main device (either concentrator or standalone PMU).
+ ///
+ private Device BuildParentDevice(DeviceMetadata validRequest,
+ bool isConcentrator,
+ int? protocolID,
+ ConnectionSettings settings,
+ IConfigurationFrame configFrame,
+ string deviceConnectionString,
+ DeviceMetadata deviceMetadata)
+ {
+ return new Device
+ {
+ Acronym = validRequest.Acronym,
+ Name = validRequest.Name,
+ IsConcentrator = isConcentrator,
+ ProtocolID = protocolID,
+ CompanyID = deviceMetadata.CompanyID,
+ HistorianID = deviceMetadata.HistorianID,
+ VendorDeviceID = deviceMetadata.VendorDeviceID,
+ InterconnectionID = deviceMetadata.InterconnectionID,
+ AccessID = isConcentrator
+ ? (int)configFrame.IDCode
+ : (int)configFrame.Cells.Cast().First().IDCode,
+ FramesPerSecond = settings.FrameRate > 0 ? settings.FrameRate : 30,
+ ConnectionString = deviceConnectionString,
+ Enabled = true,
+ AllowUseOfCachedConfiguration = true,
+ AutoStartDataParsingSequence = true,
+ ConnectOnDemand = true,
+ DataLossInterval = 5.0,
+ AllowedParsingExceptions = 10,
+ ParsingExceptionWindow = 5.0,
+ DelayedConnectionInterval = 5.0,
+ MeasurementReportingInterval = 100000,
+ };
+ }
+
+ ///
+ /// Processes all cells from the configuration frame, creating child devices (if
+ /// concentrator) and saving their phasor definitions and measurements.
+ ///
+ private void ProcessAllCells(IConfigurationFrame configFrame,
+ ConnectionSettings settings,
+ int parentDeviceID,
+ int? protocolID,
+ bool isConcentrator,
+ DeviceMetadata validRequest,
+ DeviceMetadata deviceMetadata,
+ ref int savedDeviceCount)
+ {
+ using AdoDataConnection context = DataContext;
+ TableOperations phasorTable = new(context);
+ TableOperations measurementTable = new(context);
+
+ foreach (IConfigurationCell cell in configFrame.Cells)
+ {
+ int targetDeviceID;
+ string targetAcronym;
+ string targetName;
+
+ if (isConcentrator)
+ {
+ targetDeviceID = ProcessAndSaveChildDevice(cell, settings, parentDeviceID, protocolID, deviceMetadata);
+ targetAcronym = SanitizeAcronym(cell.StationName);
+ targetName = cell.StationName;
+ savedDeviceCount++;
+ }
+ else
+ {
+ targetDeviceID = parentDeviceID;
+ targetAcronym = validRequest.Acronym;
+ targetName = validRequest.Name;
+ }
+
+ SavePhaseorsForCell(cell, targetDeviceID, phasorTable);
+ SaveMeasurementsForCell(cell, targetDeviceID, targetAcronym, targetName, deviceMetadata.HistorianID, measurementTable, context);
+ }
+ }
+
+ ///
+ /// Processes a cell from a concentrator, creating a child device record for it. Returns the
+ /// ID of the created or updated child device.
+ ///
+ private int ProcessAndSaveChildDevice(IConfigurationCell cell,
+ ConnectionSettings settings,
+ int parentDeviceID,
+ int? protocolID,
+ DeviceMetadata deviceMetadata)
+ {
+ string cellAcronym = SanitizeAcronym(cell.StationName);
+
+ var concentrator = new Device
+ {
+ Acronym = cellAcronym,
+ Name = cell.StationName,
+ IsConcentrator = false,
+ ProtocolID = protocolID,
+ CompanyID = deviceMetadata.CompanyID,
+ HistorianID = deviceMetadata.HistorianID,
+ VendorDeviceID = deviceMetadata.VendorDeviceID,
+ InterconnectionID = deviceMetadata.InterconnectionID,
+ AccessID = (int)cell.IDCode,
+ ParentID = parentDeviceID,
+ FramesPerSecond = settings.FrameRate > 0 ? settings.FrameRate : 30,
+ ConnectionString = string.Empty,
+ Enabled = true,
+ AllowUseOfCachedConfiguration = true,
+ AutoStartDataParsingSequence = true,
+ ConnectOnDemand = false,
+ DataLossInterval = 5.0,
+ AllowedParsingExceptions = 10,
+ ParsingExceptionWindow = 5.0,
+ DelayedConnectionInterval = 5.0,
+ MeasurementReportingInterval = 100000
+ };
+
+ return UpsertDeviceRecord(concentrator);
+ }
+
+ private async Task ProcessConfigurationFrame(ConnectionSettings settings, IConfigurationFrame configFrame, DeviceMetadata validRequest)
+ {
+ using AdoDataConnection context = DataContext;
+
+ int? protocolID = GetProtocolID(settings.PhasorProtocol);
+ bool isConcentrator = configFrame.Cells.Count > 1;
+ string deviceConnectionString = $"TransportProtocol={settings.TransportProtocol};{settings.ConnectionString}";
+
+ var deviceMetadata = ResolveDeviceMetadata(validRequest);
+
+ var parentDevice = BuildParentDevice(validRequest, isConcentrator, protocolID, settings, configFrame, deviceConnectionString, deviceMetadata);
+
+ var parentDeviceID = UpsertDeviceRecord(parentDevice);
+
+ int savedDeviceCount = 1;
+
+ ProcessAllCells(configFrame, settings, parentDeviceID, protocolID, isConcentrator, validRequest, deviceMetadata, ref savedDeviceCount);
+
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile),
+ $"Saved {savedDeviceCount} device(s) for acronym '{validRequest.Acronym}'");
+
+ return savedDeviceCount;
+ }
+
+ ///
+ /// Creates or updates all measurements for a configuration cell: PMU-level signals
+ /// (frequency, dF/dt, status flags), phasor magnitude/angle pairs, analog values, and
+ /// digital values. Matches openPDCManager's SaveDevice/SavePhasor measurement pattern.
+ /// PointTag format mirrors the Device Wizard: PMU signals :
+ /// {company}_{device}:{vendor}{abbreviation} e.g. GPA_SHELBY:SHELPMUF Phasors :
+ /// {company}_{device}-{suffix}{idx}:{vendor}{abbreviation} e.g. GPA_SHELBY-PM1:SHELPMУВ
+ /// Analog : {company}_{device}:{vendor}A{idx} Digital : {company}_{device}:{vendor}D{idx}
+ ///
+ private void SaveMeasurementsForCell(IConfigurationCell cell,
+ int deviceID,
+ string deviceAcronym,
+ string deviceName,
+ int? historianID,
+ TableOperations measurementTable,
+ AdoDataConnection context)
+ {
+ TableOperations deviceDetailTable = new(context);
+ var deviceDetail = deviceDetailTable.QueryRecordWhere("Acronym = {0}", deviceAcronym);
+ string companyAcronym = deviceDetail?.CompanyAcronym ?? string.Empty;
+
+ var nowTime = DateTime.Now;
+ var now = new DateTime(nowTime.Year, nowTime.Month, nowTime.Day, nowTime.Hour, nowTime.Minute, nowTime.Second, nowTime.Millisecond, DateTimeKind.Local);
+ var user = User.Identity.Name;
+
+ // Pre-load SignalType records. PMU types (FQ/DF/SF) carry their Acronym used verbatim
+ // in the tag. Phasor types are keyed by Suffix (PM/PA) in separate voltage/current
+ // maps; only the first char of Abbreviation is used in the phasor tag.
+ TableOperations sigTypeTable = new(context);
+
+ var pmuTypes = new Dictionary();
+ foreach (string pmuSuffix in new[] { "FQ", "DF", "SF" })
+ {
+ var st = sigTypeTable.QueryRecordWhere("Suffix = {0} AND Source = 'PMU'", pmuSuffix);
+ if (st?.ID > 0)
+ pmuTypes[pmuSuffix] = (st.ID, st.Acronym ?? pmuSuffix);
+ }
+
+ // Voltage phasors: VPHM (PM, Abbreviation='V') and VPHA (PA, Abbreviation='VH')
+ var voltagePhasorTypes = new Dictionary();
+ foreach (string acronym in new[] { "VPHM", "VPHA" })
+ {
+ var st = sigTypeTable.QueryRecordWhere("Acronym = {0}", acronym);
+ if (st?.ID > 0)
+ voltagePhasorTypes[st.Suffix] = (st.ID, st.Abbreviation ?? string.Empty);
+ }
+
+ // Current phasors: IPHM (PM, Abbreviation='I') and IPHA (PA, Abbreviation='IH')
+ var currentPhasorTypes = new Dictionary();
+ foreach (string acronym in new[] { "IPHM", "IPHA" })
+ {
+ var st = sigTypeTable.QueryRecordWhere("Acronym = {0}", acronym);
+ if (st?.ID > 0)
+ currentPhasorTypes[st.Suffix] = (st.ID, st.Abbreviation ?? string.Empty);
+ }
+
+ var alogST = sigTypeTable.QueryRecordWhere("Acronym = {0}", "ALOG");
+ var digiST = sigTypeTable.QueryRecordWhere("Acronym = {0}", "DIGI");
+
+ // Phasors are saved by SavePhaseorsForCell before this call; read their Phase values so
+ // the PointTag reflects the correct phase. Default is '+' (positive sequence).
+ TableOperations phasorTable = new(context);
+ var savedPhasors = phasorTable
+ .QueryRecords(restriction: new RecordRestriction("DeviceID = {0}", deviceID))
+ .ToDictionary(p => p.SourceIndex, p => p.Phase ?? "+");
+
+ // PMU-level signals — PointTag: {company}_{device}:{SignalType.Acronym} Matches
+ // expression: [?Source!=Phasor[?Acronym!=ALOG[:{SignalType.Acronym}]]]
+ foreach (string suffix in new[] { "FQ", "DF", "SF" })
+ {
+ if (!pmuTypes.TryGetValue(suffix, out var pmuType))
+ continue;
+
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ HistorianID = historianID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{pmuType.Acronym}",
+ SignalTypeID = pmuType.ID,
+ SignalReference = $"{deviceAcronym}-{suffix}",
+ Description = $"{deviceName} {PmuSignalDescription(suffix)}",
+ Internal = true,
+ Enabled = true,
+ Adder = 0.0d,
+ Multiplier = 1.0d,
+ CreatedBy = user,
+ UpdatedBy = user,
+ CreatedOn = now,
+ UpdatedOn = now
+ });
+ }
+
+ // Phasor measurements: magnitude (PM) and angle (PA) for each defined phasor.
+ // PointTag: {company}_{device}:{cleanLabel}_{Abbr[0]}{phaseStr}[.MAG|.ANG]
+ // Replicates: eval{Label.Trim().ToUpper().Replace(' ','_')}_eval{Abbr.Substring(0,1)} eval{Phase=='+'?'1':(Phase=='-'?'2':Phase)}[.MAG|.ANG]
+ int phasorIndex = 1;
+ foreach (IPhasorDefinition phasorDef in cell.PhasorDefinitions)
+ {
+ string label = phasorDef.Label?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrEmpty(label) || label.Equals("unused", StringComparison.OrdinalIgnoreCase))
+ {
+ phasorIndex++;
+ continue;
+ }
+
+ bool isVoltage = phasorDef.PhasorType == GSF.Units.EE.PhasorType.Voltage;
+ var phasorTypes = isVoltage ? voltagePhasorTypes : currentPhasorTypes;
+
+ string phase = savedPhasors.TryGetValue(phasorIndex, out string savedPhase) ? savedPhase : "+";
+ string phaseStr = phase == "+" ? "1" : (phase == "-" ? "2" : phase);
+ string cleanLabel = label.ToUpper().Replace(' ', '_');
+
+ foreach (string sfx in new[] { "PM", "PA" })
+ {
+ if (!phasorTypes.TryGetValue(sfx, out var phasorType))
+ continue;
+
+ string abbrFirst = phasorType.Abbreviation.Length > 0
+ ? phasorType.Abbreviation.Substring(0, 1)
+ : string.Empty;
+ string tagSuffix = sfx == "PM" ? ".MAG" : ".ANG";
+ string measurementLabel = sfx == "PM"
+ ? (isVoltage ? "Voltage Magnitude" : "Current Magnitude")
+ : (isVoltage ? "Voltage Angle" : "Current Angle");
+
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ HistorianID = historianID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{cleanLabel}_{abbrFirst}{phaseStr}{tagSuffix}",
+ SignalTypeID = phasorType.ID,
+ PhasorSourceIndex = phasorIndex,
+ SignalReference = $"{deviceAcronym}-{sfx}{phasorIndex}",
+ Description = $"{deviceName} {label} {measurementLabel}",
+ Internal = true,
+ Enabled = true,
+ Adder = 0.0d,
+ Multiplier = 1.0d,
+ CreatedBy = user,
+ UpdatedBy = user,
+ CreatedOn = now,
+ UpdatedOn = now
+ });
+ }
+
+ phasorIndex++;
+ }
+
+ // Analog values — PointTag: {company}_{device}:{cleanLabel} or :ALOG{idx:D2}
+ // Replicates: [?Acronym=ALOG[:eval{Label.Length>0?Label.Trim().ToUpper():ALOG+idx:D2}]]
+ if (alogST?.ID > 0)
+ {
+ int analogIndex = 1;
+ foreach (IAnalogDefinition analogDef in cell.AnalogDefinitions)
+ {
+ string analogLabel = analogDef.Label?.Trim() ?? string.Empty;
+ string analogTag = !string.IsNullOrEmpty(analogLabel)
+ ? analogLabel.ToUpper().Replace(' ', '_')
+ : $"ALOG{analogIndex:D2}";
+
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ HistorianID = historianID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{analogTag}",
+ SignalTypeID = alogST.ID,
+ SignalReference = $"{deviceAcronym}-AV{analogIndex}",
+ Description = $"{deviceName} Analog Value {analogIndex}",
+ Internal = true,
+ Enabled = true,
+ Adder = 0.0d,
+ Multiplier = 1.0d,
+ CreatedBy = user,
+ UpdatedBy = user,
+ CreatedOn = now,
+ UpdatedOn = now
+ });
+ analogIndex++;
+ }
+ }
+
+ // Digital values — PointTag: {company}_{device}:DIGI{idx:D2}
+ if (digiST?.ID > 0)
+ {
+ int digitalIndex = 1;
+ foreach (IDigitalDefinition _ in cell.DigitalDefinitions)
+ {
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ HistorianID = historianID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:DIGI{digitalIndex:D2}",
+ SignalTypeID = digiST.ID,
+ SignalReference = $"{deviceAcronym}-DV{digitalIndex}",
+ Description = $"{deviceName} Digital Value {digitalIndex}",
+ Internal = true,
+ Enabled = true,
+ Adder = 0.0d,
+ Multiplier = 1.0d,
+ CreatedBy = user,
+ UpdatedBy = user,
+ CreatedOn = now,
+ UpdatedOn = now
+ });
+ digitalIndex++;
+ }
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(SaveMeasurementsForCell),
+ $"Measurements saved for device '{deviceAcronym}'");
+ }
+
+ ///
+ /// Saves all phasor definitions from a configuration cell to the database, inserting new
+ /// phasors or updating existing ones matched by DeviceID and SourceIndex. Skips phasors
+ /// with empty or "unused" labels.
+ ///
+ private void SavePhaseorsForCell(IConfigurationCell cell, int targetDeviceID, TableOperations phasorTable)
+ {
+ int sourceIndex = 1;
+
+ foreach (IPhasorDefinition phasorDef in cell.PhasorDefinitions)
+ {
+ string label = phasorDef.Label?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrEmpty(label) ||
+ label.Equals("unused", StringComparison.OrdinalIgnoreCase))
+ {
+ sourceIndex++;
+ continue;
+ }
+
+ var existingPhasor = phasorTable.QueryRecordWhere(
+ "DeviceID = {0} AND SourceIndex = {1}", targetDeviceID, sourceIndex);
+
+ var phasor = new Phasor
+ {
+ DeviceID = targetDeviceID,
+ Label = label,
+ Type = phasorDef.PhasorType == GSF.Units.EE.PhasorType.Current ? "I" : "V",
+ Phase = "+",
+ SourceIndex = sourceIndex
+ };
+
+ var nowTime = DateTime.Now;
+ var nowTimeFormatted = new DateTime(nowTime.Year, nowTime.Month, nowTime.Day, nowTime.Hour, nowTime.Minute, nowTime.Second, nowTime.Millisecond, DateTimeKind.Local);
+ var user = User.Identity.Name;
+
+ phasor.CreatedBy = user;
+ phasor.UpdatedBy = user;
+ phasor.CreatedOn = nowTimeFormatted;
+ phasor.UpdatedOn = nowTimeFormatted;
+
+ if (existingPhasor == null)
+ phasorTable.AddNewRecord(phasor);
+ else
+ phasorTable.UpdateRecord(phasor, new RecordRestriction(
+ "DeviceID = {0} AND SourceIndex = {1}", targetDeviceID, sourceIndex));
+
+ sourceIndex++;
+ }
+ }
+
+ ///
+ /// Inserts a new device record or updates the existing one (matched by Acronym). Returns
+ /// the ID of the saved device.
+ ///
+ private int UpsertDeviceRecord(Device device)
+ {
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceRecord), $"Upserting device record");
+
+ using AdoDataConnection context = DataContext;
+
+ TableOperations nodeTable = new(context);
+ var defaultNode = nodeTable.QueryRecordWhere("Name = 'Default'");
+
+ TableOperations deviceTable = new(context);
+ var deviceInDatabase = deviceTable.QueryRecordWhere("Acronym = {0}", device.Acronym);
+
+ var nowTime = DateTime.Now;
+ var nowTimeFormatted = new DateTime(nowTime.Year, nowTime.Month, nowTime.Day, nowTime.Hour, nowTime.Minute, nowTime.Second, nowTime.Millisecond, DateTimeKind.Local);
+ var user = User.Identity.Name;
+
+ device.NodeID = defaultNode.ID;
+ device.UniqueID = Guid.NewGuid();
+ device.CreatedBy = user;
+ device.UpdatedBy = user;
+ device.CreatedOn = nowTimeFormatted;
+ device.UpdatedOn = nowTimeFormatted;
+
+ if (deviceInDatabase == null)
+ {
+ deviceTable.AddNewRecord(device);
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceRecord), $"Device added successfully");
+ deviceInDatabase = deviceTable.QueryRecordWhere("Acronym = {0}", device.Acronym);
+ }
+ else
+ {
+ var restriction = new RecordRestriction("Acronym = {0}", deviceInDatabase.Acronym);
+ deviceTable.UpdateRecord(device, restriction);
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceRecord), $"Device updated successfully");
+ }
+
+ return deviceInDatabase.ID;
+ }
+
+ private async Task ValidateRequest()
+ {
+ if (!Request.Content.IsMimeMultipartContent())
+ throw new InvalidOperationException("Expected multipart/form-data content with a .PmuConnection file");
+
+ var provider = new MultipartMemoryStreamProvider();
+ await Request.Content.ReadAsMultipartAsync(provider);
+
+ string acronym = null;
+ string name = null;
+ byte[] fileBytes = null;
+ string companyAcronym = null;
+ string historianAcronym = null;
+ string vendorDeviceName = null;
+ string interconnectionName = null;
+
+ foreach (var content in provider.Contents)
+ {
+ string fieldName = content.Headers.ContentDisposition?.Name?.Trim('"');
+ bool isFile = content.Headers.ContentDisposition?.FileName != null;
+
+ if (isFile)
+ fileBytes = await content.ReadAsByteArrayAsync();
+ else if (string.Equals(fieldName, "acronym", StringComparison.OrdinalIgnoreCase))
+ acronym = await content.ReadAsStringAsync();
+ else if (string.Equals(fieldName, "name", StringComparison.OrdinalIgnoreCase))
+ name = await content.ReadAsStringAsync();
+ else if (string.Equals(fieldName, "companyAcronym", StringComparison.OrdinalIgnoreCase))
+ companyAcronym = await content.ReadAsStringAsync();
+ else if (string.Equals(fieldName, "historianAcronym", StringComparison.OrdinalIgnoreCase))
+ historianAcronym = await content.ReadAsStringAsync();
+ else if (string.Equals(fieldName, "vendorDeviceName", StringComparison.OrdinalIgnoreCase))
+ vendorDeviceName = await content.ReadAsStringAsync();
+ else if (string.Equals(fieldName, "interconnectionName", StringComparison.OrdinalIgnoreCase))
+ interconnectionName = await content.ReadAsStringAsync();
+ }
+
+ if (fileBytes == null || fileBytes.Length == 0)
+ throw new InvalidOperationException("A .PmuConnection file is required");
+
+ if (string.IsNullOrWhiteSpace(acronym))
+ throw new InvalidOperationException("The 'acronym' form field is required");
+
+ name = string.IsNullOrWhiteSpace(name) ? acronym : name;
+
+ var deviceByPmuConnectionFile = new DeviceMetadata
+ {
+ Acronym = acronym,
+ Name = name,
+ FileBytes = fileBytes,
+ CompanyAcronym = companyAcronym,
+ HistorianAcronym = historianAcronym,
+ VendorDeviceName = vendorDeviceName,
+ InterconnectionName = interconnectionName
+ };
+
+ return deviceByPmuConnectionFile;
+ }
+
+ #endregion [ Methods ]
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs
new file mode 100644
index 0000000000..95be31a6ca
--- /dev/null
+++ b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs
@@ -0,0 +1,377 @@
+using GSF.Data;
+using GSF.Data.Model;
+using GSF.Diagnostics;
+using openPDC.Adapters.Constants;
+using openPDC.Model;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Web.Http;
+using System.Web.Http.Description;
+
+namespace openPDC.Adapters
+{
+ ///
+ /// Controller for combined Device and Phasor operations. Provides endpoints to query devices
+ /// (PMUs) along with their phasors in a single request.
+ ///
+ public class DevicePhasorController : ApiController
+ {
+ #region [ Members ]
+
+ private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(DevicePhasorController), MessageClass.Application);
+
+ #endregion [ Members ]
+
+ #region [ Properties ]
+
+ ///
+ /// Gets the DataContext for database operations.
+ ///
+ private static AdoDataConnection DataContext
+ {
+ get
+ {
+ return new AdoDataConnection(StringConstant.SystemSettings);
+ }
+ }
+
+ #endregion [ Properties ]
+
+ #region [ Methods ]
+
+ ///
+ /// Gets all devices with their respective phasors.
+ ///
+ /// List of devices with their phasors.
+ /// Returns the list of devices with phasors
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetAllDevicesWithPhasors()
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevicesWithPhasors), "Querying all devices with phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym).ToList();
+ var allPhasors = phasorTable.QueryRecords("DeviceID, SourceIndex").ToList();
+
+ var result = devices.Select(device => new DeviceWithPhasors
+ {
+ Device = device,
+ Phasors = [.. allPhasors.Where(p => p.DeviceAcronym == device.Acronym).OrderBy(p => p.SourceIndex)]
+ }).ToList();
+
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevicesWithPhasors), $"Returned {result.Count} devices with phasors");
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetAllDevicesWithPhasors), "Error querying devices with phasors", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets all devices with their respective phasors in CSV format.
+ ///
+ /// List of devices with their phasors in CSV format.
+ /// Returns the list of devices with phasors in CSV format
+ /// Internal error processing the request
+ [HttpGet]
+ public HttpResponseMessage GetAllDevicesWithPhasorsAsCsv()
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevicesWithPhasorsAsCsv), "Generating CSV with all devices and phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym).ToList();
+ var allPhasors = phasorTable.QueryRecords("DeviceID, SourceIndex").ToList();
+
+ var csv = new StringBuilder();
+
+ // Cabeçalho
+ csv.AppendLine("DeviceAcronym,DeviceName,CompanyAcronym,VendorAcronym,ProtocolName,IsConcentrator,FramesPerSecond,DeviceEnabled,Latitude,Longitude,PhasorID,PhasorLabel,PhasorType,PhasorPhase,SourceIndex,BaseKV");
+
+ // Dados
+ foreach (var device in devices)
+ {
+ var devicePhasors = allPhasors.Where(p => p.DeviceAcronym == device.Acronym).OrderBy(p => p.SourceIndex).ToList();
+
+ if (devicePhasors.Any())
+ {
+ foreach (var phasor in devicePhasors)
+ {
+ csv.AppendLine($"{EscapeCsvField(device.Acronym)},{EscapeCsvField(device.Name)},{EscapeCsvField(device.CompanyAcronym)},{EscapeCsvField(device.VendorAcronym)},{EscapeCsvField(device.ProtocolName)},{EscapeCsvField(device.IsConcentrator.ToString())},{device.FramesPerSecond},{device.Enabled},{device.Latitude},{device.Longitude},{phasor.ID},{EscapeCsvField(phasor.Label)},{EscapeCsvField(phasor.Type)},{EscapeCsvField(phasor.Phase)},{phasor.SourceIndex},{phasor.BaseKV}");
+ }
+ }
+ else
+ {
+ // Device without phasors - add line with device information only
+ csv.AppendLine($"{EscapeCsvField(device.Acronym)},{EscapeCsvField(device.Name)},{EscapeCsvField(device.CompanyAcronym)},{EscapeCsvField(device.VendorAcronym)},{EscapeCsvField(device.ProtocolName)},{EscapeCsvField(device.IsConcentrator.ToString())},{device.FramesPerSecond},{device.Enabled},{device.Latitude},{device.Longitude},,,,,0,0");
+ }
+ }
+
+ var response = Request.CreateResponse(HttpStatusCode.OK);
+ response.Content = new StringContent(csv.ToString(), Encoding.UTF8, "text/csv");
+ response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
+ {
+ FileName = $"all_devices_phasors_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv"
+ };
+
+ Log.Publish(MessageLevel.Info, nameof(GetAllDevicesWithPhasorsAsCsv), $"CSV generated with {devices.Count} devices");
+ return response;
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetAllDevicesWithPhasorsAsCsv), "Error generating CSV", exception: ex);
+ return Request.CreateResponse(HttpStatusCode.InternalServerError, new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets devices from a company with their phasors.
+ ///
+ /// Company acronym.
+ /// List of devices from the company with their phasors.
+ /// Returns the list of devices with phasors
+ /// No devices found for the company
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetDevicesWithPhasorsByCompany(string companyAcronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesWithPhasorsByCompany), $"Querying devices with phasors for company: {companyAcronym}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ RecordRestriction deviceRestriction = new("CompanyAcronym = {0}", companyAcronym);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym, restriction: deviceRestriction).ToList();
+
+ if (!devices.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDevicesWithPhasorsByCompany), $"No devices found for company: {companyAcronym}");
+ return NotFound();
+ }
+
+ var allPhasors = phasorTable.QueryRecords("DeviceID, SourceIndex").ToList();
+
+ var result = devices.Select(device => new DeviceWithPhasors
+ {
+ Device = device,
+ Phasors = [.. allPhasors.Where(p => p.DeviceAcronym == device.Acronym).OrderBy(p => p.SourceIndex)]
+ }).ToList();
+
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesWithPhasorsByCompany), $"Returned {result.Count} devices from company {companyAcronym}");
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDevicesWithPhasorsByCompany), $"Error querying devices for company {companyAcronym}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets enabled devices with their phasors.
+ ///
+ /// true for enabled, false for disabled.
+ /// List of devices filtered by status with their phasors.
+ /// Returns the list of devices with phasors
+ /// No devices found with the specified status
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetDevicesWithPhasorsByStatus(bool enabled)
+ {
+ try
+ {
+ string status = enabled ? "enabled" : "disabled";
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesWithPhasorsByStatus), $"Querying {status} devices with phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ RecordRestriction deviceRestriction = new("Enabled = {0}", enabled ? 1 : 0);
+ var devices = deviceTable.QueryRecords(StringConstant.Acronym, restriction: deviceRestriction).ToList();
+
+ if (!devices.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDevicesWithPhasorsByStatus), $"No {status} devices found");
+ return NotFound();
+ }
+
+ var allPhasors = phasorTable.QueryRecords("DeviceID, SourceIndex").ToList();
+
+ var result = devices.Select(device => new DeviceWithPhasors
+ {
+ Device = device,
+ Phasors = [.. allPhasors.Where(p => p.DeviceAcronym == device.Acronym).OrderBy(p => p.SourceIndex)]
+ }).ToList();
+
+ Log.Publish(MessageLevel.Info, nameof(GetDevicesWithPhasorsByStatus), $"Returned {result.Count} {status} devices");
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDevicesWithPhasorsByStatus), $"Error querying devices by status", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets a specific device with its phasors by Acronym.
+ ///
+ /// Device (PMU) acronym.
+ /// Device with its phasors.
+ /// Returns the device with its phasors
+ /// Device not found
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(DeviceWithPhasors))]
+ public IHttpActionResult GetDeviceWithPhasorsByAcronym(string acronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceWithPhasorsByAcronym), $"Querying device {acronym} with phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ RecordRestriction deviceRestriction = new("Acronym = {0}", acronym);
+ var device = deviceTable.QueryRecords(restriction: deviceRestriction).FirstOrDefault();
+
+ if (device == null)
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDeviceWithPhasorsByAcronym), $"Device not found: {acronym}");
+ return NotFound();
+ }
+
+ RecordRestriction phasorRestriction = new("DeviceAcronym = {0}", acronym);
+ var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, phasorRestriction).ToList();
+
+ var result = new DeviceWithPhasors
+ {
+ Device = device,
+ Phasors = phasors
+ };
+
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceWithPhasorsByAcronym), $"Returned device {acronym} with {phasors.Count} phasors");
+
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDeviceWithPhasorsByAcronym), $"Error querying device {acronym}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets a specific device with its phasors by Acronym in CSV format.
+ ///
+ /// Device (PMU) acronym.
+ /// Device with its phasors in CSV format.
+ /// Returns the device with its phasors in CSV format
+ /// Device not found
+ /// Internal error processing the request
+ [HttpGet]
+ public HttpResponseMessage GetDeviceWithPhasorsByAcronymAsCsv(string acronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceWithPhasorsByAcronymAsCsv), $"Generating CSV for device {acronym} with phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations deviceTable = new(context);
+ TableOperations phasorTable = new(context);
+
+ RecordRestriction deviceRestriction = new("Acronym = {0}", acronym);
+ var device = deviceTable.QueryRecords(restriction: deviceRestriction).FirstOrDefault();
+
+ if (device == null)
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetDeviceWithPhasorsByAcronymAsCsv), $"Device not found: {acronym}");
+ return Request.CreateResponse(HttpStatusCode.NotFound);
+ }
+
+ RecordRestriction phasorRestriction = new("DeviceAcronym = {0}", acronym);
+ var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, true, int.MaxValue, 0, phasorRestriction).ToList();
+
+ var csv = new StringBuilder();
+
+ // Cabeçalho do Device
+ csv.AppendLine("# Device Information");
+ csv.AppendLine("Acronym,Name,CompanyAcronym,VendorAcronym,ProtocolName,FramesPerSecond,Enabled,Latitude,Longitude");
+ csv.AppendLine($"{EscapeCsvField(device.Acronym)},{EscapeCsvField(device.Name)},{EscapeCsvField(device.CompanyAcronym)},{EscapeCsvField(device.VendorAcronym)},{EscapeCsvField(device.ProtocolName)},{device.FramesPerSecond},{device.Enabled},{device.Latitude},{device.Longitude}");
+
+ // Linha em branco
+ csv.AppendLine();
+
+ // Cabeçalho dos Phasors
+ csv.AppendLine("# Phasors");
+ csv.AppendLine("ID,DeviceAcronym,Label,Type,Phase,SourceIndex,BaseKV");
+
+ // Dados dos Phasors
+ foreach (var phasor in phasors)
+ {
+ csv.AppendLine($"{phasor.ID},{EscapeCsvField(phasor.DeviceAcronym)},{EscapeCsvField(phasor.Label)},{EscapeCsvField(phasor.Type)},{EscapeCsvField(phasor.Phase)},{phasor.SourceIndex},{phasor.BaseKV}");
+ }
+
+ var response = Request.CreateResponse(HttpStatusCode.OK);
+ response.Content = new StringContent(csv.ToString(), Encoding.UTF8, "text/csv");
+ response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
+ {
+ FileName = $"device_{acronym}_phasors.csv"
+ };
+
+ Log.Publish(MessageLevel.Info, nameof(GetDeviceWithPhasorsByAcronymAsCsv), $"CSV generated for device {acronym} with {phasors.Count} phasors");
+ return response;
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetDeviceWithPhasorsByAcronymAsCsv), $"Error generating CSV for device {acronym}", exception: ex);
+ return Request.CreateResponse(HttpStatusCode.InternalServerError, new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Escapes CSV fields to handle commas, quotes and line breaks.
+ ///
+ /// Field to be escaped.
+ /// Escaped field.
+ private static string EscapeCsvField(string field)
+ {
+ if (string.IsNullOrEmpty(field))
+ return string.Empty;
+
+ if (field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r"))
+ {
+ return $"\"{field.Replace("\"", "\"\"")}\"";
+ }
+
+ return field;
+ }
+
+ #endregion [ Methods ]
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Adapters/PhasorController.cs b/Source/Libraries/openPDC.Adapters/PhasorController.cs
new file mode 100644
index 0000000000..b81602f77d
--- /dev/null
+++ b/Source/Libraries/openPDC.Adapters/PhasorController.cs
@@ -0,0 +1,147 @@
+using GSF.Data;
+using GSF.Data.Model;
+using GSF.Diagnostics;
+using openPDC.Adapters.Constants;
+using openPDC.Model;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Web.Http;
+using System.Web.Http.Description;
+
+namespace openPDC.Adapters
+{
+ ///
+ /// Controller for Phasor operations in openPDC. Provides endpoints to query phasor data
+ /// from PMUs.
+ ///
+ public class PhasorController : ApiController
+ {
+ #region [ Members ]
+
+ private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(PhasorController), MessageClass.Application);
+
+ #endregion [ Members ]
+
+ #region [ Properties ]
+
+ ///
+ /// Gets the DataContext for database operations.
+ ///
+ private static AdoDataConnection DataContext
+ {
+ get
+ {
+ return new AdoDataConnection(StringConstant.SystemSettings);
+ }
+ }
+
+ #endregion [ Properties ]
+
+ #region [ Methods ]
+
+ ///
+ /// Gets all phasors in the system.
+ ///
+ /// List of all registered phasors.
+ /// Returns the list of phasors
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetAllPhasors()
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetAllPhasors), "Querying all phasors");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations phasorTable = new(context);
+ var phasors = phasorTable.QueryRecords(StringConstant.DeviceID);
+
+ Log.Publish(MessageLevel.Info, nameof(GetAllPhasors), $"Returned {phasors.Count()} phasors");
+ return Ok(phasors);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetAllPhasors), "Error querying phasors", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets the phasors of a specific device by ID.
+ ///
+ /// Device (PMU) ID.
+ /// List of phasors from the specified device.
+ /// Returns the list of device phasors
+ /// Device not found or has no phasors
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetPhasorsByDevice(int deviceId)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetPhasorsByDevice), $"Querying phasors for device ID: {deviceId}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations phasorTable = new(context);
+ RecordRestriction restriction = new("DeviceID = {0}", deviceId);
+ var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, restriction).ToList();
+
+ if (!phasors.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetPhasorsByDevice), $"No phasors found for device ID: {deviceId}");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetPhasorsByDevice), $"Returned {phasors.Count} phasors for device ID {deviceId}");
+ return Ok(phasors);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetPhasorsByDevice), $"Error querying phasors for device ID {deviceId}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ ///
+ /// Gets the phasors of a specific device by Acronym.
+ ///
+ /// Device (PMU) acronym.
+ /// List of phasors from the specified device.
+ /// Returns the list of device phasors
+ /// Device not found or has no phasors
+ /// Internal error processing the request
+ [HttpGet]
+ [ResponseType(typeof(IEnumerable))]
+ public IHttpActionResult GetPhasorsByDeviceAcronym(string deviceAcronym)
+ {
+ try
+ {
+ Log.Publish(MessageLevel.Info, nameof(GetPhasorsByDeviceAcronym), $"Querying phasors for device: {deviceAcronym}");
+
+ using AdoDataConnection context = DataContext;
+ TableOperations phasorTable = new(context);
+ RecordRestriction restriction = new("DeviceAcronym = {0}", deviceAcronym);
+ var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, restriction).ToList();
+
+ if (!phasors.Any())
+ {
+ Log.Publish(MessageLevel.Warning, nameof(GetPhasorsByDeviceAcronym), $"No phasors found for device: {deviceAcronym}");
+ return NotFound();
+ }
+
+ Log.Publish(MessageLevel.Info, nameof(GetPhasorsByDeviceAcronym), $"Returned {phasors.Count} phasors for device {deviceAcronym}");
+ return Ok(phasors);
+ }
+ catch (Exception ex)
+ {
+ Log.Publish(MessageLevel.Error, nameof(GetPhasorsByDeviceAcronym), $"Error querying phasors for device {deviceAcronym}", exception: ex);
+ return InternalServerError(ex);
+ }
+ }
+
+ #endregion [ Methods ]
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj b/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj
index aad33dee05..2ed999cebf 100644
--- a/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj
+++ b/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj
@@ -22,6 +22,7 @@
DEBUG;TRACE
prompt
4
+ ..\..\..\Build\Output\Debug\Applications\openPDC\openPDC.Adapters.xml
pdbonly
@@ -30,6 +31,7 @@
TRACE
prompt
4
+ ..\..\..\Build\Output\Release\Applications\openPDC\openPDC.Adapters.xml
@@ -39,9 +41,19 @@
..\..\Dependencies\GSF\GrafanaAdapters.dll
+
+ False
+ ..\..\..\..\..\openPDC\Source\Dependencies\GSF\GSF.Communication.dll
+
..\..\Dependencies\GSF\GSF.Core.dll
+
+ ..\..\Dependencies\GSF\GSF.PhasorProtocols.dll
+
+
+ ..\..\Dependencies\GSF\System.Net.Http.Formatting.dll
+
False
..\..\Dependencies\GSF\GSF.Historian.dll
@@ -96,7 +108,11 @@
+
+
+
+
diff --git a/Source/Libraries/openPDC.Model/Device.cs b/Source/Libraries/openPDC.Model/Device.cs
index 98d61d8a54..73406a256e 100644
--- a/Source/Libraries/openPDC.Model/Device.cs
+++ b/Source/Libraries/openPDC.Model/Device.cs
@@ -1,123 +1,139 @@
// ReSharper disable CheckNamespace
#pragma warning disable 1591
-using System;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
using GSF.ComponentModel;
using GSF.ComponentModel.DataAnnotations;
using GSF.Data.Model;
+using System;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
namespace openPDC.Model
{
[PrimaryLabel("Acronym")]
public class Device
{
- [DefaultValueExpression("Global.NodeID")]
- public Guid NodeID
+ [Label("Access ID")]
+ public int AccessID
{
get;
set;
}
- [Label("Local Device ID")]
- [PrimaryKey(true)]
- public int ID
+ [Required]
+ [StringLength(200)]
+ [AcronymValidation]
+ [Searchable]
+ public string Acronym
{
get;
set;
}
- public int? ParentID
+ [DefaultValue(10)]
+ public int AllowedParsingExceptions
{
get;
set;
}
- [Label("Unique Device ID")]
- [DefaultValueExpression("Guid.NewGuid()")]
- public Guid UniqueID
+ [DefaultValue(true)]
+ public bool AllowUseOfCachedConfiguration
{
get;
set;
}
- [Required]
- [StringLength(200)]
- [AcronymValidation]
- [Searchable]
- public string Acronym
+ [DefaultValue(true)]
+ public bool AutoStartDataParsingSequence
{
get;
set;
}
- [StringLength(200)]
- public string Name
+ [Required]
+ [Label("Company")]
+ [DefaultValueExpression("Connection.ExecuteScalar(typeof(int), (object)null, 'SELECT ID FROM Company WHERE Acronym = {0}', Global.CompanyAcronym)", Cached = true)]
+ public int? CompanyID
{
get;
set;
}
- [Label("Folder Name")]
- [StringLength(20)]
- public string OriginalSource
+ [Label("Connection String")]
+ public string ConnectionString
{
get;
set;
}
- [Label("Is Concentrator")]
- public bool IsConcentrator
+ [Label("Connect On Demand")]
+ [DefaultValue(true)]
+ public bool ConnectOnDemand
{
get;
set;
}
- [Required]
- [Label("Company")]
- [DefaultValueExpression("Connection.ExecuteScalar(typeof(int), (object)null, 'SELECT ID FROM Company WHERE Acronym = {0}', Global.CompanyAcronym)", Cached = true)]
- public int? CompanyID
+ [Label("Contacts")]
+ public string ContactList
{
get;
set;
}
- [Label("Historian")]
- public int? HistorianID
+ ///
+ /// Created by field.
+ ///
+ [Required]
+ [StringLength(50)]
+ [DefaultValueExpression("UserInfo.CurrentUserID")]
+ public string CreatedBy { get; set; }
+
+ ///
+ /// Created on field.
+ ///
+ [DefaultValueExpression("DateTime.UtcNow")]
+ public DateTime CreatedOn { get; set; }
+
+ [DefaultValue(5.0D)]
+ public double DataLossInterval
{
get;
set;
}
- [Label("Access ID")]
- public int AccessID
+ [DefaultValue(5.0D)]
+ public double DelayedConnectionInterval
{
get;
set;
}
- [Label("Vendor Device")]
- public int? VendorDeviceID
+ public bool Enabled
{
get;
set;
}
- [Label("Protocol")]
- public int? ProtocolID
+ [Label("Frames Per Second")]
+ [DefaultValue(30)]
+ public int? FramesPerSecond
{
get;
set;
}
- public decimal? Longitude
+ [Label("Historian")]
+ public int? HistorianID
{
get;
set;
}
- public decimal? Latitude
+ [Label("Local Device ID")]
+ [PrimaryKey(true)]
+ public int ID
{
get;
set;
@@ -131,135 +147,121 @@ public int? InterconnectionID
set;
}
- [Label("Connection String")]
- public string ConnectionString
- {
- get;
- set;
- }
-
- [StringLength(200)]
- public string TimeZone
+ [Label("Is Concentrator")]
+ public bool IsConcentrator
{
get;
set;
}
- [Label("Frames Per Second")]
- [DefaultValue(30)]
- public int? FramesPerSecond
+ public decimal? Latitude
{
get;
set;
}
- public long TimeAdjustmentTicks
+ public int LoadOrder
{
get;
set;
}
- [DefaultValue(5.0D)]
- public double DataLossInterval
+ public decimal? Longitude
{
get;
set;
}
- [DefaultValue(10)]
- public int AllowedParsingExceptions
+ public int? MeasuredLines
{
get;
set;
}
- [DefaultValue(5.0D)]
- public double ParsingExceptionWindow
+ [DefaultValue(100000)]
+ public int MeasurementReportingInterval
{
get;
set;
}
- [DefaultValue(5.0D)]
- public double DelayedConnectionInterval
+ [StringLength(200)]
+ public string Name
{
get;
set;
}
- [DefaultValue(true)]
- public bool AllowUseOfCachedConfiguration
+ [DefaultValueExpression("Global.NodeID")]
+ public Guid NodeID
{
get;
set;
}
- [DefaultValue(true)]
- public bool AutoStartDataParsingSequence
+ [Label("Folder Name")]
+ [StringLength(20)]
+ public string OriginalSource
{
get;
set;
}
- public bool SkipDisableRealTimeData
+ public int? ParentID
{
get;
set;
}
- [DefaultValue(100000)]
- public int MeasurementReportingInterval
+ [DefaultValue(5.0D)]
+ public double ParsingExceptionWindow
{
get;
set;
}
- [Label("Connect On Demand")]
- [DefaultValue(true)]
- public bool ConnectOnDemand
+ [Label("Protocol")]
+ public int? ProtocolID
{
get;
set;
}
- [Label("Contacts")]
- public string ContactList
+ public bool SkipDisableRealTimeData
{
get;
set;
}
- public int? MeasuredLines
+ public long TimeAdjustmentTicks
{
get;
set;
}
- public int LoadOrder
+ [StringLength(200)]
+ public string TimeZone
{
get;
set;
}
- public bool Enabled
+ [Label("Unique Device ID")]
+ [DefaultValueExpression("Guid.NewGuid()")]
+ public Guid UniqueID
{
get;
set;
}
///
- /// Created on field.
- ///
- [DefaultValueExpression("DateTime.UtcNow")]
- public DateTime CreatedOn { get; set; }
-
- ///
- /// Created by field.
+ /// Updated by field.
///
[Required]
[StringLength(50)]
- [DefaultValueExpression("UserInfo.CurrentUserID")]
- public string CreatedBy { get; set; }
+ [DefaultValueExpression("this.CreatedBy", EvaluationOrder = 1)]
+ [UpdateValueExpression("UserInfo.CurrentUserID")]
+ public string UpdatedBy { get; set; }
///
/// Updated on field.
@@ -268,13 +270,11 @@ public bool Enabled
[UpdateValueExpression("DateTime.UtcNow")]
public DateTime UpdatedOn { get; set; }
- ///
- /// Updated by field.
- ///
- [Required]
- [StringLength(50)]
- [DefaultValueExpression("this.CreatedBy", EvaluationOrder = 1)]
- [UpdateValueExpression("UserInfo.CurrentUserID")]
- public string UpdatedBy { get; set; }
+ [Label("Vendor Device")]
+ public int? VendorDeviceID
+ {
+ get;
+ set;
+ }
}
}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Model/DeviceMetadata.cs b/Source/Libraries/openPDC.Model/DeviceMetadata.cs
new file mode 100644
index 0000000000..b3583aabf7
--- /dev/null
+++ b/Source/Libraries/openPDC.Model/DeviceMetadata.cs
@@ -0,0 +1,17 @@
+namespace openPDC.Model
+{
+ public class DeviceMetadata
+ {
+ public string Acronym { get; set; }
+ public string CompanyAcronym { get; set; }
+ public int? CompanyID { get; set; }
+ public byte[] FileBytes { get; set; }
+ public string HistorianAcronym { get; set; }
+ public int? HistorianID { get; set; }
+ public int? InterconnectionID { get; set; }
+ public string InterconnectionName { get; set; }
+ public string Name { get; set; }
+ public int? VendorDeviceID { get; set; }
+ public string VendorDeviceName { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs b/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs
new file mode 100644
index 0000000000..144bb868a1
--- /dev/null
+++ b/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs
@@ -0,0 +1,31 @@
+// ReSharper disable CheckNamespace
+#pragma warning disable 1591
+
+using System.Collections.Generic;
+
+namespace openPDC.Model
+{
+ ///
+ /// DTO that represents a Device with its associated Phasors.
+ ///
+ public class DeviceWithPhasors
+ {
+ ///
+ /// Default constructor.
+ ///
+ public DeviceWithPhasors()
+ {
+ Phasors = new List();
+ }
+
+ ///
+ /// Device Information(PMU).
+ ///
+ public Device Device { get; set; }
+
+ ///
+ /// List of Phasors associated with the Device.
+ ///
+ public List Phasors { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Source/Libraries/openPDC.Model/Phasor.cs b/Source/Libraries/openPDC.Model/Phasor.cs
new file mode 100644
index 0000000000..1cba3c1037
--- /dev/null
+++ b/Source/Libraries/openPDC.Model/Phasor.cs
@@ -0,0 +1,49 @@
+// ReSharper disable CheckNamespace
+#pragma warning disable 1591
+
+using System;
+using System.ComponentModel.DataAnnotations;
+using GSF.ComponentModel;
+using GSF.Data.Model;
+
+namespace openPDC.Model
+{
+ public class Phasor
+ {
+ [PrimaryKey(true)]
+ public int ID { get; set; }
+
+ public int DeviceID { get; set; }
+
+ [StringLength(200)]
+ public string Label { get; set; }
+
+ [StringLength(1)]
+ public string Type { get; set; }
+
+ [StringLength(1)]
+ public string Phase { get; set; }
+
+ public int SourceIndex { get; set; }
+
+ public int? DestinationPhasorID { get; set; }
+
+ [DefaultValueExpression("DateTime.UtcNow")]
+ public DateTime CreatedOn { get; set; }
+
+ [Required]
+ [StringLength(50)]
+ [DefaultValueExpression("UserInfo.CurrentUserID")]
+ public string CreatedBy { get; set; }
+
+ [DefaultValueExpression("this.CreatedOn", EvaluationOrder = 1)]
+ [UpdateValueExpression("DateTime.UtcNow")]
+ public DateTime UpdatedOn { get; set; }
+
+ [Required]
+ [StringLength(50)]
+ [DefaultValueExpression("this.CreatedBy", EvaluationOrder = 1)]
+ [UpdateValueExpression("UserInfo.CurrentUserID")]
+ public string UpdatedBy { get; set; }
+ }
+}
diff --git a/Source/Libraries/openPDC.Model/openPDC.Model.csproj b/Source/Libraries/openPDC.Model/openPDC.Model.csproj
index db5f656d80..839687fa03 100644
--- a/Source/Libraries/openPDC.Model/openPDC.Model.csproj
+++ b/Source/Libraries/openPDC.Model/openPDC.Model.csproj
@@ -52,12 +52,15 @@
+
+
+