From 0da7a6b60ed784721cde4382ce79f9ce6a432ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20MO=2E=20de=20C=C3=B3rdova?= Date: Thu, 21 May 2026 23:46:30 -0300 Subject: [PATCH 1/6] Reorganizes imports and adds new API controllers Reorganized imports, included PhasorWebUI namespaces, and improved comments. The AuthenticationOptions property was moved to the beginning of the class with documentation. Added instances of the DeviceController, PhasorController, and DevicePhasorController controllers. Adjusted formatting, removed duplicates, and improved organization of static members. --- .../Applications/openPDC/openPDC/Startup.cs | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) 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 From 8a1b217d34dbe45ebdbc31e47715a05157a7dccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20MO=2E=20de=20C=C3=B3rdova?= Date: Thu, 21 May 2026 23:47:24 -0300 Subject: [PATCH 2/6] Adds REST controllers for devices and phasors. Includes DeviceController, PhasorController, and DevicePhasorController for querying devices (PMUs), phasors, and their relationships, with endpoints for filters and CSV export. Adds StringConstant.cs to standardize field names. Updates the project to include the new files and implements structured logging in all controllers. --- .../Constants/StringConstant.cs | 14 + .../openPDC.Adapters/DeviceController.cs | 230 +++++++++++ .../DevicePhasorController.cs | 376 ++++++++++++++++++ .../openPDC.Adapters/PhasorController.cs | 147 +++++++ .../openPDC.Adapters/openPDC.Adapters.csproj | 4 + 5 files changed, 771 insertions(+) create mode 100644 Source/Libraries/openPDC.Adapters/Constants/StringConstant.cs create mode 100644 Source/Libraries/openPDC.Adapters/DeviceController.cs create mode 100644 Source/Libraries/openPDC.Adapters/DevicePhasorController.cs create mode 100644 Source/Libraries/openPDC.Adapters/PhasorController.cs 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..f337bb9c33 --- /dev/null +++ b/Source/Libraries/openPDC.Adapters/DeviceController.cs @@ -0,0 +1,230 @@ +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.Web.Http; +using System.Web.Http.Description; + +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 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); + } + + #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..67e3df7121 --- /dev/null +++ b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs @@ -0,0 +1,376 @@ +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,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)},{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)},{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, true, int.MaxValue, 0, 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..07c8a20b74 100644 --- a/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj +++ b/Source/Libraries/openPDC.Adapters/openPDC.Adapters.csproj @@ -96,7 +96,11 @@ + + + + From fff5e7e75bfbed2318edbbef7b44e8df6da4ed94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20MO=2E=20de=20C=C3=B3rdova?= Date: Thu, 21 May 2026 23:51:28 -0300 Subject: [PATCH 3/6] Adds the DeviceWithPhasors class to the model. Includes the DeviceWithPhasors class as a DTO to represent a Device (PMU) with its list of associated Phasors. Adds the new DeviceWithPhasors.cs file to the openPDC.Model.csproj project. --- .../openPDC.Model/DeviceWithPhasors.cs | 31 +++++++++++++++++++ .../openPDC.Model/openPDC.Model.csproj | 1 + 2 files changed, 32 insertions(+) create mode 100644 Source/Libraries/openPDC.Model/DeviceWithPhasors.cs diff --git a/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs b/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs new file mode 100644 index 0000000000..cbcf045efa --- /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 que representa um Device com seus Phasors associados. + /// + public class DeviceWithPhasors + { + /// + /// Construtor padrão. + /// + public DeviceWithPhasors() + { + Phasors = new List(); + } + + /// + /// Informações do Device (PMU). + /// + public DeviceDetail Device { get; set; } + + /// + /// Lista de Phasors associados ao Device. + /// + public List Phasors { get; set; } + } +} \ No newline at end of file diff --git a/Source/Libraries/openPDC.Model/openPDC.Model.csproj b/Source/Libraries/openPDC.Model/openPDC.Model.csproj index db5f656d80..8f432cf74c 100644 --- a/Source/Libraries/openPDC.Model/openPDC.Model.csproj +++ b/Source/Libraries/openPDC.Model/openPDC.Model.csproj @@ -53,6 +53,7 @@ + From 9cfc7971f966773c74b5bcf4a3ac49b43f336a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20MO=2E=20de=20C=C3=B3rdova?= Date: Fri, 22 May 2026 01:13:28 -0300 Subject: [PATCH 4/6] Translates DeviceWithPhasors comments into English Comments for the DeviceWithPhasors class have been translated from Portuguese to English, improving documentation and making it easier to understand for international developers. --- Source/Libraries/openPDC.Model/DeviceWithPhasors.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs b/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs index cbcf045efa..b08019bdb4 100644 --- a/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs +++ b/Source/Libraries/openPDC.Model/DeviceWithPhasors.cs @@ -6,12 +6,12 @@ namespace openPDC.Model { /// - /// DTO que representa um Device com seus Phasors associados. + /// DTO that represents a Device with its associated Phasors. /// public class DeviceWithPhasors { /// - /// Construtor padrão. + /// Default constructor. /// public DeviceWithPhasors() { @@ -19,12 +19,12 @@ public DeviceWithPhasors() } /// - /// Informações do Device (PMU). + /// Device Information(PMU). /// public DeviceDetail Device { get; set; } /// - /// Lista de Phasors associados ao Device. + /// List of Phasors associated with the Device. /// public List Phasors { get; set; } } From 8df272410f597b7c7e4fb3292071743375643140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20MO=2E=20de=20C=C3=B3rdova?= Date: Fri, 22 May 2026 12:34:52 -0300 Subject: [PATCH 5/6] Simplified Phasor Query and XML Comment Adjustment The driver's XML comment has been reformatted for better readability. In the GetDeviceWithPhasorsByAcronym method, the phasor query has been simplified by removing sorting, limit, and offset parameters, using only the acronym filter. --- .../Libraries/openPDC.Adapters/DevicePhasorController.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs index 67e3df7121..704151e0a2 100644 --- a/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs +++ b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs @@ -16,8 +16,8 @@ namespace openPDC.Adapters { /// - /// Controller for combined Device and Phasor operations. Provides endpoints to - /// query devices (PMUs) along with their phasors in a single request. + /// 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 { @@ -267,7 +267,7 @@ public IHttpActionResult GetDeviceWithPhasorsByAcronym(string acronym) } RecordRestriction phasorRestriction = new("DeviceAcronym = {0}", acronym); - var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, true, int.MaxValue, 0, phasorRestriction).ToList(); + var phasors = phasorTable.QueryRecords(StringConstant.SourceIndex, phasorRestriction).ToList(); var result = new DeviceWithPhasors { @@ -276,6 +276,7 @@ public IHttpActionResult GetDeviceWithPhasorsByAcronym(string acronym) }; Log.Publish(MessageLevel.Info, nameof(GetDeviceWithPhasorsByAcronym), $"Returned device {acronym} with {phasors.Count} phasors"); + return Ok(result); } catch (Exception ex) From c398055db112fe0e768d4601bfe61edaa476e42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20C=C3=B3rdova?= Date: Tue, 26 May 2026 17:39:46 -0300 Subject: [PATCH 6/6] Added IsConcentrator column to device CSV The CSV header now includes the "IsConcentrator" column after "ProtocolName". The corresponding value is required for each device, both those with phasors and those without phasors. --- Source/Libraries/openPDC.Adapters/DevicePhasorController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs index 704151e0a2..f599207c91 100644 --- a/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs +++ b/Source/Libraries/openPDC.Adapters/DevicePhasorController.cs @@ -104,7 +104,7 @@ public HttpResponseMessage GetAllDevicesWithPhasorsAsCsv() var csv = new StringBuilder(); // Cabeçalho - csv.AppendLine("DeviceAcronym,DeviceName,CompanyAcronym,VendorAcronym,ProtocolName,FramesPerSecond,DeviceEnabled,Latitude,Longitude,PhasorID,PhasorLabel,PhasorType,PhasorPhase,SourceIndex,BaseKV"); + csv.AppendLine("DeviceAcronym,DeviceName,CompanyAcronym,VendorAcronym,ProtocolName,IsConcentrator,FramesPerSecond,DeviceEnabled,Latitude,Longitude,PhasorID,PhasorLabel,PhasorType,PhasorPhase,SourceIndex,BaseKV"); // Dados foreach (var device in devices) @@ -115,13 +115,13 @@ public HttpResponseMessage GetAllDevicesWithPhasorsAsCsv() { foreach (var phasor in devicePhasors) { - 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},{phasor.ID},{EscapeCsvField(phasor.Label)},{EscapeCsvField(phasor.Type)},{EscapeCsvField(phasor.Phase)},{phasor.SourceIndex},{phasor.BaseKV}"); + 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)},{device.FramesPerSecond},{device.Enabled},{device.Latitude},{device.Longitude},,,,,0,0"); + 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"); } }