diff --git a/Source/Applications/openPDC/openPDC/Startup.cs b/Source/Applications/openPDC/openPDC/Startup.cs
index 79f1c06b0a..7ff707a3b8 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,14 @@
using ModbusAdapters;
using Newtonsoft.Json;
using openPDC.Adapters;
-using Owin;
using openPDC.Model;
+using Owin;
using PhasorWebUI;
+using System;
+using System.Security;
+using System.Web.Http;
+using System.Web.Http.Cors;
+using System.Web.Http.ExceptionHandling;
namespace openPDC
{
@@ -53,6 +53,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
@@ -77,15 +82,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 +125,7 @@ public void Configuration(IAppBuilder app)
Program.Host.LogException
))
{
- WebExtensions.AddEmbeddedResourceAssembly(hub.GetType().Assembly);
+ WebExtensions.AddEmbeddedResourceAssembly(hub.GetType().Assembly);
}
}
catch (Exception ex)
@@ -145,9 +151,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 +185,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}",
@@ -208,8 +219,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 +235,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/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..4eac478dec
--- /dev/null
+++ b/Source/Libraries/openPDC.Adapters/DeviceController.cs
@@ -0,0 +1,933 @@
+using GSF.Communication;
+using GSF.Data;
+using GSF.Data.Model;
+using GSF.Diagnostics;
+using GSF.PhasorProtocols;
+using GSF.Security.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
+ {
+ (string acronym, string name, byte[] fileBytes) = await ValidateRequest();
+
+ ConnectionSettings settings;
+
+ using (var stream = new MemoryStream(fileBytes))
+ settings = ParsePmuConnectionFile(stream, 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, acronym, name);
+ return (count, 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;
+
+ private static int LookupSignalTypeID(AdoDataConnection context, string suffix)
+ {
+ try
+ {
+ return context.ExecuteScalar("SELECT ID FROM SignalType WHERE Suffix = {0}", suffix);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ ///
+ /// 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 Derivative",
+ "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 = 60)
+ {
+ 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();
+ }
+ }
+
+ ///
+ /// 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('_');
+ }
+
+ ///
+ /// Builds a Device object for the parent/main device (either concentrator or standalone PMU).
+ ///
+ private Device BuildParentDevice(string acronym, string name, bool isConcentrator, int? protocolID,
+ ConnectionSettings settings, IConfigurationFrame configFrame, string deviceConnectionString)
+ {
+ return new Device
+ {
+ Acronym = acronym,
+ Name = name,
+ IsConcentrator = isConcentrator,
+ ProtocolID = protocolID,
+ 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, string parentAcronym, string parentName, 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);
+ targetAcronym = SanitizeAcronym(cell.StationName);
+ targetName = cell.StationName;
+ savedDeviceCount++;
+ }
+ else
+ {
+ targetDeviceID = parentDeviceID;
+ targetAcronym = parentAcronym;
+ targetName = parentName;
+ }
+
+ SavePhaseorsForCell(cell, targetDeviceID, phasorTable);
+ SaveMeasurementsForCell(cell, targetDeviceID, targetAcronym, targetName, 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)
+ {
+ string cellAcronym = SanitizeAcronym(cell.StationName);
+
+ var concentrator = new Device
+ {
+ Acronym = cellAcronym,
+ Name = cell.StationName,
+ IsConcentrator = false,
+ ProtocolID = protocolID,
+ 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, string acronym, string name)
+ {
+ using AdoDataConnection context = DataContext;
+
+ int? protocolID = GetProtocolID(settings.PhasorProtocol);
+ bool isConcentrator = configFrame.Cells.Count > 1;
+ string deviceConnectionString = $"TransportProtocol={settings.TransportProtocol};{settings.ConnectionString}";
+
+ var parentDevice = BuildParentDevice(acronym, name, isConcentrator, protocolID, settings, configFrame, deviceConnectionString);
+ var parentDeviceID = UpsertDeviceRecord(parentDevice);
+
+ int savedDeviceCount = 1;
+ ProcessAllCells(configFrame, settings, parentDeviceID, protocolID, isConcentrator, acronym, name, ref savedDeviceCount);
+
+ Log.Publish(MessageLevel.Info, nameof(UpsertDeviceByPmuConnectionFile),
+ $"Saved {savedDeviceCount} device(s) for acronym '{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.
+ ///
+ private void SaveMeasurementsForCell(IConfigurationCell cell, int deviceID, string deviceAcronym,
+ string deviceName, TableOperations measurementTable, AdoDataConnection context)
+ {
+ TableOperations deviceDetailTable = new(context);
+ var deviceDetail = deviceDetailTable.QueryRecordWhere("Acronym = {0}", deviceAcronym);
+ string companyAcronym = deviceDetail?.CompanyAcronym ?? string.Empty;
+ string vendorAcronym = deviceDetail?.VendorAcronym ?? 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 all relevant signal type IDs in one pass to avoid per-measurement round-trips.
+ var signalTypeIds = new Dictionary();
+ foreach (string suffix in new[] { "FQ", "DF", "SF", "PM", "PA", "IM", "IA", "AV", "DV" })
+ {
+ int id = LookupSignalTypeID(context, suffix);
+ if (id > 0)
+ signalTypeIds[suffix] = id;
+ }
+
+ // PMU-level: Frequency (FQ), dF/dt (DF), Status Flags (SF)
+ foreach (string suffix in new[] { "FQ", "DF", "SF" })
+ {
+ if (!signalTypeIds.TryGetValue(suffix, out int signalTypeID))
+ continue;
+
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{vendorAcronym}{suffix}",
+ SignalTypeID = signalTypeID,
+ 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 and angle for each defined (non-unused) phasor.
+ 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;
+ string magnitudeSuffix = isVoltage ? "PM" : "IM";
+ string angleSuffix = isVoltage ? "PA" : "IA";
+
+ foreach ((string sfx, string measurementLabel) in new[] { (magnitudeSuffix, "Magnitude"), (angleSuffix, "Angle") })
+ {
+ if (!signalTypeIds.TryGetValue(sfx, out int signalTypeID))
+ continue;
+
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}-{sfx}{phasorIndex}:{vendorAcronym}{sfx}",
+ SignalTypeID = signalTypeID,
+ 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
+ if (signalTypeIds.TryGetValue("AV", out int avTypeID))
+ {
+ int analogIndex = 1;
+ foreach (IAnalogDefinition _ in cell.AnalogDefinitions)
+ {
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{vendorAcronym}A{analogIndex}",
+ SignalTypeID = avTypeID,
+ 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
+ if (signalTypeIds.TryGetValue("DV", out int dvTypeID))
+ {
+ int digitalIndex = 1;
+ foreach (IDigitalDefinition _ in cell.DigitalDefinitions)
+ {
+ UpsertMeasurement(measurementTable, new Measurement
+ {
+ DeviceID = deviceID,
+ PointTag = $"{companyAcronym}_{deviceAcronym}:{vendorAcronym}D{digitalIndex}",
+ SignalTypeID = dvTypeID,
+ 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;
+ }
+
+ ///
+ /// Inserts a new measurement or updates the existing one matched by SignalReference.
+ /// Preserves the SignalID (GUID) of existing records on update.
+ ///
+ private 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));
+ }
+ }
+
+ private async Task<(string, string, byte[])> 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;
+
+ 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();
+ }
+
+ 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;
+
+ return (acronym, name, fileBytes);
+ }
+
+ #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..0ac8eb02c0 100644
--- a/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj
+++ b/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj
@@ -39,9 +39,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 +106,11 @@
+
+
+
+
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..37f1c9f1ad 100644
--- a/Source/Libraries/openPDC.Model/openPDC.Model.csproj
+++ b/Source/Libraries/openPDC.Model/openPDC.Model.csproj
@@ -53,11 +53,13 @@
+
+