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 @@ + +