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