From 0e11a05b5d778e366737e84ee38bc2daa4d0851a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 18 Aug 2026 03:18:36 +0200 Subject: [PATCH 01/14] chore: apply dotnet format baseline dotnet format --verify-no-changes was failing against master with hundreds of unformatted diagnostics (mostly tab/space indentation drift), so every PR built on top of it inherited a dirty baseline before touching a single line of its own. Runs `dotnet format MTConnect.NET.sln` (default warn severity, per the repository's existing .editorconfig rules) and commits the result: indentation, brace placement and other whitespace-only corrections across 61 files, concentrated in MTConnect.NET-Common, MTConnect.NET-HTTP and the build/ tooling projects. No behavioural changes; `dotnet build` succeeds cleanly on the result. --- .../AdapterConfigurationFileWatcher.cs | 2 +- .../AgentConfigurationFileWatcher.cs | 2 +- .../MTConnectAgentApplication.cs | 2 +- .../MTConnectShdrHttpAgentServer.cs | 2 +- .../Module.cs | 2 +- .../Module.cs | 38 +- build/MTConnect.NET-DocsGen/RouteInventory.cs | 18 +- .../CSharp/ClassModel.cs | 4 +- .../CSharp/ComponentType.cs | 2 +- .../CSharp/CompositionType.cs | 2 +- .../CSharp/DataItemType.cs | 32 +- .../CSharp/EnumModel.cs | 2 +- .../CSharp/EnumStringModel.cs | 2 +- .../CSharp/InterfaceDataItemType.cs | 2 +- .../CSharp/TemplateRenderer.cs | 4 +- build/MTConnect.NET.Builder/Files.cs | 64 +- .../Parts/agent/docker/Docker.cs | 2 +- .../Parts/agent/installer/Installer.cs | 2 +- .../Adapters/ItemQueue.cs | 2 +- .../Adapters/MTConnectAdapter.cs | 14 +- .../Agents/MTConnectAgent.cs | 4 +- libraries/MTConnect.NET-Common/Assemblies.cs | 2 +- .../MTConnect.NET-Common/Assets/Asset.cs | 16 +- .../Assets/CuttingTools/CuttingToolAsset.cs | 48 +- .../MTConnect.NET-Common/Assets/IAsset.cs | 12 +- .../Buffers/CircularBuffer.cs | 4 +- .../Buffers/MTConnectAssetQueue.cs | 32 +- .../MTConnectConditionObservationQueue.cs | 4 +- .../Buffers/MTConnectObservationQueue.cs | 4 +- .../Clients/SampleRange.cs | 2 +- .../MTConnect.NET-Common/Devices/IDataItem.cs | 8 +- .../Extensions/AssemblyExtensions.cs | 2 +- .../Extensions/StringFunctions.cs | 324 +++++----- .../Headers/IMTConnectErrorHeader.cs | 2 +- .../Interfaces/Interface.cs | 2 +- .../Interfaces/TaskAsset.cs | 12 +- .../Events/AssetChangedValueObservation.cs | 18 +- .../Events/AssetRemovedValueObservation.cs | 18 +- .../Events/DeviceAddedValueObservation.cs | 26 +- .../Events/DeviceChangedValueObservation.cs | 26 +- .../Events/DeviceRemovedValueObservation.cs | 26 +- .../Streams/ComponentStream.cs | 2 +- .../Streams/IComponentStream.cs | 2 +- .../Streams/Output/ComponentStreamOutput.cs | 2 +- .../Streams/Output/IComponentStreamOutput.cs | 2 +- .../MTConnectDeviceFinder.cs | 4 +- .../Ceen/Common/AppDomainTask.cs | 52 +- .../Ceen/Httpd/HttpServer.cs | 12 +- .../Ceen/Httpd/LimitedBodyStream.cs | 2 +- .../Ceen/Httpd/ServerConfig.cs | 572 +++++++++--------- .../Servers/MTConnectHttpServer.cs | 4 +- .../Clients/MTConnectMqttClient.cs | 16 +- .../Clients/MTConnectMqttExpandedClient.cs | 8 +- .../MTConnect.NET-MQTT/MTConnectMqttBroker.cs | 8 +- .../MTConnect.NET-MQTT/MTConnectMqttRelay.cs | 4 +- .../Adapters/ShdrAdapter.cs | 8 +- .../MTConnect.NET-SHDR/Shdr/ShdrClient.cs | 4 +- .../MTConnect.NET-SysML/Xmi/OwnedComment.cs | 3 +- .../Xmi/PackagedElementCollection.cs | 4 +- .../SchemaVersionFieldsCoexistTests.cs | 2 +- .../Streams/Current.cs | 2 +- 61 files changed, 753 insertions(+), 752 deletions(-) diff --git a/adapter/MTConnect.NET-Applications-Adapter/Configurations/AdapterConfigurationFileWatcher.cs b/adapter/MTConnect.NET-Applications-Adapter/Configurations/AdapterConfigurationFileWatcher.cs index d72f2dde6..adcc8ab84 100644 --- a/adapter/MTConnect.NET-Applications-Adapter/Configurations/AdapterConfigurationFileWatcher.cs +++ b/adapter/MTConnect.NET-Applications-Adapter/Configurations/AdapterConfigurationFileWatcher.cs @@ -18,7 +18,7 @@ public class AdapterConfigurationFileWatcher : ConfigurationFile /// /// Filesystem path to watch. /// Poll interval in milliseconds. - public AdapterConfigurationFileWatcher(string path, int interval = DefaultInterval) :base(path, interval) { } + public AdapterConfigurationFileWatcher(string path, int interval = DefaultInterval) : base(path, interval) { } /// diff --git a/agent/MTConnect.NET-Applications-Agents/Configurations/AgentConfigurationFileWatcher.cs b/agent/MTConnect.NET-Applications-Agents/Configurations/AgentConfigurationFileWatcher.cs index f2dad5cd7..9264dccdc 100644 --- a/agent/MTConnect.NET-Applications-Agents/Configurations/AgentConfigurationFileWatcher.cs +++ b/agent/MTConnect.NET-Applications-Agents/Configurations/AgentConfigurationFileWatcher.cs @@ -18,7 +18,7 @@ public class AgentConfigurationFileWatcher : ConfigurationFileWa /// /// Filesystem path to watch. /// Poll interval in milliseconds. - public AgentConfigurationFileWatcher(string path, int interval = DefaultInterval) :base(path, interval) { } + public AgentConfigurationFileWatcher(string path, int interval = DefaultInterval) : base(path, interval) { } /// diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs index 523e4887d..4b604371c 100644 --- a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs +++ b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs @@ -582,7 +582,7 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos _agentLogger.Warn($"No Devices Found : Reading from : {configuration.Devices}"); } } - + // Initilialize Processors _processors = new MTConnectAgentProcessors(configuration); diff --git a/agent/Modules/MTConnect.NET-AgentModule-HttpServer/MTConnectShdrHttpAgentServer.cs b/agent/Modules/MTConnect.NET-AgentModule-HttpServer/MTConnectShdrHttpAgentServer.cs index 9143fe45c..fa1c781a6 100644 --- a/agent/Modules/MTConnect.NET-AgentModule-HttpServer/MTConnectShdrHttpAgentServer.cs +++ b/agent/Modules/MTConnect.NET-AgentModule-HttpServer/MTConnectShdrHttpAgentServer.cs @@ -153,7 +153,7 @@ private Stream ReadRequestBody(Stream inputStream) var outputBytes = Encoding.Convert(Encoding.ASCII, Encoding.UTF8, inputBytes); return new MemoryStream(outputBytes); } - + } catch { } } diff --git a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs index 41647dc24..c54517360 100644 --- a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs +++ b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs @@ -959,7 +959,7 @@ await AsyncVoidGuard.Run( { multipleObservations.Add(CloneAsObservation(conditionObservation)); } - + var result = await _entityServer.PublishObservations(_mqttClient, multipleObservations); if (result != null && result.IsSuccess) { diff --git a/agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/Module.cs index 6d62ab127..319008cdc 100644 --- a/agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/Module.cs +++ b/agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/Module.cs @@ -132,13 +132,13 @@ private void AddAdapter(IShdrAdapterClientConfiguration configuration, IDevice d if (configuration.OutputConnectionInformation) { - // Initialize Adapter URI Observation - var adapterUriDataItem = adapterComponent.GetDataItemByType(AdapterUriDataItem.TypeId); - if (adapterUriDataItem != null && initializeDataItems) - { - _mtconnectAgent.AddObservation(adapterUriDataItem, adapterComponent.Uri); - } - } + // Initialize Adapter URI Observation + var adapterUriDataItem = adapterComponent.GetDataItemByType(AdapterUriDataItem.TypeId); + if (adapterUriDataItem != null && initializeDataItems) + { + _mtconnectAgent.AddObservation(adapterUriDataItem, adapterComponent.Uri); + } + } // Create new SHDR Adapter Client to read from SHDR stream var adapterClient = new ShdrAdapterClient(configuration, _mtconnectAgent, device, idSuffix); @@ -160,9 +160,9 @@ private void AddAdapter(IShdrAdapterClientConfiguration configuration, IDevice d _mtconnectAgent.AddObservation(connectionStatusDataItem, ConnectionStatus.LISTEN); } - // Start the Adapter Client - adapterClient.Start(); - } + // Start the Adapter Client + adapterClient.Start(); + } } @@ -181,8 +181,8 @@ private void AdapterConnected(object sender, string message) if (_configuration.AvailableOnConnection) { var availabilityDataItem = adapterClient.Device.GetDataItemByType(AvailabilityDataItem.TypeId); - _mtconnectAgent.AddObservation(availabilityDataItem, Availability.AVAILABLE); - } + _mtconnectAgent.AddObservation(availabilityDataItem, Availability.AVAILABLE); + } Log(MTConnectLogLevel.Information, $"ID = " + adapterClient.Id + " : " + message); } @@ -198,14 +198,14 @@ private void AdapterDisconnected(object sender, string message) _mtconnectAgent.AddObservation(connectionStatusDataItem, ConnectionStatus.CLOSED); } - // Set Availability (if AvailableOnConnection = TRUE) - if (_configuration.AvailableOnConnection) - { - var availabilityDataItem = adapterClient.Device.GetDataItemByType(AvailabilityDataItem.TypeId); - _mtconnectAgent.AddObservation(availabilityDataItem, Availability.UNAVAILABLE); - } + // Set Availability (if AvailableOnConnection = TRUE) + if (_configuration.AvailableOnConnection) + { + var availabilityDataItem = adapterClient.Device.GetDataItemByType(AvailabilityDataItem.TypeId); + _mtconnectAgent.AddObservation(availabilityDataItem, Availability.UNAVAILABLE); + } - Log(MTConnectLogLevel.Information, $"ID = " + adapterClient.Id + " : " + message); + Log(MTConnectLogLevel.Information, $"ID = " + adapterClient.Id + " : " + message); } private void AdapterConnectionError(object sender, Exception exception) diff --git a/build/MTConnect.NET-DocsGen/RouteInventory.cs b/build/MTConnect.NET-DocsGen/RouteInventory.cs index c5742cc68..1cfcf2aca 100644 --- a/build/MTConnect.NET-DocsGen/RouteInventory.cs +++ b/build/MTConnect.NET-DocsGen/RouteInventory.cs @@ -220,8 +220,8 @@ private static (string? method, string? handler) SniffLambdaGate(ParenthesizedLa // the *summary text* still flows from /// on the handler class. private static readonly IReadOnlyDictionary> CeenHandlerParameters = new Dictionary> - { - ["MTConnectProbeResponseHandler"] = new EndpointParam[] + { + ["MTConnectProbeResponseHandler"] = new EndpointParam[] { new("deviceType", "Query", "string", null, "Optional device-type filter."), new("version", "Query", "string", null, "Target MTConnect Standard version of the response document."), @@ -230,7 +230,7 @@ private static readonly IReadOnlyDictionary new("indentOutput", "Query", "bool", null, "Pretty-print the response document."), new("outputComments", "Query", "bool", null, "Emit comments / annotations in the response document."), }, - ["MTConnectCurrentResponseHandler"] = new EndpointParam[] + ["MTConnectCurrentResponseHandler"] = new EndpointParam[] { new("path", "Query", "string", null, "XPath that filters the data items included in the response."), new("at", "Query", "ulong", null, "Sequence number anchoring the snapshot."), @@ -242,7 +242,7 @@ private static readonly IReadOnlyDictionary new("indentOutput", "Query", "bool", null, "Pretty-print the response document."), new("outputComments", "Query", "bool", null, "Emit comments / annotations in the response document."), }, - ["MTConnectSampleResponseHandler"] = new EndpointParam[] + ["MTConnectSampleResponseHandler"] = new EndpointParam[] { new("path", "Query", "string", null, "XPath that filters the data items included in the response."), new("from", "Query", "ulong", null, "Sequence number lower bound."), @@ -256,7 +256,7 @@ private static readonly IReadOnlyDictionary new("indentOutput", "Query", "bool", null, "Pretty-print the response document."), new("outputComments", "Query", "bool", null, "Emit comments / annotations in the response document."), }, - ["MTConnectAssetsResponseHandler"] = new EndpointParam[] + ["MTConnectAssetsResponseHandler"] = new EndpointParam[] { new("type", "Query", "string", null, "Asset type filter (e.g. CuttingTool)."), new("removed", "Query", "bool", null, "Include removed assets when true."), @@ -264,20 +264,20 @@ private static readonly IReadOnlyDictionary new("documentFormat", "Query", "string", "xml", "Response document format."), new("indentOutput", "Query", "bool", null, "Pretty-print the response document."), }, - ["MTConnectAssetResponseHandler"] = new EndpointParam[] + ["MTConnectAssetResponseHandler"] = new EndpointParam[] { new("assetId", "Route", "string", null, "Asset identifier captured from the trailing path segment."), new("documentFormat", "Query", "string", "xml", "Response document format."), }, - ["MTConnectPutResponseHandler"] = new EndpointParam[] + ["MTConnectPutResponseHandler"] = new EndpointParam[] { new("(form / query)", "Body", "Dictionary", null, "DataItemId=Value entries to enqueue as observations."), }, - ["MTConnectPostResponseHandler"] = new EndpointParam[] + ["MTConnectPostResponseHandler"] = new EndpointParam[] { new("(body)", "Body", "string", null, "Asset document payload."), }, - }; + }; // Cache of parsed /// summary text per handler-class file path, so // we only Roslyn-parse each file once even though several variables diff --git a/build/MTConnect.NET-SysML-Import/CSharp/ClassModel.cs b/build/MTConnect.NET-SysML-Import/CSharp/ClassModel.cs index 320d08060..435a2b5ae 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/ClassModel.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/ClassModel.cs @@ -49,7 +49,7 @@ public static ClassModel Create(MTConnectClassModel importModel) { var propertyValue = importProperty.GetValue(importModel); - var exportProperty = exportProperties.FirstOrDefault(o => o.Name == importProperty.Name); + var exportProperty = exportProperties.FirstOrDefault(o => o.Name == importProperty.Name); if (exportProperty != null && exportProperty.PropertyType == importProperty.PropertyType) { exportProperty.SetValue(exportModel, propertyValue); @@ -62,7 +62,7 @@ public static ClassModel Create(MTConnectClassModel importModel) // Remove 'Enum' suffix if (exportPropertyModel.DataType.EndsWith("Enum")) - { + { var suffix = "Enum"; if (exportPropertyModel.DataType.EndsWith(suffix)) exportPropertyModel.DataType = exportPropertyModel.DataType.Substring(0, exportPropertyModel.DataType.Length - suffix.Length); } diff --git a/build/MTConnect.NET-SysML-Import/CSharp/ComponentType.cs b/build/MTConnect.NET-SysML-Import/CSharp/ComponentType.cs index 20c6fb259..71ce253e7 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/ComponentType.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/ComponentType.cs @@ -54,7 +54,7 @@ public ComponentType() { } /// Identifier prefix applied to the /// rendered type. /// Backing UML class. - public ComponentType(XmiDocument xmiDocument, string idPrefix, UmlClass umlClass) : base (xmiDocument, idPrefix, umlClass) { } + public ComponentType(XmiDocument xmiDocument, string idPrefix, UmlClass umlClass) : base(xmiDocument, idPrefix, umlClass) { } /// diff --git a/build/MTConnect.NET-SysML-Import/CSharp/CompositionType.cs b/build/MTConnect.NET-SysML-Import/CSharp/CompositionType.cs index 22a8b4e7b..74d1a2153 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/CompositionType.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/CompositionType.cs @@ -37,7 +37,7 @@ public CompositionType() { } /// rendered type. /// Backing UML enumeration /// literal. - public CompositionType(XmiDocument xmiDocument, string idPrefix, UmlEnumerationLiteral umlEnumerationLiteral) : base (xmiDocument, idPrefix, umlEnumerationLiteral) { } + public CompositionType(XmiDocument xmiDocument, string idPrefix, UmlEnumerationLiteral umlEnumerationLiteral) : base(xmiDocument, idPrefix, umlEnumerationLiteral) { } /// diff --git a/build/MTConnect.NET-SysML-Import/CSharp/DataItemType.cs b/build/MTConnect.NET-SysML-Import/CSharp/DataItemType.cs index 83b70541a..472eabfd0 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/DataItemType.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/DataItemType.cs @@ -67,7 +67,7 @@ public DataItemType() { } /// Optional sub-classes that derive /// from . public DataItemType(XmiDocument xmiDocument, string category, string idPrefix, UmlClass umlClass, UmlEnumerationLiteral umlEnumerationLiteral, IEnumerable subClasses = null) - : base (xmiDocument, category, idPrefix, umlClass, umlEnumerationLiteral, subClasses) { } + : base(xmiDocument, category, idPrefix, umlClass, umlEnumerationLiteral, subClasses) { } /// @@ -155,19 +155,19 @@ public virtual string RenderModel() public string RenderDescriptions() => null; - private static string GetName(string type) - { - switch (type) - { - case "EMERGENCY_STOP": return "estop"; - case "CONTROLLER_MODE": return "mode"; - case "EXECUTION": return "exec"; - case "LOAD": return "load"; - case "POSITION": return "pos"; - case "TEMPERATURE": return "temp"; - } - - return type.ToCamelCase(); - } - } + private static string GetName(string type) + { + switch (type) + { + case "EMERGENCY_STOP": return "estop"; + case "CONTROLLER_MODE": return "mode"; + case "EXECUTION": return "exec"; + case "LOAD": return "load"; + case "POSITION": return "pos"; + case "TEMPERATURE": return "temp"; + } + + return type.ToCamelCase(); + } + } } diff --git a/build/MTConnect.NET-SysML-Import/CSharp/EnumModel.cs b/build/MTConnect.NET-SysML-Import/CSharp/EnumModel.cs index 30c97191f..be6b5358a 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/EnumModel.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/EnumModel.cs @@ -35,7 +35,7 @@ public static EnumModel Create(MTConnectEnumModel importModel, Func o.Name == importProperty.Name); + var exportProperty = exportProperties.FirstOrDefault(o => o.Name == importProperty.Name); if (exportProperty != null && exportProperty.PropertyType == importProperty.PropertyType) { exportProperty.SetValue(exportModel, propertyValue); diff --git a/build/MTConnect.NET-SysML-Import/CSharp/EnumStringModel.cs b/build/MTConnect.NET-SysML-Import/CSharp/EnumStringModel.cs index 6ce799937..654dacf1c 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/EnumStringModel.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/EnumStringModel.cs @@ -37,7 +37,7 @@ public static EnumStringModel Create(MTConnectEnumModel importModel, Func o.Name == importProperty.Name); + var exportProperty = exportProperties.FirstOrDefault(o => o.Name == importProperty.Name); if (exportProperty != null && exportProperty.PropertyType == importProperty.PropertyType) { exportProperty.SetValue(exportModel, propertyValue); diff --git a/build/MTConnect.NET-SysML-Import/CSharp/InterfaceDataItemType.cs b/build/MTConnect.NET-SysML-Import/CSharp/InterfaceDataItemType.cs index afacf6251..8620d2696 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/InterfaceDataItemType.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/InterfaceDataItemType.cs @@ -32,7 +32,7 @@ public InterfaceDataItemType() { } /// literal. /// Optional sub-classes. public InterfaceDataItemType(XmiDocument xmiDocument, string category, string idPrefix, UmlClass umlClass, UmlEnumerationLiteral umlEnumerationLiteral, IEnumerable subClasses = null) - : base (xmiDocument, category, idPrefix, umlClass, umlEnumerationLiteral, subClasses) { } + : base(xmiDocument, category, idPrefix, umlClass, umlEnumerationLiteral, subClasses) { } /// diff --git a/build/MTConnect.NET-SysML-Import/CSharp/TemplateRenderer.cs b/build/MTConnect.NET-SysML-Import/CSharp/TemplateRenderer.cs index 922affd75..8b9bbec83 100644 --- a/build/MTConnect.NET-SysML-Import/CSharp/TemplateRenderer.cs +++ b/build/MTConnect.NET-SysML-Import/CSharp/TemplateRenderer.cs @@ -95,7 +95,7 @@ public static void Render(MTConnectModel mtconnectModel, string outputPath) } } - break; + break; } } } @@ -810,7 +810,7 @@ private static void ApplyAssetSuffix(ClassModel template, bool alsoSuffixParent) private static string ConvertUnitEnum(string input) - { + { var output = input; if (output != null) diff --git a/build/MTConnect.NET.Builder/Files.cs b/build/MTConnect.NET.Builder/Files.cs index 01f2da46b..f01fc4a41 100644 --- a/build/MTConnect.NET.Builder/Files.cs +++ b/build/MTConnect.NET.Builder/Files.cs @@ -138,7 +138,7 @@ public static byte[] Zip(string sourcePath) using (var outputStream = new MemoryStream()) { using (var archive = new ZipArchive(outputStream, ZipArchiveMode.Create)) - { + { // Add Source files from Directory var files = Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories); foreach (var file in files) @@ -179,40 +179,40 @@ public static void Zip(string sourcePath, string destinationPath) } } - /// - /// Extracts the supplied zip archive bytes into - /// . - /// - /// In-memory zip archive. - /// Target directory. - public static void Unzip(byte[] archivedBytes, string destinationPath) - { - if (archivedBytes != null && !string.IsNullOrEmpty(destinationPath)) - { - try - { - if (!Directory.Exists(destinationPath)) Directory.CreateDirectory(destinationPath); + /// + /// Extracts the supplied zip archive bytes into + /// . + /// + /// In-memory zip archive. + /// Target directory. + public static void Unzip(byte[] archivedBytes, string destinationPath) + { + if (archivedBytes != null && !string.IsNullOrEmpty(destinationPath)) + { + try + { + if (!Directory.Exists(destinationPath)) Directory.CreateDirectory(destinationPath); - using (var inputStream = new MemoryStream(archivedBytes)) - { - using (var archive = new ZipArchive(inputStream, ZipArchiveMode.Read)) - { - archive.ExtractToDirectory(destinationPath, true); - } - } - } - catch { } - } - } + using (var inputStream = new MemoryStream(archivedBytes)) + { + using (var archive = new ZipArchive(inputStream, ZipArchiveMode.Read)) + { + archive.ExtractToDirectory(destinationPath, true); + } + } + } + catch { } + } + } - /// - /// Recursively deletes every file and subdirectory under - /// , leaving the directory itself - /// in place but empty. Best-effort — IO errors are swallowed. - /// - /// Directory to clear. - public static void Clear(string directory) + /// + /// Recursively deletes every file and subdirectory under + /// , leaving the directory itself + /// in place but empty. Best-effort — IO errors are swallowed. + /// + /// Directory to clear. + public static void Clear(string directory) { if (!string.IsNullOrEmpty(directory)) { diff --git a/build/MTConnect.NET.Builder/Parts/agent/docker/Docker.cs b/build/MTConnect.NET.Builder/Parts/agent/docker/Docker.cs index 81b2ab5bb..1a6c40e7a 100644 --- a/build/MTConnect.NET.Builder/Parts/agent/docker/Docker.cs +++ b/build/MTConnect.NET.Builder/Parts/agent/docker/Docker.cs @@ -74,7 +74,7 @@ private static async Task BuildProject(string configurationId, bool verbose) Console.WriteLine(); Environment.Exit(1); } - } + } } private static async Task BuildImage(string configurationId, bool verbose) diff --git a/build/MTConnect.NET.Builder/Parts/agent/installer/Installer.cs b/build/MTConnect.NET.Builder/Parts/agent/installer/Installer.cs index f3f2cf2ad..3314efc68 100644 --- a/build/MTConnect.NET.Builder/Parts/agent/installer/Installer.cs +++ b/build/MTConnect.NET.Builder/Parts/agent/installer/Installer.cs @@ -74,7 +74,7 @@ private static async Task BuildProject(string configurationId, bool verbose) Environment.Exit(1); } } - } + } } private static async Task CreateInstaller(string configurationId, bool verbose) diff --git a/libraries/MTConnect.NET-Common/Adapters/ItemQueue.cs b/libraries/MTConnect.NET-Common/Adapters/ItemQueue.cs index c26481757..4e157311b 100644 --- a/libraries/MTConnect.NET-Common/Adapters/ItemQueue.cs +++ b/libraries/MTConnect.NET-Common/Adapters/ItemQueue.cs @@ -45,7 +45,7 @@ public IEnumerable Take(int count = 1) foreach (var item in items) { - x.Add(item.Value); + x.Add(item.Value); } // Remove Items from Queue diff --git a/libraries/MTConnect.NET-Common/Adapters/MTConnectAdapter.cs b/libraries/MTConnect.NET-Common/Adapters/MTConnectAdapter.cs index 53f95d696..7e816e7f4 100644 --- a/libraries/MTConnect.NET-Common/Adapters/MTConnectAdapter.cs +++ b/libraries/MTConnect.NET-Common/Adapters/MTConnectAdapter.cs @@ -88,16 +88,16 @@ public class MTConnectAdapter : IMTConnectAdapter /// /// Raised when new data is sent to the Agent. Includes the AgentClient ID and the Line sent as an argument. /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler> DataSent; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when an error occurs when sending a new line to the Agent. Includes the AgentClient ID and the Error message as an argument. /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler> SendError; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// @@ -373,8 +373,8 @@ public void AddObservation(IObservationInput observation) } // If using Buffer, Add to Buffer - - + + // -- Need to add Mode property? @@ -461,7 +461,7 @@ public bool SendObservation(IObservationInput observation) var success = Write(newObservation); if (success) { - // Update Last Sent DataItems + // Update Last Sent DataItems UpdateLastObservations(new List { newObservation }); } diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index e1276ec00..ce2e70e9b 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -202,9 +202,9 @@ public string Sender /// /// Raised when a new Asset is attempted to be added to the Agent /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler AssetReceived; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when a new Asset is added to the Agent diff --git a/libraries/MTConnect.NET-Common/Assemblies.cs b/libraries/MTConnect.NET-Common/Assemblies.cs index 822e1bd75..2b4bc211c 100644 --- a/libraries/MTConnect.NET-Common/Assemblies.cs +++ b/libraries/MTConnect.NET-Common/Assemblies.cs @@ -6,7 +6,7 @@ using System.Reflection; #if NET5_0_OR_GREATER - using System.Runtime.Loader; +using System.Runtime.Loader; #endif namespace MTConnect diff --git a/libraries/MTConnect.NET-Common/Assets/Asset.cs b/libraries/MTConnect.NET-Common/Assets/Asset.cs index 7c8fb8219..aa46af263 100644 --- a/libraries/MTConnect.NET-Common/Assets/Asset.cs +++ b/libraries/MTConnect.NET-Common/Assets/Asset.cs @@ -139,12 +139,12 @@ private static Dictionary GetAllTypes() /// The MTConnect version the response document targets. public IAsset Process(Version mtconnectVersion) { - if (mtconnectVersion < MTConnectVersions.Version12) return null; + if (mtconnectVersion < MTConnectVersions.Version12) return null; - if (mtconnectVersion < MTConnectVersions.Version22) Hash = null; + if (mtconnectVersion < MTConnectVersions.Version22) Hash = null; - return OnProcess(mtconnectVersion); - } + return OnProcess(mtconnectVersion); + } /// /// Per-type version adjustment hook invoked by ; the base implementation returns the asset unchanged. Overrides may downgrade properties or return null to exclude the asset for a given version. @@ -152,8 +152,8 @@ public IAsset Process(Version mtconnectVersion) /// The MTConnect version the response document targets. protected virtual IAsset OnProcess(Version mtconnectVersion) { - return this; - } + return this; + } /// /// Validates the asset against the given MTConnect version; the base implementation accepts all assets and is overridden by types with required fields or constraints. @@ -170,7 +170,7 @@ public virtual ValidationResult IsValid(Version mtconnectVersion) /// When true, the asset timestamp is folded into the hash. public virtual string GenerateHash(bool includeTimestamp = true) { - return null; + return null; } - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Assets/CuttingTools/CuttingToolAsset.cs b/libraries/MTConnect.NET-Common/Assets/CuttingTools/CuttingToolAsset.cs index 965d541b5..b849e4889 100644 --- a/libraries/MTConnect.NET-Common/Assets/CuttingTools/CuttingToolAsset.cs +++ b/libraries/MTConnect.NET-Common/Assets/CuttingTools/CuttingToolAsset.cs @@ -53,7 +53,7 @@ protected override IAsset OnProcess(Version mtconnectVersion) return asset; } - return null; + return null; } /// @@ -80,25 +80,25 @@ public override ValidationResult IsValid(Version mtconnectVersion) } - /// - /// Computes the content hash of this cutting tool asset; see . - /// - /// When true, the asset timestamp is folded into the hash. - public override string GenerateHash(bool includeTimestamp = true) - { - return GenerateHash(this, includeTimestamp); - } - - /// - /// Computes a SHA-1 content hash combining the asset's scalar properties with the hash of its life cycle; when is false the timestamp and UUID are excluded so equality is independent of when and where the asset was reported. Returns null for a null asset. - /// - /// The cutting tool asset to hash. - /// When true, the asset timestamp is folded into the hash. - public static string GenerateHash(CuttingToolAsset asset, bool includeTimestamp = true) - { - if (asset != null) - { - var ids = new List(); + /// + /// Computes the content hash of this cutting tool asset; see . + /// + /// When true, the asset timestamp is folded into the hash. + public override string GenerateHash(bool includeTimestamp = true) + { + return GenerateHash(this, includeTimestamp); + } + + /// + /// Computes a SHA-1 content hash combining the asset's scalar properties with the hash of its life cycle; when is false the timestamp and UUID are excluded so equality is independent of when and where the asset was reported. Returns null for a null asset. + /// + /// The cutting tool asset to hash. + /// When true, the asset timestamp is folded into the hash. + public static string GenerateHash(CuttingToolAsset asset, bool includeTimestamp = true) + { + if (asset != null) + { + var ids = new List(); if (includeTimestamp) ids.Add(ObjectExtensions.GetHashPropertyString(asset).ToSHA1Hash()); else ids.Add(ObjectExtensions.GetHashPropertyString(asset, new string[] { nameof(Timestamp), nameof(Uuid) }).ToSHA1Hash()); @@ -106,9 +106,9 @@ public static string GenerateHash(CuttingToolAsset asset, bool includeTimestamp ids.Add(CuttingTools.CuttingToolLifeCycle.GenerateHash(asset.CuttingToolLifeCycle)); return StringFunctions.ToSHA1Hash(ids.ToArray()); - } + } - return null; - } - } + return null; + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Assets/IAsset.cs b/libraries/MTConnect.NET-Common/Assets/IAsset.cs index ba45979f9..329f2361a 100644 --- a/libraries/MTConnect.NET-Common/Assets/IAsset.cs +++ b/libraries/MTConnect.NET-Common/Assets/IAsset.cs @@ -18,11 +18,11 @@ public partial interface IAsset : IMTConnectEntity ulong InstanceId { get; set; } - /// - /// Prepares the asset for inclusion in a response document for the given MTConnect version, returning the version-adjusted asset or null to exclude it. - /// - /// The MTConnect version the response document targets. - IAsset Process(Version mtconnectVersion); + /// + /// Prepares the asset for inclusion in a response document for the given MTConnect version, returning the version-adjusted asset or null to exclude it. + /// + /// The MTConnect version the response document targets. + IAsset Process(Version mtconnectVersion); /// /// Validates the asset against the given MTConnect version, reporting whether it satisfies that version's constraints. @@ -35,5 +35,5 @@ public partial interface IAsset : IMTConnectEntity /// /// When true, the asset timestamp is folded into the hash. string GenerateHash(bool includeTimestamp = true); - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Buffers/CircularBuffer.cs b/libraries/MTConnect.NET-Common/Buffers/CircularBuffer.cs index c79fd512f..fc9fc0d72 100644 --- a/libraries/MTConnect.NET-Common/Buffers/CircularBuffer.cs +++ b/libraries/MTConnect.NET-Common/Buffers/CircularBuffer.cs @@ -17,7 +17,7 @@ class CircularBuffer public uint Capacity { - get + get { lock (_lock) return (uint)_buffer.Length; } @@ -93,7 +93,7 @@ public void Add(ref BufferObservation observation) ++_size; _full = _size == _buffer.Length; } - } + } } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetQueue.cs b/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetQueue.cs index b4dc68c9c..5fb98eb10 100644 --- a/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetQueue.cs +++ b/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetQueue.cs @@ -46,7 +46,7 @@ public IEnumerable Take(uint count = 1) foreach (var item in items) { - x.Add(item.Value); + x.Add(item.Value); } // Remove Items from Queue @@ -69,28 +69,28 @@ public bool Add(uint index, IAsset asset, uint originalIndex) if (!string.IsNullOrEmpty(hash)) { - lock (_lock) + lock (_lock) + { + if (_items.Count > _limit) return false; + + if (_items.TryGetValue(hash, out var _)) + { + _items.Remove(hash); + _items.Add(hash, new AssetQueueItem(index, asset, originalIndex)); + return true; + } + else { - if (_items.Count > _limit) return false; - - if (_items.TryGetValue(hash, out var _)) - { - _items.Remove(hash); - _items.Add(hash, new AssetQueueItem(index, asset, originalIndex)); - return true; - } - else - { - _items.Add(hash, new AssetQueueItem(index, asset, originalIndex)); - return true; - } + _items.Add(hash, new AssetQueueItem(index, asset, originalIndex)); + return true; } } } + } catch { } } return false; - } + } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Buffers/MTConnectConditionObservationQueue.cs b/libraries/MTConnect.NET-Common/Buffers/MTConnectConditionObservationQueue.cs index 218ea19e3..e0a846e9a 100644 --- a/libraries/MTConnect.NET-Common/Buffers/MTConnectConditionObservationQueue.cs +++ b/libraries/MTConnect.NET-Common/Buffers/MTConnectConditionObservationQueue.cs @@ -46,7 +46,7 @@ public IEnumerable Take(int count = 1) foreach (var item in items) { - x.AddRange(item.Value); + x.AddRange(item.Value); } // Remove Items from Queue @@ -98,6 +98,6 @@ public bool Add(IEnumerable observations) } return false; - } + } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Buffers/MTConnectObservationQueue.cs b/libraries/MTConnect.NET-Common/Buffers/MTConnectObservationQueue.cs index 86fb7600a..36fa53ce8 100644 --- a/libraries/MTConnect.NET-Common/Buffers/MTConnectObservationQueue.cs +++ b/libraries/MTConnect.NET-Common/Buffers/MTConnectObservationQueue.cs @@ -45,7 +45,7 @@ public IEnumerable Take(int count = 1) foreach (var item in items) { - x.Add(item.Value); + x.Add(item.Value); } // Remove Items from Queue @@ -83,6 +83,6 @@ public bool Add(BufferObservation observation) } return false; - } + } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Clients/SampleRange.cs b/libraries/MTConnect.NET-Common/Clients/SampleRange.cs index 0367fcb31..0d9d2ec0b 100644 --- a/libraries/MTConnect.NET-Common/Clients/SampleRange.cs +++ b/libraries/MTConnect.NET-Common/Clients/SampleRange.cs @@ -29,7 +29,7 @@ public void Reset() public override string ToString() { string f = "From = {0}, To = {1}, Count = {2}"; - return string.Format(f, From, To, Count); + return string.Format(f, From, To, Count); } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Devices/IDataItem.cs b/libraries/MTConnect.NET-Common/Devices/IDataItem.cs index 21f5d9795..579a608bd 100644 --- a/libraries/MTConnect.NET-Common/Devices/IDataItem.cs +++ b/libraries/MTConnect.NET-Common/Devices/IDataItem.cs @@ -32,10 +32,10 @@ public partial interface IDataItem : IMTConnectEntity string Hash { get; } - /// - /// The text description that describes what the DataItem Type represents - /// - string TypeDescription { get; } + /// + /// The text description that describes what the DataItem Type represents + /// + string TypeDescription { get; } /// /// The text description that describes what the DataItem SubType represents diff --git a/libraries/MTConnect.NET-Common/Extensions/AssemblyExtensions.cs b/libraries/MTConnect.NET-Common/Extensions/AssemblyExtensions.cs index 8fcfcc30f..ac7334478 100644 --- a/libraries/MTConnect.NET-Common/Extensions/AssemblyExtensions.cs +++ b/libraries/MTConnect.NET-Common/Extensions/AssemblyExtensions.cs @@ -25,7 +25,7 @@ public static class AssemblyExtensions /// Code taken from https://stackoverflow.com/questions/7889228/how-to-prevent-reflectiontypeloadexception-when-calling-assembly-gettypes /// public static IReadOnlyCollection GetMatchingTypesInAssembly( - this Assembly assembly, + this Assembly assembly, Predicate predicate) { var types = new List(); diff --git a/libraries/MTConnect.NET-Common/Extensions/StringFunctions.cs b/libraries/MTConnect.NET-Common/Extensions/StringFunctions.cs index af7933ea1..3b89f5d8d 100644 --- a/libraries/MTConnect.NET-Common/Extensions/StringFunctions.cs +++ b/libraries/MTConnect.NET-Common/Extensions/StringFunctions.cs @@ -21,10 +21,10 @@ public static class StringFunctions [ThreadStatic] private static MD5 _md5; - [ThreadStatic] - private static SHA1 _sha1; + [ThreadStatic] + private static SHA1 _sha1; - [ThreadStatic] + [ThreadStatic] private static Random _random; private static MD5 MD5Algorithm @@ -39,19 +39,19 @@ private static MD5 MD5Algorithm } } - private static SHA1 SHA1Algorithm - { - get - { - if (_sha1 == null) - { - _sha1 = SHA1.Create(); - } - return _sha1; - } - } + private static SHA1 SHA1Algorithm + { + get + { + if (_sha1 == null) + { + _sha1 = SHA1.Create(); + } + return _sha1; + } + } - private static Random Random + private static Random Random { get { @@ -484,153 +484,153 @@ public static byte[] ToMD5HashBytes(byte[][] hashBytes) } - /// - /// Computes the SHA-1 digest of the UTF-8 bytes of the string and returns it as a lowercase hex string; returns null if hashing throws. - /// - /// The string to hash. - public static string ToSHA1Hash(this string s) - { - try - { - var hash = SHA1Algorithm.ComputeHash(_utf8.GetBytes(s)); - return string.Concat(hash.Select(b => b.ToString("x2"))); - } - catch { } - - return null; - } - - /// - /// Computes the SHA-1 digest of the byte buffer and returns it as a lowercase hex string; returns null when the buffer is null or hashing throws. - /// - /// The buffer to hash. - public static string ToSHA1Hash(this byte[] bytes) - { - if (bytes != null) - { - try - { - var hash = SHA1Algorithm.ComputeHash(bytes); - return string.Concat(hash.Select(b => b.ToString("x2"))); - } - catch { } - } - - return null; - } - - /// - /// Formats an already-computed SHA-1 digest as a lowercase hex string without re-hashing; returns null when the buffer is null or formatting throws. - /// - /// The raw digest bytes to render as hex. - public static string ToSHA1HashString(this byte[] hashBytes) - { - if (hashBytes != null) - { - try - { - return string.Concat(hashBytes.Select(b => b.ToString("x2"))); - } - catch { } - } - - return null; - } - - /// - /// Computes the raw SHA-1 digest of the UTF-8 bytes of the string; returns null if hashing throws. - /// - /// The string to hash. - public static byte[] ToSHA1HashBytes(this string s) - { - try - { - return SHA1Algorithm.ComputeHash(_utf8.GetBytes(s)); - } - catch { } - - return null; - } - - /// - /// Computes the raw SHA-1 digest of the byte buffer; returns null when the buffer is null or hashing throws. - /// - /// The buffer to hash. - public static byte[] ToSHA1HashBytes(this byte[] bytes) - { - if (bytes != null) - { - try - { - return SHA1Algorithm.ComputeHash(bytes); - } - catch { } - } - return null; - } - - /// - /// Computes an order-sensitive rolling SHA-1 over the lines: each line is hashed, concatenated after the running hash, and re-hashed, so the result depends on both content and sequence. Returns null for a null or empty array. - /// - /// The ordered lines to fold into a single hash. - public static string ToSHA1Hash(string[] lines) - { - if (lines != null && lines.Length > 0) - { - var x1 = lines[0]; - var h = x1.ToSHA1Hash(); - - for (int i = 1; i < lines.Length; i++) - { - x1 = lines[i].ToSHA1Hash(); - x1 = h + x1; - h = x1.ToSHA1Hash(); - } - - return h; - } - - return null; - } - - /// - /// Iterates the digest buffers, concatenating the running result with each next buffer and re-hashing it with SHA-1; for a single-element array the element is returned unchanged, and null/empty input returns null. - /// Note: for multi-element input the value returned is the last source buffer rather than the accumulated hash. - /// - /// The ordered digest buffers to combine. - public static byte[] ToSHA1HashBytes(byte[][] hashBytes) - { - if (hashBytes != null && hashBytes.Length > 0) - { - var x1 = hashBytes[0]; - var x2 = x1; - byte[] a1; - - for (int i = 1; i < hashBytes.Length; i++) - { - x2 = hashBytes[i]; - if (x2 != null) - { - a1 = new byte[x1.Length + x2.Length]; - Array.Copy(x1, 0, a1, 0, x1.Length); - Array.Copy(x2, 0, a1, x1.Length, x2.Length); - - x1 = a1.ToSHA1HashBytes(); - } - } - - return x2; - } - - return null; - } - - - /// - /// Formats a byte count as a human-readable size using binary (1024) steps and one decimal place, ranging from "B" to "EB" and preserving the sign of a negative count. - /// - /// The number of bytes to format. - public static string ToFileSize(this long byteCount) + /// + /// Computes the SHA-1 digest of the UTF-8 bytes of the string and returns it as a lowercase hex string; returns null if hashing throws. + /// + /// The string to hash. + public static string ToSHA1Hash(this string s) + { + try + { + var hash = SHA1Algorithm.ComputeHash(_utf8.GetBytes(s)); + return string.Concat(hash.Select(b => b.ToString("x2"))); + } + catch { } + + return null; + } + + /// + /// Computes the SHA-1 digest of the byte buffer and returns it as a lowercase hex string; returns null when the buffer is null or hashing throws. + /// + /// The buffer to hash. + public static string ToSHA1Hash(this byte[] bytes) + { + if (bytes != null) + { + try + { + var hash = SHA1Algorithm.ComputeHash(bytes); + return string.Concat(hash.Select(b => b.ToString("x2"))); + } + catch { } + } + + return null; + } + + /// + /// Formats an already-computed SHA-1 digest as a lowercase hex string without re-hashing; returns null when the buffer is null or formatting throws. + /// + /// The raw digest bytes to render as hex. + public static string ToSHA1HashString(this byte[] hashBytes) + { + if (hashBytes != null) + { + try + { + return string.Concat(hashBytes.Select(b => b.ToString("x2"))); + } + catch { } + } + + return null; + } + + /// + /// Computes the raw SHA-1 digest of the UTF-8 bytes of the string; returns null if hashing throws. + /// + /// The string to hash. + public static byte[] ToSHA1HashBytes(this string s) + { + try + { + return SHA1Algorithm.ComputeHash(_utf8.GetBytes(s)); + } + catch { } + + return null; + } + + /// + /// Computes the raw SHA-1 digest of the byte buffer; returns null when the buffer is null or hashing throws. + /// + /// The buffer to hash. + public static byte[] ToSHA1HashBytes(this byte[] bytes) + { + if (bytes != null) + { + try + { + return SHA1Algorithm.ComputeHash(bytes); + } + catch { } + } + return null; + } + + /// + /// Computes an order-sensitive rolling SHA-1 over the lines: each line is hashed, concatenated after the running hash, and re-hashed, so the result depends on both content and sequence. Returns null for a null or empty array. + /// + /// The ordered lines to fold into a single hash. + public static string ToSHA1Hash(string[] lines) + { + if (lines != null && lines.Length > 0) + { + var x1 = lines[0]; + var h = x1.ToSHA1Hash(); + + for (int i = 1; i < lines.Length; i++) + { + x1 = lines[i].ToSHA1Hash(); + x1 = h + x1; + h = x1.ToSHA1Hash(); + } + + return h; + } + + return null; + } + + /// + /// Iterates the digest buffers, concatenating the running result with each next buffer and re-hashing it with SHA-1; for a single-element array the element is returned unchanged, and null/empty input returns null. + /// Note: for multi-element input the value returned is the last source buffer rather than the accumulated hash. + /// + /// The ordered digest buffers to combine. + public static byte[] ToSHA1HashBytes(byte[][] hashBytes) + { + if (hashBytes != null && hashBytes.Length > 0) + { + var x1 = hashBytes[0]; + var x2 = x1; + byte[] a1; + + for (int i = 1; i < hashBytes.Length; i++) + { + x2 = hashBytes[i]; + if (x2 != null) + { + a1 = new byte[x1.Length + x2.Length]; + Array.Copy(x1, 0, a1, 0, x1.Length); + Array.Copy(x2, 0, a1, x1.Length, x2.Length); + + x1 = a1.ToSHA1HashBytes(); + } + } + + return x2; + } + + return null; + } + + + /// + /// Formats a byte count as a human-readable size using binary (1024) steps and one decimal place, ranging from "B" to "EB" and preserving the sign of a negative count. + /// + /// The number of bytes to format. + public static string ToFileSize(this long byteCount) { string[] suf = { "B", "KB", "MB", "GB", "TB", "PB", "EB" }; //Longs run out around EB if (byteCount == 0) diff --git a/libraries/MTConnect.NET-Common/Headers/IMTConnectErrorHeader.cs b/libraries/MTConnect.NET-Common/Headers/IMTConnectErrorHeader.cs index d917a7c11..bfb545603 100644 --- a/libraries/MTConnect.NET-Common/Headers/IMTConnectErrorHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/IMTConnectErrorHeader.cs @@ -45,7 +45,7 @@ public interface IMTConnectErrorHeader /// /// A timestamp in 8601 format of the last update of the Device information for any device. /// - string DeviceModelChangeTime { get; } + string DeviceModelChangeTime { get; } /// /// A flag indicating that the Agent that published the Response Document is operating in a test mode. diff --git a/libraries/MTConnect.NET-Common/Interfaces/Interface.cs b/libraries/MTConnect.NET-Common/Interfaces/Interface.cs index ef377bbb1..9cc031c50 100644 --- a/libraries/MTConnect.NET-Common/Interfaces/Interface.cs +++ b/libraries/MTConnect.NET-Common/Interfaces/Interface.cs @@ -8,5 +8,5 @@ namespace MTConnect.Interfaces /// /// Interface is the next level of Structural Element in the MTConnectDevices XML document.As an abstract type XML element, Interface will be replaced in the XML documents by specific Interface types defined below /// - public abstract class Interface : Component, IInterface { } + public abstract class Interface : Component, IInterface { } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Interfaces/TaskAsset.cs b/libraries/MTConnect.NET-Common/Interfaces/TaskAsset.cs index 78437f651..04fb88fd9 100644 --- a/libraries/MTConnect.NET-Common/Interfaces/TaskAsset.cs +++ b/libraries/MTConnect.NET-Common/Interfaces/TaskAsset.cs @@ -50,12 +50,12 @@ public TaskAsset() /// The MTConnect version the response document is being generated for. protected override IAsset OnProcess(Version mtconnectVersion) { - if (mtconnectVersion != null && mtconnectVersion >= MTConnectVersions.Version13) - { - return this; - } + if (mtconnectVersion != null && mtconnectVersion >= MTConnectVersions.Version13) + { + return this; + } - return null; - } + return null; + } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Observations/Events/AssetChangedValueObservation.cs b/libraries/MTConnect.NET-Common/Observations/Events/AssetChangedValueObservation.cs index f9148c04f..d27e269bb 100644 --- a/libraries/MTConnect.NET-Common/Observations/Events/AssetChangedValueObservation.cs +++ b/libraries/MTConnect.NET-Common/Observations/Events/AssetChangedValueObservation.cs @@ -18,13 +18,13 @@ public string AssetType set => AddValue(new ObservationValue(ValueKeys.AssetType, value)); } - /// - /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 - /// - public string Hash - { - get => GetValue(ValueKeys.Hash); - set => AddValue(new ObservationValue(ValueKeys.Hash, value)); - } - } + /// + /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 + /// + public string Hash + { + get => GetValue(ValueKeys.Hash); + set => AddValue(new ObservationValue(ValueKeys.Hash, value)); + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Observations/Events/AssetRemovedValueObservation.cs b/libraries/MTConnect.NET-Common/Observations/Events/AssetRemovedValueObservation.cs index e5797ea36..ccde88f4c 100644 --- a/libraries/MTConnect.NET-Common/Observations/Events/AssetRemovedValueObservation.cs +++ b/libraries/MTConnect.NET-Common/Observations/Events/AssetRemovedValueObservation.cs @@ -19,13 +19,13 @@ public string AssetType set => AddValue(new ObservationValue(ValueKeys.AssetType, value)); } - /// - /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 - /// - public string Hash - { - get => GetValue(ValueKeys.Hash); - set => AddValue(new ObservationValue(ValueKeys.Hash, value)); - } - } + /// + /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 + /// + public string Hash + { + get => GetValue(ValueKeys.Hash); + set => AddValue(new ObservationValue(ValueKeys.Hash, value)); + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Observations/Events/DeviceAddedValueObservation.cs b/libraries/MTConnect.NET-Common/Observations/Events/DeviceAddedValueObservation.cs index 5793e5c76..d26e65299 100644 --- a/libraries/MTConnect.NET-Common/Observations/Events/DeviceAddedValueObservation.cs +++ b/libraries/MTConnect.NET-Common/Observations/Events/DeviceAddedValueObservation.cs @@ -3,18 +3,18 @@ namespace MTConnect.Observations.Events { - /// - /// UUID of new device added to an MTConnect Agent. - /// - public class DeviceAddedValueObservation : EventValueObservation + /// + /// UUID of new device added to an MTConnect Agent. + /// + public class DeviceAddedValueObservation : EventValueObservation { - /// - /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 - /// - public string Hash - { - get => GetValue(ValueKeys.Hash); - set => AddValue(new ObservationValue(ValueKeys.Hash, value)); - } - } + /// + /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 + /// + public string Hash + { + get => GetValue(ValueKeys.Hash); + set => AddValue(new ObservationValue(ValueKeys.Hash, value)); + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Observations/Events/DeviceChangedValueObservation.cs b/libraries/MTConnect.NET-Common/Observations/Events/DeviceChangedValueObservation.cs index 080273826..2f7c7c735 100644 --- a/libraries/MTConnect.NET-Common/Observations/Events/DeviceChangedValueObservation.cs +++ b/libraries/MTConnect.NET-Common/Observations/Events/DeviceChangedValueObservation.cs @@ -3,18 +3,18 @@ namespace MTConnect.Observations.Events { - /// - /// UUID of the device whose metadata has changed. - /// - public class DeviceChangedValueObservation : EventValueObservation + /// + /// UUID of the device whose metadata has changed. + /// + public class DeviceChangedValueObservation : EventValueObservation { - /// - /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 - /// - public string Hash - { - get => GetValue(ValueKeys.Hash); - set => AddValue(new ObservationValue(ValueKeys.Hash, value)); - } - } + /// + /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 + /// + public string Hash + { + get => GetValue(ValueKeys.Hash); + set => AddValue(new ObservationValue(ValueKeys.Hash, value)); + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Observations/Events/DeviceRemovedValueObservation.cs b/libraries/MTConnect.NET-Common/Observations/Events/DeviceRemovedValueObservation.cs index f7eaf9f91..df4a8b25d 100644 --- a/libraries/MTConnect.NET-Common/Observations/Events/DeviceRemovedValueObservation.cs +++ b/libraries/MTConnect.NET-Common/Observations/Events/DeviceRemovedValueObservation.cs @@ -3,18 +3,18 @@ namespace MTConnect.Observations.Events { - /// - /// UUID of a device removed from an MTConnect Agent. - /// - public class DeviceRemovedValueObservation : EventValueObservation + /// + /// UUID of a device removed from an MTConnect Agent. + /// + public class DeviceRemovedValueObservation : EventValueObservation { - /// - /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 - /// - public string Hash - { - get => GetValue(ValueKeys.Hash); - set => AddValue(new ObservationValue(ValueKeys.Hash, value)); - } - } + /// + /// Condensed message digest from a secure one-way hash function. FIPS PUB 180-4 + /// + public string Hash + { + get => GetValue(ValueKeys.Hash); + set => AddValue(new ObservationValue(ValueKeys.Hash, value)); + } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Streams/ComponentStream.cs b/libraries/MTConnect.NET-Common/Streams/ComponentStream.cs index 4e15b870b..ddd0e00dd 100644 --- a/libraries/MTConnect.NET-Common/Streams/ComponentStream.cs +++ b/libraries/MTConnect.NET-Common/Streams/ComponentStream.cs @@ -119,5 +119,5 @@ private static IEnumerable GetObservations(IEnumerable obser } return l; } - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Streams/IComponentStream.cs b/libraries/MTConnect.NET-Common/Streams/IComponentStream.cs index d872a1115..e1c1672e2 100644 --- a/libraries/MTConnect.NET-Common/Streams/IComponentStream.cs +++ b/libraries/MTConnect.NET-Common/Streams/IComponentStream.cs @@ -96,5 +96,5 @@ public interface IComponentStream /// Condition organizes the Data Entities returned in the MTConnectStreams XML document for those DataItem elements defined with a category attribute of CONDITION in the MTConnectDevices document. /// IEnumerable Conditions { get; } - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Streams/Output/ComponentStreamOutput.cs b/libraries/MTConnect.NET-Common/Streams/Output/ComponentStreamOutput.cs index c179331c3..d03470424 100644 --- a/libraries/MTConnect.NET-Common/Streams/Output/ComponentStreamOutput.cs +++ b/libraries/MTConnect.NET-Common/Streams/Output/ComponentStreamOutput.cs @@ -86,5 +86,5 @@ public ComponentStreamOutput(IComponentStream componentStream) } } } - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Streams/Output/IComponentStreamOutput.cs b/libraries/MTConnect.NET-Common/Streams/Output/IComponentStreamOutput.cs index 881e88db2..f12fe7661 100644 --- a/libraries/MTConnect.NET-Common/Streams/Output/IComponentStreamOutput.cs +++ b/libraries/MTConnect.NET-Common/Streams/Output/IComponentStreamOutput.cs @@ -45,5 +45,5 @@ public interface IComponentStreamOutput /// Gets All Observations (Samples, Events, and Conditions) /// IObservationOutput[] Observations { get; } - } + } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs b/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs index de4d2d049..8dab67e2a 100644 --- a/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs +++ b/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs @@ -62,11 +62,11 @@ public class MTConnectDeviceFinder /// Raised when a probe response is parsed successfully; immediately followed by . public event ProbeRequestHandler ProbeSuccessful; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised when a probe attempt fails (HTTP error, parse failure, non-MTConnect response). public event ProbeRequestHandler ProbeError; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Common/AppDomainTask.cs b/libraries/MTConnect.NET-HTTP/Ceen/Common/AppDomainTask.cs index 94a01b817..e362b2e83 100644 --- a/libraries/MTConnect.NET-HTTP/Ceen/Common/AppDomainTask.cs +++ b/libraries/MTConnect.NET-HTTP/Ceen/Common/AppDomainTask.cs @@ -48,32 +48,32 @@ public Task ResultAsync() return m_tcs.Task; } - /// - /// Handles a task by invoking the AppDomainTask after completion - /// - /// The running task. - /// The handler for the result. - /// The data type parameter. - public static void HandleTask(Task sourcetask, AppDomainTask handler) - { - sourcetask.ContinueWith(task => - { - if (task.IsCanceled) - handler.SetCancelled(); - else if (task.IsFaulted) - { - if (task.Exception == null) - handler.SetFailed(new Exception()); + /// + /// Handles a task by invoking the AppDomainTask after completion + /// + /// The running task. + /// The handler for the result. + /// The data type parameter. + public static void HandleTask(Task sourcetask, AppDomainTask handler) + { + sourcetask.ContinueWith(task => + { + if (task.IsCanceled) + handler.SetCancelled(); + else if (task.IsFaulted) + { + if (task.Exception == null) + handler.SetFailed(new Exception()); #pragma warning disable SYSLIB0050 // legacy AppDomain bridge: Formatter-based serialization gate is the documented contract - else if (task.Exception.GetType().IsSerializable) + else if (task.Exception.GetType().IsSerializable) #pragma warning restore SYSLIB0050 - handler.SetFailed(task.Exception); - else - handler.SetFailed(new Exception(task.Exception.Message)); - } - else - handler.SetComplete(task.Result); - }); - } - } + handler.SetFailed(task.Exception); + else + handler.SetFailed(new Exception(task.Exception.Message)); + } + else + handler.SetComplete(task.Result); + }); + } + } } diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs index 0b44bac10..0e173d38e 100644 --- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs +++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs @@ -176,9 +176,9 @@ public void Setup(bool usessl, ServerConfig config) [SupportedOSPlatform("windows")] #endif public void HandleRequest(SocketInformation socket, EndPoint remoteEndPoint, string logtaskid) - { - RunClient(socket, remoteEndPoint, logtaskid, Controller); - } + { + RunClient(socket, remoteEndPoint, logtaskid, Controller); + } /// /// Requests that this instance stops serving requests @@ -873,9 +873,9 @@ public static Task ListenAsync( [SupportedOSPlatform("windows")] #endif private static void RunClient(SocketInformation socketinfo, EndPoint remoteEndPoint, string logtaskid, RunnerControl controller) - { - RunClient(new Socket(socketinfo), remoteEndPoint, logtaskid, controller); - } + { + RunClient(new Socket(socketinfo), remoteEndPoint, logtaskid, controller); + } /// /// Handler method for connections diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs index 8f7d1fd2b..779395338 100644 --- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs +++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs @@ -162,7 +162,7 @@ public async Task DiscardAllAsync(System.Threading.CancellationToken cance return true; } -#endregion + #endregion } } diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs index 753cce92b..df9355b0d 100644 --- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs +++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs @@ -12,96 +12,96 @@ namespace Ceen.Httpd /// Configuration of a server instance /// internal class ServerConfig : ILoadedModuleInfo - { - /// - /// The socket backlog. - /// - public int SocketBacklog { get; set; } = 5; - /// - /// The maximum size of the request line. - /// - public int MaxRequestLineSize { get; set; } = 8 * 1024; - /// - /// The maximum size of the request header. - /// - public int MaxRequestHeaderSize { get; set; } = 64 * 1024; - /// - /// The maximum number of active requests. - /// - public int MaxActiveRequests { get; set; } = 500000; - /// - /// The maximum number of internal redirects - /// - public int MaxInternalRedirects { get; set; } = 5; - - /// - /// The maximum size of a POST request with url encoded data. - /// This is also the maximum size allowed for automatically - /// decoding multipart form data. - /// - public int MaxUrlEncodedFormSize { get; set; } = 5 * 1024 * 1024; - - /// - /// Allow automatic parsing of multipart form data - /// - public bool AutoParseMultipartFormData { get; set; } = true; - - /// - /// The maximum size of a POST request - /// - public long MaxPostSize { get; set; } = 100 * 1024 * 1024; - - /// - /// A flag indicating if the X-HTTP-Method-Override header is supported - /// - public bool AllowHttpMethodOverride { get; set; } = true; - - /// - /// A flag indicating if requests with HTTP/1.0 are allowed - /// - public bool AllowLegacyHttp { get; set; } = false; - - /// - /// A value indicating the name of the header, - /// the proxy uses to communicate the source IP of the request. - /// Commonly this is set to "X-Real-IP" or "X-Forwarded-For" - /// Leave blank to disable. - /// - public string AllowedSourceIPHeaderValue { get; set; } = null; - - /// - /// The request idle timeout in seconds. - /// - public int RequestIdleTimeoutSeconds { get; set; } = 5; - /// - /// The request header read timeout in seconds. - /// - public int RequestHeaderReadTimeoutSeconds { get; set; } = 10; - /// - /// The maximum number of requests to server with a single connection. - /// - public int KeepAliveMaxRequests { get; set; } = 30; - /// - /// The keep-alive timeout in seconds - /// - public int KeepAliveTimeoutSeconds { get; set; } = 10; + { + /// + /// The socket backlog. + /// + public int SocketBacklog { get; set; } = 5; + /// + /// The maximum size of the request line. + /// + public int MaxRequestLineSize { get; set; } = 8 * 1024; + /// + /// The maximum size of the request header. + /// + public int MaxRequestHeaderSize { get; set; } = 64 * 1024; + /// + /// The maximum number of active requests. + /// + public int MaxActiveRequests { get; set; } = 500000; + /// + /// The maximum number of internal redirects + /// + public int MaxInternalRedirects { get; set; } = 5; + + /// + /// The maximum size of a POST request with url encoded data. + /// This is also the maximum size allowed for automatically + /// decoding multipart form data. + /// + public int MaxUrlEncodedFormSize { get; set; } = 5 * 1024 * 1024; + + /// + /// Allow automatic parsing of multipart form data + /// + public bool AutoParseMultipartFormData { get; set; } = true; + + /// + /// The maximum size of a POST request + /// + public long MaxPostSize { get; set; } = 100 * 1024 * 1024; + + /// + /// A flag indicating if the X-HTTP-Method-Override header is supported + /// + public bool AllowHttpMethodOverride { get; set; } = true; + + /// + /// A flag indicating if requests with HTTP/1.0 are allowed + /// + public bool AllowLegacyHttp { get; set; } = false; + + /// + /// A value indicating the name of the header, + /// the proxy uses to communicate the source IP of the request. + /// Commonly this is set to "X-Real-IP" or "X-Forwarded-For" + /// Leave blank to disable. + /// + public string AllowedSourceIPHeaderValue { get; set; } = null; + + /// + /// The request idle timeout in seconds. + /// + public int RequestIdleTimeoutSeconds { get; set; } = 5; + /// + /// The request header read timeout in seconds. + /// + public int RequestHeaderReadTimeoutSeconds { get; set; } = 10; + /// + /// The maximum number of requests to server with a single connection. + /// + public int KeepAliveMaxRequests { get; set; } = 30; + /// + /// The keep-alive timeout in seconds + /// + public int KeepAliveTimeoutSeconds { get; set; } = 10; /// /// The maximum number of seconds a process may be running /// /// The max processing time seconds. public int MaxProcessingTimeSeconds { get; set; } = 30; - /// - /// The router instance to use for handling requests - /// - public IRouter Router { get; set; } - /// - /// The logger instances to use - /// - public IList Loggers { get; set; } - /// - /// The loaded module instance - /// - public IList Modules { get; set; } + /// + /// The router instance to use for handling requests + /// + public IRouter Router { get; set; } + /// + /// The logger instances to use + /// + public IList Loggers { get; set; } + /// + /// The loaded module instance + /// + public IList Modules { get; set; } /// /// The loaded post-processor instances /// @@ -111,193 +111,193 @@ internal class ServerConfig : ILoadedModuleInfo /// public Action AddDefaultResponseHeaders { get; set; } - /// - /// Gets or sets the default name of the server reported in response headers. - /// - /// The default name of the server. - public string DefaultServerName { get; set; } - - /// - /// The server certificate if used for serving SSL requests - /// - public X509Certificate SSLCertificate { get; set; } - /// - /// True if a client SSL certificate should be requested - /// - public bool SSLRequireClientCert { get; set; } = false; - /// - /// List the allowed SSL versions - /// - public SslProtocols SSLEnabledProtocols { get; set; } = SslProtocols.Tls12; - /// - /// Value indicating if SSL certificates are checked against a revocation list - /// - public bool SSLCheckCertificateRevocation { get; set; } = true; - - /// - /// A callback handler for debugging the internal server state - /// + /// + /// Gets or sets the default name of the server reported in response headers. + /// + /// The default name of the server. + public string DefaultServerName { get; set; } + + /// + /// The server certificate if used for serving SSL requests + /// + public X509Certificate SSLCertificate { get; set; } + /// + /// True if a client SSL certificate should be requested + /// + public bool SSLRequireClientCert { get; set; } = false; + /// + /// List the allowed SSL versions + /// + public SslProtocols SSLEnabledProtocols { get; set; } = SslProtocols.Tls12; + /// + /// Value indicating if SSL certificates are checked against a revocation list + /// + public bool SSLCheckCertificateRevocation { get; set; } = true; + + /// + /// A callback handler for debugging the internal server state + /// #pragma warning disable CS0649 // field is part of the public configuration surface, assigned by consumers - public DebugLogDelegate DebugLogHandler; + public DebugLogDelegate DebugLogHandler; #pragma warning restore CS0649 - /// - /// The storage creator - /// - public IStorageCreator Storage { get; set; } + /// + /// The storage creator + /// + public IStorageCreator Storage { get; set; } - /// - /// The loader context for this instance - /// + /// + /// The loader context for this instance + /// #pragma warning disable CS0649 // field is part of the public configuration surface, assigned by consumers - public IDisposable LoaderContext; + public IDisposable LoaderContext; #pragma warning restore CS0649 - - /// - /// Static initializer for the class. - /// - static ServerConfig() - { - } - - /// - /// Initializes a new instance of the class. - /// - public ServerConfig() - { - var version = typeof(ServerConfig).Assembly.GetName().Version; - DefaultServerName = string.Format("ceenhttpd/{0}.{1}", version.Major, version.Minor); - - AddDefaultResponseHeaders = DefaultHeaders; - } - - /// - /// Loads a certificate instance - /// - /// The path to the file with the certificate. - /// The certificate password. - public void LoadCertificate(string path, string password) - { + + /// + /// Static initializer for the class. + /// + static ServerConfig() + { + } + + /// + /// Initializes a new instance of the class. + /// + public ServerConfig() + { + var version = typeof(ServerConfig).Assembly.GetName().Version; + DefaultServerName = string.Format("ceenhttpd/{0}.{1}", version.Major, version.Minor); + + AddDefaultResponseHeaders = DefaultHeaders; + } + + /// + /// Loads a certificate instance + /// + /// The path to the file with the certificate. + /// The certificate password. + public void LoadCertificate(string path, string password) + { #if NET9_0_OR_GREATER this.SSLCertificate = X509CertificateLoader.LoadPkcs12FromFile(path, password ?? ""); #else - this.SSLCertificate = new X509Certificate2(path, password ?? ""); + this.SSLCertificate = new X509Certificate2(path, password ?? ""); #endif - } - - /// - /// Adds default headers to the output. - /// - /// The response to update. - public void DefaultHeaders(IHttpResponse response) - { - response.AddHeader("Date", DateTime.UtcNow.ToString("R", CultureInfo.InvariantCulture)); - - if (!string.IsNullOrWhiteSpace(DefaultServerName)) - response.AddHeader("Server", DefaultServerName); - } - - /// - /// Adds a logger instance to the server - /// - /// The server configuration. - /// The logger module to add. - public ServerConfig AddLogger(LogDelegate logger) - { - return AddLogger(new Logging.FunctionLogger(logger)); - } - - /// - /// Adds a logger instance to the server - /// - /// The server configuration. - /// The logger module to add. - public ServerConfig AddLogger(ILogger logger) - { - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - if (Loggers == null) - Loggers = new List(); - - Loggers.Add(logger); - return this; - } - - /// - /// Adds a route to this configuration - /// - /// The server configuration. - /// The handler function that will execute the operation. - public ServerConfig AddRoute(HttpHandlerDelegate handler) - { - if (handler == null) - throw new ArgumentNullException(nameof(handler)); - return AddRoute(null, new Handler.FunctionHandler(handler)); - } - - /// - /// Adds a route to this configuration - /// - /// The server configuration. - /// The handler function that will execute the operation. - public ServerConfig AddRoute(IHttpModule handler) - { - if (handler == null) - throw new ArgumentNullException(nameof(handler)); - return AddRoute(null, handler); - } - - /// - /// Adds a route to this configuration - /// - /// The server configuration. - /// The expression used to pre-filter requests before invoking the handler. - /// The handler function that will execute the operation. - public ServerConfig AddRoute(string route, HttpHandlerDelegate handler) - { - if (handler == null) - throw new ArgumentNullException(nameof(handler)); - return AddRoute(route, new Handler.FunctionHandler(handler)); - } - - /// - /// Adds a route to this configuration - /// - /// The server configuration. - /// The expression used to pre-filter requests before invoking the handler. - /// The handler module that will execute the operation. - public ServerConfig AddRoute(string route, IHttpModule handler) - { - if (handler == null) - throw new ArgumentNullException(nameof(handler)); - - Router rt; - if (this.Router == null) - this.Router = rt = new Router(); - else if (this.Router is Router) - rt = this.Router as Router; - else - throw new Exception($"Cannot use the AddRoute method unless the {nameof(Router)} is an instance of {typeof(Router).FullName}"); - - rt.Add(route, handler); - return this; - } - - /// - /// Adds a module instance to the server - /// - /// The server configuration. - /// The module to add. - public ServerConfig AddModule(IModule module) - { - if (module == null) - throw new ArgumentNullException(nameof(module)); - if (Modules == null) - Modules = new List(); - - Modules.Add(module); - return this; - } + } + + /// + /// Adds default headers to the output. + /// + /// The response to update. + public void DefaultHeaders(IHttpResponse response) + { + response.AddHeader("Date", DateTime.UtcNow.ToString("R", CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(DefaultServerName)) + response.AddHeader("Server", DefaultServerName); + } + + /// + /// Adds a logger instance to the server + /// + /// The server configuration. + /// The logger module to add. + public ServerConfig AddLogger(LogDelegate logger) + { + return AddLogger(new Logging.FunctionLogger(logger)); + } + + /// + /// Adds a logger instance to the server + /// + /// The server configuration. + /// The logger module to add. + public ServerConfig AddLogger(ILogger logger) + { + if (logger == null) + throw new ArgumentNullException(nameof(logger)); + if (Loggers == null) + Loggers = new List(); + + Loggers.Add(logger); + return this; + } + + /// + /// Adds a route to this configuration + /// + /// The server configuration. + /// The handler function that will execute the operation. + public ServerConfig AddRoute(HttpHandlerDelegate handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + return AddRoute(null, new Handler.FunctionHandler(handler)); + } + + /// + /// Adds a route to this configuration + /// + /// The server configuration. + /// The handler function that will execute the operation. + public ServerConfig AddRoute(IHttpModule handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + return AddRoute(null, handler); + } + + /// + /// Adds a route to this configuration + /// + /// The server configuration. + /// The expression used to pre-filter requests before invoking the handler. + /// The handler function that will execute the operation. + public ServerConfig AddRoute(string route, HttpHandlerDelegate handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + return AddRoute(route, new Handler.FunctionHandler(handler)); + } + + /// + /// Adds a route to this configuration + /// + /// The server configuration. + /// The expression used to pre-filter requests before invoking the handler. + /// The handler module that will execute the operation. + public ServerConfig AddRoute(string route, IHttpModule handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Router rt; + if (this.Router == null) + this.Router = rt = new Router(); + else if (this.Router is Router) + rt = this.Router as Router; + else + throw new Exception($"Cannot use the AddRoute method unless the {nameof(Router)} is an instance of {typeof(Router).FullName}"); + + rt.Add(route, handler); + return this; + } + + /// + /// Adds a module instance to the server + /// + /// The server configuration. + /// The module to add. + public ServerConfig AddModule(IModule module) + { + if (module == null) + throw new ArgumentNullException(nameof(module)); + if (Modules == null) + Modules = new List(); + + Modules.Add(module); + return this; + } /// /// Adds a post-processor instance to the server @@ -315,48 +315,48 @@ public ServerConfig AddPostProcessor(IPostProcessor postprocessor) return this; } - /// - /// Calls all shutdown modules - /// - /// A combined task - public async Task ShutdownAsync() - { - var res = Ceen.Context - .GetItemsOfType(this) - .Select(x => x.ShutdownAsync()) - .ToArray(); + /// + /// Calls all shutdown modules + /// + /// A combined task + public async Task ShutdownAsync() + { + var res = Ceen.Context + .GetItemsOfType(this) + .Select(x => x.ShutdownAsync()) + .ToArray(); + + if (res.Length != 0) + await Task.WhenAll(res); - if (res.Length != 0) - await Task.WhenAll(res); - - if (LoaderContext != null) - LoaderContext.Dispose(); - } + if (LoaderContext != null) + LoaderContext.Dispose(); + } /// /// The handlers loaded by the router /// - IEnumerable> ILoadedModuleInfo.Handlers - => (Router as Router)?.Rules.Select(x => new KeyValuePair(x.Key?.ToString(), x.Value)); + IEnumerable> ILoadedModuleInfo.Handlers + => (Router as Router)?.Rules.Select(x => new KeyValuePair(x.Key?.ToString(), x.Value)); /// /// The logger instances /// - IEnumerable ILoadedModuleInfo.Loggers - => Loggers; + IEnumerable ILoadedModuleInfo.Loggers + => Loggers; /// /// The loaded modules /// - IEnumerable ILoadedModuleInfo.Modules - => Modules; + IEnumerable ILoadedModuleInfo.Modules + => Modules; /// /// The loaded post-processors /// - IEnumerable ILoadedModuleInfo.PostProcessors - => PostProcessors; + IEnumerable ILoadedModuleInfo.PostProcessors + => PostProcessors; } } diff --git a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServer.cs b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServer.cs index 27fbbe24c..835a2dcaa 100644 --- a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServer.cs +++ b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServer.cs @@ -52,10 +52,10 @@ public class MTConnectHttpServer : IDisposable public event EventHandler ServerCertificateLoaded; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised for each diagnostic log line emitted by the underlying Ceen HTTP listener (intentionally spelled ServerLogRecevied for API compatibility). public event EventHandler ServerLogRecevied; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Event for when an error occurs with the HttpListener diff --git a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs index afc7c6872..16e42967b 100644 --- a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs +++ b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs @@ -81,23 +81,23 @@ public class MTConnectMqttClient : IMTConnectClient, IMTConnectEntityClient, IDi /// /// Raised when the connection to the MQTT broker is established /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler Connected; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when the connection to the MQTT broker is disconnected /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler Disconnected; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when the status of the connection to the MQTT broker has changed /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler ConnectionStatusChanged; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when an error occurs during connection to the MQTT broker @@ -147,9 +147,9 @@ public class MTConnectMqttClient : IMTConnectClient, IMTConnectEntityClient, IDi /// /// Raised when an MTConnectError Document is received /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler MTConnectError; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when any MQTT Message is received diff --git a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs index 3512a7558..f358f7e8b 100644 --- a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs +++ b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs @@ -111,15 +111,15 @@ public class MTConnectMqttExpandedClient : IMTConnectEntityClient, IDisposable /// Current broker session status. public MTConnectMqttConnectionStatus ConnectionStatus => _connectionStatus; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised after the broker session has been established and subscriptions are active. public event EventHandler Connected; - #pragma warning restore CS0067 +#pragma warning restore CS0067 - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised when the broker session is dropped (by the client, the broker, or a transport failure). public event EventHandler Disconnected; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// Raised whenever transitions; carries the new status. public event EventHandler ConnectionStatusChanged; diff --git a/libraries/MTConnect.NET-MQTT/MTConnectMqttBroker.cs b/libraries/MTConnect.NET-MQTT/MTConnectMqttBroker.cs index 96a27d13e..8dac12358 100644 --- a/libraries/MTConnect.NET-MQTT/MTConnectMqttBroker.cs +++ b/libraries/MTConnect.NET-MQTT/MTConnectMqttBroker.cs @@ -60,18 +60,18 @@ public class MTConnectMqttBroker : IHostedService /// Raised by the underlying when a downstream MQTT client disconnects. public event EventHandler ClientDisconnected; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised after each successful publish; the argument is the topic name. public event EventHandler MessageSent; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// Raised when the broker's connection to its underlying transport fails. public event EventHandler ConnectionError; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised when a publish fails after the connection has been established. public event EventHandler PublishError; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// diff --git a/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs b/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs index 1b858a8c4..5498ca04d 100644 --- a/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs +++ b/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs @@ -82,10 +82,10 @@ public class MTConnectMqttRelay : IDisposable /// Raised when the broker session is dropped (either by the relay, the broker, or a transport failure). public event EventHandler Disconnected; - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses /// Raised after each successful publish; the argument is the topic name. public event EventHandler MessageSent; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// Raised when establishing or maintaining the broker session throws. public event EventHandler ConnectionError; diff --git a/libraries/MTConnect.NET-SHDR/Adapters/ShdrAdapter.cs b/libraries/MTConnect.NET-SHDR/Adapters/ShdrAdapter.cs index 868b7e8c5..e1694e32e 100644 --- a/libraries/MTConnect.NET-SHDR/Adapters/ShdrAdapter.cs +++ b/libraries/MTConnect.NET-SHDR/Adapters/ShdrAdapter.cs @@ -97,9 +97,9 @@ public class ShdrAdapter /// /// Raised when an error occurs during an existing Agent connection. Includes the AgentClient ID as an argument. /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler AgentConnectionError; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// @@ -120,9 +120,9 @@ public class ShdrAdapter /// /// Raised when new data is sent to the Agent. Includes the AgentClient ID and the Line sent as an argument. /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler> DataSent; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// /// Raised when an error occurs when sending a new line to the Agent. Includes the AgentClient ID and the Error message as an argument. diff --git a/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs b/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs index a9c516012..0f0dc34d7 100644 --- a/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs +++ b/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs @@ -119,9 +119,9 @@ public class ShdrClient /// /// Raised when an SHDR Command message is received from the Adapter /// - #pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses +#pragma warning disable CS0067 // event is part of the public API surface, raised by subclasses public event EventHandler CommandReceived; - #pragma warning restore CS0067 +#pragma warning restore CS0067 /// Creates an unconfigured SHDR client with a fresh random and the heartbeat-on-change suppression enabled. diff --git a/libraries/MTConnect.NET-SysML/Xmi/OwnedComment.cs b/libraries/MTConnect.NET-SysML/Xmi/OwnedComment.cs index e95f39b5f..c9cc87906 100644 --- a/libraries/MTConnect.NET-SysML/Xmi/OwnedComment.cs +++ b/libraries/MTConnect.NET-SysML/Xmi/OwnedComment.cs @@ -9,7 +9,8 @@ namespace MTConnect.SysML.Xmi /// [Serializable, XmlRoot(ElementName = XmiHelper.XmiStructure.OWNED_COMMENT, Namespace = "")] [XmlInclude(typeof(UmlComment))] - public class OwnedComment : XmiElement { + public class OwnedComment : XmiElement + { /// /// body attribute /// diff --git a/libraries/MTConnect.NET-SysML/Xmi/PackagedElementCollection.cs b/libraries/MTConnect.NET-SysML/Xmi/PackagedElementCollection.cs index 50fa1521c..13b8667ee 100644 --- a/libraries/MTConnect.NET-SysML/Xmi/PackagedElementCollection.cs +++ b/libraries/MTConnect.NET-SysML/Xmi/PackagedElementCollection.cs @@ -109,7 +109,7 @@ public void CopyTo(T[] array, int arrayIndex) /// public IEnumerator GetEnumerator() => Items.GetEnumerator(); - + /// public bool Remove(T item) { @@ -135,7 +135,7 @@ public bool Remove(T item) IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - + /// /// Deserializes the XmlElement(s) into the provided . /// diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/SchemaVersionFieldsCoexistTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/SchemaVersionFieldsCoexistTests.cs index fac5503da..28555dd0c 100644 --- a/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/SchemaVersionFieldsCoexistTests.cs +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/SchemaVersionFieldsCoexistTests.cs @@ -50,7 +50,7 @@ public class SchemaVersionFieldsCoexistTests /// The surface. [TestCase(typeof(JsonMTConnectStreams), "envelope")] [TestCase(typeof(JsonMTConnectDevices), "envelope")] - [TestCase(typeof(JsonDevicesHeader), "Header")] + [TestCase(typeof(JsonDevicesHeader), "Header")] public void SchemaVersion_property_exists_with_camelCase_json_key( System.Type carrier, string surface) { diff --git a/tests/MTConnect.NET-XML-Tests/Streams/Current.cs b/tests/MTConnect.NET-XML-Tests/Streams/Current.cs index e8cb9efa1..d4c461501 100644 --- a/tests/MTConnect.NET-XML-Tests/Streams/Current.cs +++ b/tests/MTConnect.NET-XML-Tests/Streams/Current.cs @@ -26,7 +26,7 @@ public void ReadStreamTestFiles() using var xmlReader = XmlReader.Create(fileStream); var doc = XmlStreamsResponseDocument.ReadXml(xmlReader); - + Assert.That(doc != null, $"Read Original : {file}"); } } From 66b322c2b03f1dca6d41de7113df96f7fc73e7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 18 Aug 2026 03:19:10 +0200 Subject: [PATCH 02/14] ci: add dotnet format verification gate Adds a `format` job to the existing build-test-coverage workflow that runs `dotnet format MTConnect.NET.sln --verify-no-changes` on every push to master and every non-draft pull request targeting it, so formatting drift is caught before merge instead of silently compounding into the baseline. Uses the default (warn) severity rather than --severity info: at info severity dotnet format also attempts to auto-fix pre-existing Roslyn analyzer diagnostics across the repository, which is a separate, larger undertaking from verifying whitespace/style formatting and out of scope for this gate. --- .github/workflows/dotnet.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4bf2a6ec3..867be9dd7 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,6 +24,43 @@ on: - 'docs/**' jobs: + # ------------------------------------------------------------------ + # Job 0 — dotnet format gate. Verifies the tree matches the + # repository's .editorconfig with zero diff, so formatting drift + # (indentation, brace placement, whitespace) is caught at PR time + # instead of silently accumulating into the baseline every + # subsequent branch then has to inherit. Uses the default "warn" + # severity — the same severity `dotnet format` applies with no + # --severity flag — rather than --severity info: at info severity + # dotnet format additionally tries to auto-fix long-standing Roslyn + # analyzer diagnostics (CA1859, CA1861, CA1018, ...) across the + # repo, which is a materially different (and, on this SDK/analyzer + # combination, occasionally crash-prone) undertaking from verifying + # whitespace/style formatting and is out of scope for this gate. + # Runs on the same draft-skip gate as the other jobs and does not + # depend on them, so a formatting-only fix gets fast feedback. + # ------------------------------------------------------------------ + format: + name: format + if: github.event_name == 'push' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup .NET 8.0 + 9.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore solution + run: dotnet restore MTConnect.NET.sln + + - name: Verify format (dotnet format --verify-no-changes) + run: dotnet format MTConnect.NET.sln --verify-no-changes --no-restore + # ------------------------------------------------------------------ # Job 1 — unsharded unit + integration sweep on both OS legs. # Excludes Category=E2E altogether; the route walk is handled in From 86865e5037de2d9dd5cb7b909fccacb777312c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 11 Jun 2026 20:16:52 +0200 Subject: [PATCH 03/14] ci(workflow): drop matrix expressions from job display names --- .github/workflows/dotnet.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 867be9dd7..3c642275a 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -73,7 +73,7 @@ jobs: # paid exactly once across the whole workflow (in job 2 below). # ------------------------------------------------------------------ build-and-test: - name: build-and-test-${{ matrix.os }} + name: build-and-test # Skip drafts: run only on push-to-master + ready (non-draft) PRs. # The pull_request `types` list above includes `ready_for_review` # so CI fires the moment a draft is flipped to ready. @@ -360,7 +360,7 @@ jobs: # categories run exactly once (in job 1) across the workflow. # ------------------------------------------------------------------ route-check-e2e: - name: route-check-e2e-shard${{ matrix.shard }}of${{ matrix.shardTotal }} + name: route-check-e2e if: github.event_name == 'push' || github.event.pull_request.draft == false needs: docs-prepare runs-on: ubuntu-latest From 5faf40dffb2f4e01b80d2798bd8fbc1a9e8921dd Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 15:16:46 -0400 Subject: [PATCH 04/14] Fixed issue with SHDR dropping empty value This is often used for messages and program names. SHDR should pass the value as is to the Agent and the Agent should then decide (based on validation level) whether to accept the value or not. --- libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs b/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs index 47d3e98fc..1b0299cc9 100644 --- a/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs +++ b/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs @@ -391,7 +391,13 @@ private static IEnumerable FromKeyValuePairs(string input, long ti dataItem.AddValue(new ObservationValue(ValueKeys.Result, valueString != null ? valueString.ToString().Trim() : string.Empty)); + dataItems.Add(dataItem); + } + else + { + // Assume Empty Value + dataItem.AddValue(new ObservationValue(ValueKeys.Result, string.Empty)); dataItems.Add(dataItem); } } From c864087d38044189888a15e3a31157ad6566005e Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 16:10:17 -0400 Subject: [PATCH 05/14] Added separate DeviceValidationLevel propery and enum to handle MTConnectDevices validation. This allows a device to be validated at a different level than observations/assets --- .../Agents/DeviceValidationLevel.cs | 32 +++++++++++++++++++ .../Agents/MTConnectAgent.cs | 18 +++++------ .../Configurations/AgentConfiguration.cs | 7 ++++ .../Configurations/IAgentConfiguration.cs | 5 +++ 4 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs diff --git a/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs b/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs new file mode 100644 index 000000000..a3aa86125 --- /dev/null +++ b/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +namespace MTConnect.Agents +{ + /// + /// Controls how the Agent reacts when Devices data fails validation against + /// the MTConnect Standard. + /// + public enum DeviceValidationLevel + { + /// + /// Accept invalid device information; perform no validation action. + /// + Ignore, + + /// + /// Accept invalid device information but emit a validation warning. + /// + Warning, + + /// + /// Drop the invalid device information and continue processing the remainder. + /// + Remove, + + /// + /// Reject the entire device information on the first validation failure. + /// + Strict + } +} \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index ce2e70e9b..7b6aee4c3 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -1312,15 +1312,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericComponent in genericComponents) { var validationResults = new ValidationResult(false, $"Invalid Component : \"{genericComponent.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidComponentAdded, h => h(obj.Uuid, genericComponent, validationResults)); // Remove Component from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComponent(genericComponent.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveComponent(genericComponent.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } @@ -1332,15 +1332,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericComposition in genericCompositions) { var validationResults = new ValidationResult(false, $"Invalid Composition : \"{genericComposition.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidCompositionAdded, h => h(obj.Uuid, genericComposition, validationResults)); // Remove Compsition from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComposition(genericComposition.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveComposition(genericComposition.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } @@ -1352,15 +1352,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericDataItem in genericDataItems) { var validationResults = new ValidationResult(false, $"Invalid DataItem : \"{genericDataItem.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidDataItemAdded, h => h(obj.Uuid, genericDataItem, validationResults)); // Remove DataItem from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveDataItem(genericDataItem.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveDataItem(genericDataItem.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs index a27e8e4f0..81f351aaf 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs @@ -127,6 +127,12 @@ public string DefaultVersionValue [JsonPropertyName("enableValidation")] public bool EnableValidation { get; set; } + /// + /// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + /// + [JsonPropertyName("deviceValidationLevel")] + public DeviceValidationLevel DeviceValidationLevel { get; set; } + /// /// Gets or Sets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict /// @@ -155,6 +161,7 @@ public AgentConfiguration() ObservationBufferSize = 131072; AssetBufferSize = 1024; DefaultVersion = MTConnectVersions.Max; + DeviceValidationLevel = DeviceValidationLevel.Warning; InputValidationLevel = InputValidationLevel.Warning; ConvertUnits = true; IgnoreObservationCase = false; diff --git a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs index 7136d971d..14157fe28 100644 --- a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs @@ -65,6 +65,11 @@ public interface IAgentConfiguration /// bool EnableValidation { get; } + /// + /// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + /// + DeviceValidationLevel DeviceValidationLevel { get; } + /// /// Gets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict /// From d82363b7e3bb2601bd1e6e29134b0876c27dbc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 15 Jul 2026 22:58:16 +0200 Subject: [PATCH 06/14] docs(reference): regenerate configuration.md for DeviceValidationLevel The new `DeviceValidationLevel` property + enum added on this branch adds one config key row to `IAgentConfiguration` and `AgentConfiguration`; the drift gate `docs/scripts/generate-reference.sh --check` reports DRIFT until the generated `docs/reference/configuration.md` catches up. Runs the generator to produce the current output. --- docs/reference/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0a309c280..b3a6005dd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -83,6 +83,7 @@ Configuration for an MTConnect Agent | `changeToken` | `ChangeToken` | `string` | An opaque token regenerated each time the configuration is saved, allowing consumers to detect that the configuration has changed. | | `convertUnits` | `ConvertUnits` | `bool` | Gets or Sets the default for Converting Units when adding Observations | | `defaultVersion` | `DefaultVersionValue` | `string` | The string form of used for serialization; assigning a parseable version string updates . | +| `deviceValidationLevel` | `DeviceValidationLevel` | `DeviceValidationLevel` | Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `enableAgentDevice` | `EnableAgentDevice` | `bool` | Gets or Sets whether the Agent Device is output | | `enableMetrics` | `EnableMetrics` | `bool` | Gets or Sets whether Metrics are captured (ex. ObserationUpdateRate, AssetUpdateRate) | | `enableValidation` | `EnableValidation` | `bool` | Gets or Sets whether validation information is output | @@ -194,6 +195,7 @@ Configuration for an MTConnect Agent | `changeToken` | `ChangeToken` | `string` | An opaque token that changes whenever the underlying configuration source is reloaded, allowing consumers to detect that the configuration has been replaced. | | `convertUnits` | `ConvertUnits` | `bool` | Gets the default for Converting Units when adding Observations | | `defaultVersion` | `DefaultVersion` | `Version` | Gets the default MTConnect version to output response documents for. | +| `deviceValidationLevel` | `DeviceValidationLevel` | `DeviceValidationLevel` | Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `enableAgentDevice` | `EnableAgentDevice` | `bool` | Gets or Sets whether the Agent Device is output | | `enableMetrics` | `EnableMetrics` | `bool` | Gets whether Metrics are captured (ex. ObserationUpdateRate, AssetUpdateRate) | | `enableValidation` | `EnableValidation` | `bool` | Gets or Sets whether validation information is output | From 23e2f4e4659c3470c6bf67d97a42129b0dd961fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 18 Aug 2026 07:47:36 +0200 Subject: [PATCH 07/14] fix(common): coerce empty Result to UNAVAILABLE by DataItem value class Classifies each DataItem into one of three value classes derived from the MTConnect Standard, Part 2 - Devices Information Model, and coerces null, empty, or whitespace-only Results to UNAVAILABLE only when the target value class forbids the empty string: * Numeric (all Samples, per the "Sample MUST always be reported in float" requirement, and numeric-typed Events enumerated by the SysML model - PART_COUNT, LINE_NUMBER, BLOCK_COUNT, HARDNESS, TOOL_OFFSET, and kindred integer/float Result types): always coerced. * Enumeration (Events whose Type has a controlled vocabulary, e.g. EXECUTION, CONTROLLER_MODE, AVAILABILITY): coerced by default; the AllowEmptyResultForEnumEvents configuration flag preserves the empty Result when integrators require parity with adapters that emit empty values. * String (free-form Event Types such as PROGRAM, MESSAGE, TOOL_ID, ASSET_CHANGED, and every other non-vocabulary Type): never coerced; the standard's default Observation::result value type is `string`, and the reference C++ agent accepts empty strings for these Events. Adds DataItemValueClass and DataItem.GetValueClass(IDataItem) to expose the classification, plus the AllowEmptyResultForEnumEvents flag on IAgentConfiguration/AgentConfiguration (default false). Regenerates docs/reference/configuration.md for the new flag. --- docs/reference/configuration.md | 2 + .../Agents/MTConnectAgent.cs | 81 +++++++++++++ .../Configurations/AgentConfiguration.cs | 12 ++ .../Configurations/IAgentConfiguration.cs | 14 +++ .../MTConnect.NET-Common/Devices/DataItem.cs | 108 ++++++++++++++++++ .../Devices/DataItems/DataItemValueClass.cs | 63 ++++++++++ 6 files changed, 280 insertions(+) create mode 100644 libraries/MTConnect.NET-Common/Devices/DataItems/DataItemValueClass.cs diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b3a6005dd..b76a2be12 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -79,6 +79,7 @@ Configuration for an MTConnect Agent | Key | C# property | Type | Description | | --- | --- | --- | --- | +| `allowEmptyResultForEnumEvents` | `AllowEmptyResultForEnumEvents` | `bool` | Gets or Sets whether an empty, null, or whitespace-only Result is preserved for Event DataItems whose Type has a controlled vocabulary (for example EXECUTION, CONTROLLER_MODE). Defaults to false, which coerces such Results to UNAVAILABLE. Numeric DataItems (all Samples, and the numeric-typed Events enumerated by the MTConnect Standard SysML) are always coerced regardless of this flag; free-form String Event DataItems (PROGRAM, MESSAGE, TOOL_ID, ASSET_CHANGED, and every other non-vocabulary Type) always preserve the empty Result. | | `assetBufferSize` | `AssetBufferSize` | `uint` | The maximum number of assets the agent can hold in its buffer | | `changeToken` | `ChangeToken` | `string` | An opaque token regenerated each time the configuration is saved, allowing consumers to detect that the configuration has changed. | | `convertUnits` | `ConvertUnits` | `bool` | Gets or Sets the default for Converting Units when adding Observations | @@ -191,6 +192,7 @@ Configuration for an MTConnect Agent | Key | C# property | Type | Description | | --- | --- | --- | --- | +| `allowEmptyResultForEnumEvents` | `AllowEmptyResultForEnumEvents` | `bool` | Gets whether an empty, null, or whitespace-only Result is preserved for Event DataItems whose Type has a controlled vocabulary (for example EXECUTION, CONTROLLER_MODE). When false (the default), such Results are coerced to UNAVAILABLE to satisfy the MTConnect Standard requirement that a controlled-vocabulary Event's Result be a member of the vocabulary. When true, the empty Result is published verbatim, which some implementations rely on for parity with adapters that emit empty values. Numeric DataItems (all Samples, and the numeric-typed Events enumerated by the SysML model) are always coerced regardless of this flag, per the Part 2 "Sample MUST always be reported in float" requirement. Free-form String Event DataItems (PROGRAM, MESSAGE, TOOL_ID, ASSET_CHANGED, and every other non-vocabulary Type) always preserve the empty Result. | | `assetBufferSize` | `AssetBufferSize` | `uint` | The maximum number of assets the agent can hold in its buffer | | `changeToken` | `ChangeToken` | `string` | An opaque token that changes whenever the underlying configuration source is reloaded, allowing consumers to detect that the configuration has been replaced. | | `convertUnits` | `ConvertUnits` | `bool` | Gets the default for Converting Units when adding Observations | diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index 7b6aee4c3..65420fcfc 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -2152,6 +2152,28 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, var dataItem = GetDataItem(deviceUuid, input.DataItemKey); if (dataItem != null) { + // Coerce a null, empty, or whitespace-only Result to UNAVAILABLE according to the + // target DataItem's value class per the MTConnect Standard, Part 2 - Devices + // Information Model. The classification is derived once from the DataItem's + // Category, Representation, and Type; the coerce fires only for value classes + // whose Result grammar forbids an empty string. + // + // * Numeric (all Samples; numeric-typed Events per the SysML model): the + // "Sample MUST always be reported in float" requirement leaves no room for a + // non-parseable value. + // * Enumeration (Events whose Type has a controlled vocabulary): the Result MUST + // be a member of that vocabulary. The AllowEmptyResultForEnumEvents flag lets + // integrators opt out of the coerce for compatibility with adapters that emit + // empty values for these Events. + // * String (free-form Event Types such as PROGRAM, MESSAGE, TOOL_ID, + // ASSET_CHANGED): the standard's Observation::result default value type is + // `string` and does not forbid the empty string; the SDK preserves the Result + // verbatim, matching the behaviour of the reference C++ agent. + if (ShouldCoerceEmptyResultToUnavailable(dataItem, input)) + { + CoerceEmptyResultToUnavailable(input); + } + // Add required properties switch (dataItem.Representation) { @@ -2277,6 +2299,65 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, } + /// + /// Returns true when the observation's Result value is null, the empty string, or whitespace-only, + /// AND the DataItem's value class forbids an empty Result under the active configuration. + /// + /// + /// Numeric-typed DataItems (Samples and the numeric-typed Events enumerated in the MTConnect + /// Standard SysML) are coerced unconditionally: the Result MUST be parseable as a number. + /// Enumeration-typed Events are coerced by default; setting + /// to true preserves the + /// empty Result for compatibility with adapters that emit empty values for these Events. + /// Free-form String Event Types (PROGRAM, MESSAGE, TOOL_ID, ASSET_CHANGED, and every other + /// non-vocabulary Type) are never coerced: the standard's default value type for + /// Observation::result is string and does not forbid the empty string. + /// + private bool ShouldCoerceEmptyResultToUnavailable(IDataItem dataItem, IObservationInput input) + { + if (dataItem == null || input == null) return false; + if (dataItem.Category == DataItemCategory.CONDITION) return false; + if (!IsEmptyResult(input)) return false; + + var valueClass = DataItem.GetValueClass(dataItem); + switch (valueClass) + { + case DataItemValueClass.Numeric: return true; + case DataItemValueClass.Enumeration: return !_configuration.AllowEmptyResultForEnumEvents; + default: return false; + } + } + + /// + /// Returns true when the observation's Result value is null, the empty string, or whitespace-only. + /// + private static bool IsEmptyResult(IObservationInput input) + { + var result = input.GetValue(ValueKeys.Result); + if (result == null) return true; + return string.IsNullOrWhiteSpace(result); + } + + /// + /// Rewrites the observation's Result value to and flags the input as unavailable. + /// + /// + /// Removes any prior Result entry (so the Values collection does not carry a duplicate ValueKey), + /// adds the UNAVAILABLE sentinel, and sets so downstream + /// consumers that branch on the flag observe the coerced state. Spec authority: MTConnect Part 2 + /// Devices Information Model - Observation Information Model - Representation - Observation Values. + /// + private static void CoerceEmptyResultToUnavailable(IObservationInput input) + { + var preserved = (input.Values ?? Enumerable.Empty()) + .Where(v => v.Key != ValueKeys.Result) + .ToList(); + input.Values = preserved; + input.AddValue(ValueKeys.Result, Observation.Unavailable); + input.IsUnavailable = true; + } + + /// /// Add new Observations for DataItems to the Agent /// diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs index 81f351aaf..fd873c6c7 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs @@ -139,6 +139,17 @@ public string DefaultVersionValue [JsonPropertyName("inputValidationLevel")] public InputValidationLevel InputValidationLevel { get; set; } + /// + /// Gets or Sets whether an empty, null, or whitespace-only Result is preserved for Event DataItems + /// whose Type has a controlled vocabulary (for example EXECUTION, CONTROLLER_MODE). Defaults to + /// false, which coerces such Results to UNAVAILABLE. Numeric DataItems (all Samples, + /// and the numeric-typed Events enumerated by the MTConnect Standard SysML) are always coerced + /// regardless of this flag; free-form String Event DataItems (PROGRAM, MESSAGE, TOOL_ID, + /// ASSET_CHANGED, and every other non-vocabulary Type) always preserve the empty Result. + /// + [JsonPropertyName("allowEmptyResultForEnumEvents")] + public bool AllowEmptyResultForEnumEvents { get; set; } + /// /// Gets or Sets whether the Agent Device is output @@ -163,6 +174,7 @@ public AgentConfiguration() DefaultVersion = MTConnectVersions.Max; DeviceValidationLevel = DeviceValidationLevel.Warning; InputValidationLevel = InputValidationLevel.Warning; + AllowEmptyResultForEnumEvents = false; ConvertUnits = true; IgnoreObservationCase = false; EnableAgentDevice = true; diff --git a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs index 14157fe28..f6cc210c5 100644 --- a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs @@ -75,6 +75,20 @@ public interface IAgentConfiguration /// InputValidationLevel InputValidationLevel { get; } + /// + /// Gets whether an empty, null, or whitespace-only Result is preserved for Event DataItems whose + /// Type has a controlled vocabulary (for example EXECUTION, CONTROLLER_MODE). When false + /// (the default), such Results are coerced to UNAVAILABLE to satisfy the MTConnect + /// Standard requirement that a controlled-vocabulary Event's Result be a member of the + /// vocabulary. When true, the empty Result is published verbatim, which some + /// implementations rely on for parity with adapters that emit empty values. + /// Numeric DataItems (all Samples, and the numeric-typed Events enumerated by the SysML model) + /// are always coerced regardless of this flag, per the Part 2 "Sample MUST always be reported + /// in float" requirement. Free-form String Event DataItems (PROGRAM, MESSAGE, TOOL_ID, + /// ASSET_CHANGED, and every other non-vocabulary Type) always preserve the empty Result. + /// + bool AllowEmptyResultForEnumEvents { get; } + /// /// Gets or Sets whether the Agent Device is output diff --git a/libraries/MTConnect.NET-Common/Devices/DataItem.cs b/libraries/MTConnect.NET-Common/Devices/DataItem.cs index ad7f1935d..05c06738c 100644 --- a/libraries/MTConnect.NET-Common/Devices/DataItem.cs +++ b/libraries/MTConnect.NET-Common/Devices/DataItem.cs @@ -1202,6 +1202,114 @@ private static Dictionary GetAllTypes() } + private static readonly Dictionary _eventValueClassCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly object _eventValueClassLock = new object(); + + // Numeric-typed Event DataItems per the MTConnect Standard SysML model. Each entry corresponds + // to an EventEnum class whose `result` attribute is typed `integer` or `float` + // (see build/sysml-model/MTConnectSysMLModel.xml). Events not in this set and lacking a + // controlled-vocabulary enum in the MTConnect.Observations.Events namespace are treated as + // free-form String values, for which the empty string is a permissible Result. + private static readonly HashSet _numericEventTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ACTIVATION_COUNT", + "AXIS_FEEDRATE_OVERRIDE", + "BLOCK_COUNT", + "CYCLE_COUNT", + "DEACTIVATION_COUNT", + "HARDNESS", + "LINE_NUMBER", + "LOAD_COUNT", + "MATERIAL_LAYER", + "MEASUREMENT_VALUE", + "NETWORK_PORT", + "PART_COUNT", + "PART_INDEX", + "PATH_FEEDRATE_OVERRIDE", + "PROGRAM_NEST_LEVEL", + "ROTARY_VELOCITY_OVERRIDE", + "THICKNESS", + "TOOL_OFFSET", + "TRANSFER_COUNT", + "UNCERTAINTY", + "UNLOAD_COUNT" + }; + + + /// + /// Classifies the DataItem by the shape of value its Result carries, per the MTConnect Standard. + /// + /// + /// The classification follows the MTConnect Standard, Part 2 - Devices Information Model: + /// + /// SAMPLE observations are Numeric: the Value Properties of Sample + /// section states "Sample MUST always be reported in float". + /// EVENT observations with a VALUE representation are classified from + /// the DataItem's Type: an enumeration is inferred when a matching enum type exists in + /// MTConnect.Observations.Events; the numeric-typed Event list mirrors the + /// Standard SysML model; every other Type falls back to String, matching the default + /// value type for Observation::result. + /// EVENT observations with a DATA_SET, TABLE, or TIME_SERIES + /// representation carry structured payloads rather than a single Result and are + /// reported as String from this API (their coercion is not this classifier's + /// concern). + /// CONDITION observations report a condition state rather than a + /// Result value and are reported as String from this API (their coercion is governed + /// by ). + /// + /// + /// The DataItem to classify. When null, is returned. + /// The value class the DataItem's Result belongs to. + public static DataItemValueClass GetValueClass(IDataItem dataItem) + { + if (dataItem == null) return DataItemValueClass.String; + + if (dataItem.Category == DataItemCategory.SAMPLE) return DataItemValueClass.Numeric; + + if (dataItem.Category != DataItemCategory.EVENT) return DataItemValueClass.String; + + if (dataItem.Representation != DataItemRepresentation.VALUE) return DataItemValueClass.String; + + return ClassifyEventValueByType(dataItem.Type); + } + + private static DataItemValueClass ClassifyEventValueByType(string type) + { + if (string.IsNullOrEmpty(type)) return DataItemValueClass.String; + + lock (_eventValueClassLock) + { + if (_eventValueClassCache.TryGetValue(type, out var cached)) return cached; + } + + DataItemValueClass resolved; + + // Enumeration: a corresponding enum type exists in the MTConnect.Observations.Events + // namespace with the DataItem Type's PascalCase name (for example, EXECUTION -> Execution). + var enumTypeName = $"MTConnect.Observations.Events.{type.ToPascalCase()}"; + var enumType = typeof(DataItem).Assembly.GetType(enumTypeName, throwOnError: false, ignoreCase: false); + if (enumType != null && enumType.IsEnum) + { + resolved = DataItemValueClass.Enumeration; + } + else if (_numericEventTypes.Contains(type)) + { + resolved = DataItemValueClass.Numeric; + } + else + { + resolved = DataItemValueClass.String; + } + + lock (_eventValueClassLock) + { + _eventValueClassCache[type] = resolved; + } + + return resolved; + } + + /// /// Determines whether the specified DataItem is valid for use under the given MTConnect Standard version. /// diff --git a/libraries/MTConnect.NET-Common/Devices/DataItems/DataItemValueClass.cs b/libraries/MTConnect.NET-Common/Devices/DataItems/DataItemValueClass.cs new file mode 100644 index 000000000..95dadddce --- /dev/null +++ b/libraries/MTConnect.NET-Common/Devices/DataItems/DataItemValueClass.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +namespace MTConnect.Devices.DataItems +{ + /// + /// Classifies a DataItem by the shape of value its Result carries, so callers can + /// apply value-class-appropriate handling (for example, coercing an empty Result to + /// UNAVAILABLE for numeric and enumeration classes while leaving arbitrary + /// String values untouched). + /// + /// + /// The MTConnect Standard, Part 2 - Devices Information Model, ties Result validity + /// to the DataItem's category and (for Events) its controlled vocabulary: + /// + /// + /// Samples: the Value Properties of Sample section states that + /// "Sample MUST always be reported in float", making every SAMPLE observation a + /// numeric value. + /// + /// + /// Events with an enumerated vocabulary (EXECUTION, CONTROLLER_MODE, + /// etc.): the Result MUST be a member of the controlled vocabulary defined for + /// that Type. + /// + /// + /// Events with a free-form textual value (PROGRAM, MESSAGE, TOOL_ID, + /// ASSET_CHANGED, and other non-vocabulary types): the standard's + /// Observation::result definition sets the default value type to string, + /// and does not forbid the empty string. The maintainer of the reference + /// C++ agent confirmed that empty-string Results are accepted for these Events + /// (PR #217 discussion, 2026-08-18). + /// + /// + /// CONDITION observations are excluded from this classification; their state + /// (Normal, Warning, Fault, Unavailable) is a separate axis handled by + /// . + /// + public enum DataItemValueClass + { + /// + /// The DataItem Result is arbitrary text. Empty and whitespace values are + /// permitted; the SDK MUST NOT coerce them to UNAVAILABLE. + /// + String, + + /// + /// The DataItem Result MUST be a member of the controlled vocabulary defined + /// for its Type by the MTConnect Standard. Empty, whitespace, or off-vocabulary + /// Results are coerced to UNAVAILABLE unless the configuration flag + /// AllowEmptyResultForEnumEvents is set to true. + /// + Enumeration, + + /// + /// The DataItem Result MUST parse as a number. Empty, whitespace, and + /// non-parseable Results are coerced to UNAVAILABLE. All Samples fall + /// into this class per the Part 2 "Sample MUST always be reported in float" + /// requirement. + /// + Numeric + } +} From b7cbebbaa960c24fe51bdb47572143663539e35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 18 Aug 2026 07:47:36 +0200 Subject: [PATCH 08/14] test(common): pin value-class-aware empty-Result coerce contract Covers every arm of the classifier introduced on the same branch: * Numeric (SAMPLE, and numeric-typed Events like PART_COUNT): empty and whitespace Results are coerced to UNAVAILABLE under every input- validation level including Strict. * Enumeration Event (AVAILABILITY): the empty family is coerced when AllowEmptyResultForEnumEvents is false (the default), and preserved verbatim when the flag is true. A concrete vocabulary member is always preserved verbatim. * String Event (PROGRAM, MESSAGE, TOOL_ID): the empty family and arbitrary text are preserved verbatim, matching the reference C++ agent's behavior for non-vocabulary Event Types. * Direct GetValueClass assertions: classifies each representative DataItem into the correct value class. --- .../AddObservationEmptyResultCoerceTests.cs | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs new file mode 100644 index 000000000..777842215 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs @@ -0,0 +1,332 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Linq; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Observations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the value-class-aware empty-Result contract on + /// . + /// + /// The MTConnect Standard, Part 2 - Devices Information Model classifies each DataItem + /// by the shape of value its Result carries; the SDK coerces null / empty / whitespace + /// Results only when the DataItem's value class forbids the empty string: + /// + /// Numeric DataItems (all Samples per the "Sample MUST always be reported in + /// float" requirement, and the numeric-typed Events enumerated in the SysML + /// model - PART_COUNT, LINE_NUMBER, BLOCK_COUNT, HARDNESS, TOOL_OFFSET, and + /// kindred integer/float Result types) are always coerced. + /// Enumeration Events (EXECUTION, CONTROLLER_MODE, AVAILABILITY, and every + /// other Type with a controlled vocabulary) are coerced by default; the + /// escape hatch + /// preserves the empty Result when set to true. + /// Free-form String Events (PROGRAM, MESSAGE, TOOL_ID, ASSET_CHANGED, and + /// every other non-vocabulary Type) preserve the empty Result verbatim: the + /// standard's default value type for Observation::result is string, + /// and the reference C++ agent accepts empty strings for these Events + /// (confirmed by the MTConnect.NET maintainer on PR #217, 2026-08-18). + /// + /// + /// The convenience overload + /// AddObservation(string deviceKey, string dataItemKey, object value, DateTime timestamp) + /// exercises the canonical + /// AddObservation(string, IObservationInput, ...) path that every other + /// AddObservation overload routes through; the tests cast values to object so + /// the compiler resolves that overload unambiguously against the + /// (deviceKey, dataItemKey, valueKey, value) sibling. + /// + [TestFixture] + [Category("AddObservationEmptyResultCoerce")] + public class AddObservationEmptyResultCoerceTests + { + private const string DeviceKey = "U-COERCE"; + private const string DeviceId = "d-coerce"; + + private static readonly InputValidationLevel[] _nonStrictLevels = + { + InputValidationLevel.Ignore, + InputValidationLevel.Warning, + InputValidationLevel.Remove, + }; + + private static readonly object?[] _nullEmptyWhitespaceValues = + { + new object?[] { null }, + new object?[] { string.Empty }, + new object?[] { " " }, + new object?[] { "\t" }, + new object?[] { "\n" }, + }; + + + // -------------------------------------------------------------------- // + // Numeric class: SAMPLE // + // Spec: MTConnect Part 2, Value Properties of Sample - // + // "Sample MUST always be reported in float." // + // -------------------------------------------------------------------- // + + /// Empty Result on a SAMPLE DataItem is coerced to UNAVAILABLE under every non-Strict input-validation level. + [Test] + [TestCaseSource(nameof(_nonStrictLevels))] + public void Sample_EmptyResult_Coerced_To_Unavailable_Under_NonStrict_Levels(InputValidationLevel level) + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + using var agent = NewAgent(level, dataItem: new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True, "empty-Result Sample observation must reach the buffer post-coerce"); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable), + "Part 2 mandates that Sample values be numeric; empty MUST become UNAVAILABLE"); + } + + /// The null / empty / whitespace family is coerced for every SAMPLE DataItem. + [Test] + [TestCaseSource(nameof(_nullEmptyWhitespaceValues))] + public void Sample_NullEmptyOrWhitespaceResult_Coerced_To_Unavailable(object? badValue) + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object?)badValue!, DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable)); + } + + /// Strict input-validation still admits an empty Sample Result via the coerce path rather than silently dropping it. + [Test] + public void Sample_EmptyResult_Under_Strict_Coerced_And_Lands() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Strict, dataItem: new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True, "Strict must coerce to UNAVAILABLE - never silently drop an empty Result"); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable)); + } + + + // -------------------------------------------------------------------- // + // Enumeration class: EVENT with controlled vocabulary // + // Spec: MTConnect Part 2, Observation::result - the Result MUST be a // + // member of the controlled vocabulary defined for the DataItem's Type. // + // AVAILABILITY (Availability enum: AVAILABLE, UNAVAILABLE) exercises // + // an EVENT Type with a small, unambiguous vocabulary. // + // -------------------------------------------------------------------- // + + /// Empty Result on an Enumeration EVENT DataItem is coerced to UNAVAILABLE when the flag defaults to false. + [Test] + [TestCaseSource(nameof(_nullEmptyWhitespaceValues))] + public void EnumEvent_EmptyResult_Coerced_To_Unavailable_When_Flag_False(object? badValue) + { + const string dataItemKey = AvailabilityDataItem.NameId; + using var agent = NewAgent( + InputValidationLevel.Warning, + allowEmptyResultForEnumEvents: false, + dataItem: new AvailabilityDataItem(DeviceId)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object?)badValue!, DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable)); + } + + /// Empty Result on an Enumeration EVENT DataItem is preserved verbatim when the escape-hatch flag is enabled. + [Test] + public void EnumEvent_EmptyResult_Preserved_When_Flag_True() + { + const string dataItemKey = AvailabilityDataItem.NameId; + using var agent = NewAgent( + InputValidationLevel.Warning, + allowEmptyResultForEnumEvents: true, + dataItem: new AvailabilityDataItem(DeviceId)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True); + var currentResult = CurrentResult(agent, dataItemKey) ?? string.Empty; + Assert.That(currentResult, Is.EqualTo(string.Empty), + "AllowEmptyResultForEnumEvents=true preserves the empty Result for controlled-vocabulary Events"); + } + + /// A concrete vocabulary member is preserved verbatim on an Enumeration EVENT DataItem regardless of the flag. + [Test] + public void EnumEvent_ConcreteResult_Is_Preserved_Verbatim() + { + const string dataItemKey = AvailabilityDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new AvailabilityDataItem(DeviceId)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)"AVAILABLE", DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo("AVAILABLE"), + "the coerce must not substitute a sentinel for a valid controlled-vocabulary member"); + } + + + // -------------------------------------------------------------------- // + // String class: free-form EVENT (PROGRAM, MESSAGE, TOOL_ID) // + // Spec: MTConnect Part 2, Observation::result - the default value type // + // for Observation::result is `string`; the standard does not forbid // + // the empty string for non-vocabulary Event Types. The reference C++ // + // agent accepts empty strings for these Events. // + // -------------------------------------------------------------------- // + + /// Empty Result on a PROGRAM Event DataItem is preserved verbatim: PROGRAM is a free-form String Event Type. + [Test] + [TestCaseSource(nameof(_nullEmptyWhitespaceValues))] + public void StringEvent_Program_EmptyResult_Preserved(object? badValue) + { + const string dataItemKey = ProgramDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new ProgramDataItem(DeviceId, ProgramDataItem.SubTypes.ACTIVE)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object?)badValue!, DateTime.UtcNow); + + Assert.That(added, Is.True); + // Null / whitespace values pass through unchanged; the observation's Result equals the input's string form. + var expected = badValue?.ToString() ?? string.Empty; + var currentResult = (CurrentResult(agent, dataItemKey) as string) ?? string.Empty; + Assert.That(currentResult, Is.EqualTo(expected), + "PROGRAM is a free-form String Event Type; empty and whitespace Results MUST be preserved"); + } + + /// Empty Result on a MESSAGE Event DataItem is preserved verbatim. + [Test] + public void StringEvent_Message_EmptyResult_Preserved() + { + const string dataItemKey = MessageDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new MessageDataItem(DeviceId)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True); + var currentResult = (CurrentResult(agent, dataItemKey) as string) ?? string.Empty; + Assert.That(currentResult, Is.EqualTo(string.Empty), + "MESSAGE is a free-form String Event Type; empty Results MUST be preserved"); + } + + /// Empty Result on a TOOL_ID Event DataItem is preserved verbatim. + [Test] + public void StringEvent_ToolId_EmptyResult_Preserved() + { + const string dataItemKey = ToolIdDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new ToolIdDataItem(DeviceId)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True); + var currentResult = (CurrentResult(agent, dataItemKey) as string) ?? string.Empty; + Assert.That(currentResult, Is.EqualTo(string.Empty), + "TOOL_ID is a free-form String Event Type; empty Results MUST be preserved"); + } + + /// A concrete String Result is passed through verbatim on a free-form Event DataItem. + [Test] + public void StringEvent_Program_ConcreteResult_Is_Preserved_Verbatim() + { + const string dataItemKey = ProgramDataItem.NameId; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: new ProgramDataItem(DeviceId, ProgramDataItem.SubTypes.ACTIVE)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)"PART_A.NC", DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo("PART_A.NC"), + "PROGRAM MUST pass free-form text through verbatim"); + } + + + // -------------------------------------------------------------------- // + // Numeric-typed EVENT (SysML integer/float Result) // + // -------------------------------------------------------------------- // + + /// Empty Result on a numeric-typed Event (PART_COUNT: SysML `result: integer`) is coerced. + [Test] + public void NumericEvent_PartCount_EmptyResult_Coerced_To_Unavailable() + { + const string dataItemKey = PartCountDataItem.NameId; + using var agent = NewAgent( + InputValidationLevel.Warning, + dataItem: new PartCountDataItem(DeviceId, PartCountDataItem.SubTypes.ALL)); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable), + "PART_COUNT's Result type is integer per the SysML model; empty MUST become UNAVAILABLE"); + } + + + // -------------------------------------------------------------------- // + // Classifier direct tests // + // -------------------------------------------------------------------- // + + /// Direct assertion: classifies representative DataItems into the three value classes. + [Test] + public void GetValueClass_Classifies_Representative_DataItems() + { + Assert.That(DataItem.GetValueClass(new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL)), Is.EqualTo(DataItemValueClass.Numeric), + "SAMPLE observations are Numeric per Part 2 - Sample MUST be float"); + Assert.That(DataItem.GetValueClass(new AvailabilityDataItem(DeviceId)), Is.EqualTo(DataItemValueClass.Enumeration), + "AVAILABILITY has a controlled vocabulary (Availability enum)"); + Assert.That(DataItem.GetValueClass(new ExecutionDataItem(DeviceId)), Is.EqualTo(DataItemValueClass.Enumeration), + "EXECUTION has a controlled vocabulary (Execution enum)"); + Assert.That(DataItem.GetValueClass(new ProgramDataItem(DeviceId, ProgramDataItem.SubTypes.ACTIVE)), Is.EqualTo(DataItemValueClass.String), + "PROGRAM carries free-form text"); + Assert.That(DataItem.GetValueClass(new MessageDataItem(DeviceId)), Is.EqualTo(DataItemValueClass.String), + "MESSAGE carries free-form text"); + Assert.That(DataItem.GetValueClass(new ToolIdDataItem(DeviceId)), Is.EqualTo(DataItemValueClass.String), + "TOOL_ID carries free-form text"); + Assert.That(DataItem.GetValueClass(new AssetChangedDataItem(DeviceId)), Is.EqualTo(DataItemValueClass.String), + "ASSET_CHANGED carries the asset id as free-form text"); + Assert.That(DataItem.GetValueClass(new PartCountDataItem(DeviceId, PartCountDataItem.SubTypes.ALL)), Is.EqualTo(DataItemValueClass.Numeric), + "PART_COUNT Result is integer per the SysML model"); + } + + + // -------------------------------------------------------------------- // + // Helpers // + // -------------------------------------------------------------------- // + + private static MTConnectAgentBroker NewAgent( + InputValidationLevel level, + IDataItem dataItem, + bool allowEmptyResultForEnumEvents = false) + { + var config = new AgentConfiguration + { + InputValidationLevel = level, + AllowEmptyResultForEnumEvents = allowEmptyResultForEnumEvents, + }; + var agent = new MTConnectAgentBroker(config); + agent.Start(); + + var device = new Device + { + Id = DeviceId, + Name = DeviceId, + Uuid = DeviceKey, + }; + device.AddDataItem(dataItem); + + var added = agent.AddDevice(device); + Assert.That(added, Is.Not.Null, "AddDevice must succeed for test pre-condition"); + return agent; + } + + private static object? CurrentResult(IMTConnectAgentBroker agent, string dataItemKey) + { + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + return current?.GetValue(ValueKeys.Result); + } + } +} From 2773335cec85020b91ea6237c45155551f8a5473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 00:23:48 +0200 Subject: [PATCH 09/14] test(common,shdr): close coverage-FLOOR gaps on PR 217 empty-Result contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage FLOOR (CONVENTIONS §1.0d-trigies-novodecies) — extends the existing `AddObservationEmptyResultCoerceTests` and adds two new fixtures to close the audit gaps identified during Ultrareview cycle: 1. Numeric-Event allow-list exhaustiveness. Every entry in the SysML numeric-typed Event allow-list (ACTIVATION_COUNT, AXIS_FEEDRATE_OVERRIDE, BLOCK_COUNT, CYCLE_COUNT, DEACTIVATION_COUNT, HARDNESS, LINE_NUMBER, LOAD_COUNT, MATERIAL_LAYER, MEASUREMENT_VALUE, NETWORK_PORT, PART_COUNT, PART_INDEX, PATH_FEEDRATE_OVERRIDE, PROGRAM_NEST_LEVEL, ROTARY_VELOCITY_OVERRIDE, THICKNESS, TOOL_OFFSET, TRANSFER_COUNT, UNCERTAINTY, UNLOAD_COUNT) has both a direct `DataItem.GetValueClass` classifier assertion and an end-to-end `AddObservation` coerce assertion, so any drift between the DataItem.cs allow-list and the test surface is caught immediately. 2. DataItemValueClass switch-arm guard. Iterates every enum value and asserts each is reachable through `GetValueClass` on a representative DataItem, catching a silently-added arm that has no coerce-path branch. 3. `DeviceValidationLevelSplitTests` — new fixture pinning the split between `DeviceValidationLevel` (governs `NormalizeDevice`) and `InputValidationLevel` (governs the observation-input path). Covers every arm (Ignore, Warning, Remove, Strict) × every branch point (invalid Component, Composition, DataItem) and pins the independence invariant (`DeviceValidationLevel = Strict` rejects even when `InputValidationLevel = Ignore`, and the converse). 4. `ShdrDataItemParseEmptyValueTests` — new fixture pinning the parse-side complement of the coerce. `ShdrDataItem.FromString` previously dropped any key-value pair whose value token was missing (bare trailing key `avail|AVAILABLE|program` and trailing-pipe `program|`); the fix preserves such pairs with an empty-string Result so the agent's value-class-aware coerce runs at the correct layer. Verified RED against `upstream/master` (3 of 5 tests fail on pre-fix ShdrDataItem.cs); GREEN with the fix in place. All 84 Common-Tests observation-coerce/device-validation cases plus the 5 SHDR parse-empty-value cases pass on bluefin under `dotnet test -c Debug`. Composition: - §1.0d-trigies-octies (TDD-before-fix) — SHDR fixture proven RED pre-fix, GREEN post-fix. - §1.0d-trigies-novodecies (coverage FLOOR) — every enum arm covered, every allow-list entry pinned. - §10 + §10a (100 % + positive-and-negative) — Numeric coerce, Enum coerce, String preserve, and the flag-driven Enum-preserve escape hatch all covered. --- .../AddObservationEmptyResultCoerceTests.cs | 111 ++++++ .../Agents/DeviceValidationLevelSplitTests.cs | 352 ++++++++++++++++++ .../ShdrDataItemParseEmptyValueTests.cs | 134 +++++++ 3 files changed, 597 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/DeviceValidationLevelSplitTests.cs create mode 100644 tests/MTConnect.NET-SHDR-Tests/ShdrDataItemParseEmptyValueTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs index 777842215..e22310219 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs @@ -266,6 +266,117 @@ public void NumericEvent_PartCount_EmptyResult_Coerced_To_Unavailable() } + // -------------------------------------------------------------------- // + // Numeric-typed EVENT allow-list exhaustiveness (SysML integer/float) // + // -------------------------------------------------------------------- // + + /// + /// The SysML numeric-typed Event allow-list mirrored in . + /// Every entry MUST classify as ; adding a new numeric + /// Event type without extending this list, or dropping an entry, is caught here. + /// + private static readonly string[] _numericEventTypeAllowList = + { + "ACTIVATION_COUNT", + "AXIS_FEEDRATE_OVERRIDE", + "BLOCK_COUNT", + "CYCLE_COUNT", + "DEACTIVATION_COUNT", + "HARDNESS", + "LINE_NUMBER", + "LOAD_COUNT", + "MATERIAL_LAYER", + "MEASUREMENT_VALUE", + "NETWORK_PORT", + "PART_COUNT", + "PART_INDEX", + "PATH_FEEDRATE_OVERRIDE", + "PROGRAM_NEST_LEVEL", + "ROTARY_VELOCITY_OVERRIDE", + "THICKNESS", + "TOOL_OFFSET", + "TRANSFER_COUNT", + "UNCERTAINTY", + "UNLOAD_COUNT", + }; + + /// + /// Every entry in the SysML numeric-typed Event allow-list classifies as + /// so its empty Result is coerced, + /// not preserved verbatim. + /// + [Test] + [TestCaseSource(nameof(_numericEventTypeAllowList))] + public void GetValueClass_Numeric_Event_AllowList_Entry_Is_Numeric(string typeId) + { + var dataItem = new DataItem + { + Id = $"{DeviceId}_{typeId}", + Category = DataItemCategory.EVENT, + Type = typeId, + Representation = DataItemRepresentation.VALUE, + }; + + Assert.That(DataItem.GetValueClass(dataItem), Is.EqualTo(DataItemValueClass.Numeric), + $"SysML numeric-typed Event '{typeId}' MUST classify as Numeric so its empty Result is coerced"); + } + + /// + /// Every entry in the SysML numeric-typed Event allow-list has its empty Result coerced to + /// UNAVAILABLE through AddObservation, exercising the coerce path end-to-end + /// for each type. + /// + [Test] + [TestCaseSource(nameof(_numericEventTypeAllowList))] + public void NumericEvent_AllowList_Entry_EmptyResult_Coerced_To_Unavailable(string typeId) + { + var dataItemKey = $"{typeId}_key"; + var dataItem = new DataItem + { + Id = $"{DeviceId}_{typeId}", + Name = dataItemKey, + Category = DataItemCategory.EVENT, + Type = typeId, + Representation = DataItemRepresentation.VALUE, + }; + + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var added = agent.AddObservation(DeviceKey, dataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True, $"{typeId} empty-Result observation must reach the buffer post-coerce"); + Assert.That(CurrentResult(agent, dataItemKey), Is.EqualTo(Observation.Unavailable), + $"{typeId} is numeric per the SysML model; empty MUST become UNAVAILABLE"); + } + + + // -------------------------------------------------------------------- // + // DataItemValueClass switch arm guard // + // -------------------------------------------------------------------- // + + /// + /// Guard: every enum value has been observed in the + /// classifier's output for a representative DataItem. Adding a new arm without a matching + /// coerce-path branch is caught here as a compile-time-adjacent tripwire. + /// + [Test] + public void DataItemValueClass_All_Enum_Arms_Are_Reachable_From_GetValueClass() + { + var observedArms = new System.Collections.Generic.HashSet + { + DataItem.GetValueClass(new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL)), + DataItem.GetValueClass(new AvailabilityDataItem(DeviceId)), + DataItem.GetValueClass(new ProgramDataItem(DeviceId, ProgramDataItem.SubTypes.ACTIVE)), + }; + + foreach (DataItemValueClass arm in Enum.GetValues(typeof(DataItemValueClass))) + { + Assert.That(observedArms, Does.Contain(arm), + $"Enum arm {arm} has no representative DataItem covered by GetValueClass; add one or extend the classifier"); + } + } + + // -------------------------------------------------------------------- // // Classifier direct tests // // -------------------------------------------------------------------- // diff --git a/tests/MTConnect.NET-Common-Tests/Agents/DeviceValidationLevelSplitTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/DeviceValidationLevelSplitTests.cs new file mode 100644 index 000000000..d226b96f8 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/DeviceValidationLevelSplitTests.cs @@ -0,0 +1,352 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Linq; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the split between + /// (governs MTConnectAgent.NormalizeDevice: reaction to unknown Components, + /// Compositions, and DataItems on an added Device) and + /// (governs the per-observation + /// input path). Before this PR the two axes shared a single enum, so integrators could + /// not run InputValidationLevel = Strict alongside a permissive device model. The + /// fixture proves both invariants: + /// + /// Each arm (Ignore, Warning, Remove, Strict) + /// drives NormalizeDevice's reaction to a generic (unknown-type) Component, + /// Composition, and DataItem exactly as documented. + /// The two axes are independent: an InputValidationLevel = Strict + /// configuration with DeviceValidationLevel = Ignore still accepts a Device + /// whose model contains generic entities, and vice versa a + /// DeviceValidationLevel = Strict with InputValidationLevel = Ignore + /// rejects the same Device. + /// Both configuration properties default to + /// / , matching the pre-split single-axis + /// default so existing integrators are unaffected. + /// + /// + [TestFixture] + [Category("DeviceValidationLevelSplit")] + public class DeviceValidationLevelSplitTests + { + private const string DeviceUuid = "u-devvalid"; + private const string DeviceId = "d-devvalid"; + + + // -------------------------------------------------------------------- // + // Configuration defaults + independence // + // -------------------------------------------------------------------- // + + /// Configuration defaults preserve pre-split behaviour: both axes are Warning. + [Test] + public void AgentConfiguration_Defaults_Both_Axes_To_Warning() + { + var config = new AgentConfiguration(); + + Assert.That(config.DeviceValidationLevel, Is.EqualTo(DeviceValidationLevel.Warning), + "DeviceValidationLevel must default to Warning so existing single-axis integrators are unaffected"); + Assert.That(config.InputValidationLevel, Is.EqualTo(InputValidationLevel.Warning), + "InputValidationLevel must default to Warning so existing single-axis integrators are unaffected"); + } + + /// The two validation axes can hold different values simultaneously - the whole point of the split. + [Test] + public void AgentConfiguration_Two_Axes_Can_Hold_Different_Values() + { + var config = new AgentConfiguration + { + DeviceValidationLevel = DeviceValidationLevel.Ignore, + InputValidationLevel = InputValidationLevel.Strict, + }; + + Assert.That(config.DeviceValidationLevel, Is.EqualTo(DeviceValidationLevel.Ignore)); + Assert.That(config.InputValidationLevel, Is.EqualTo(InputValidationLevel.Strict)); + } + + + // -------------------------------------------------------------------- // + // DeviceValidationLevel arms x invalid Component // + // -------------------------------------------------------------------- // + + /// DeviceValidationLevel = Ignore accepts a Device carrying a generic (unknown-type) Component unchanged. + [Test] + public void InvalidComponent_DeviceValidationLevel_Ignore_Accepts_Device_Untouched() + { + using var agent = NewAgent(DeviceValidationLevel.Ignore, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "Ignore must accept the Device"); + Assert.That(added.Components?.Any(c => c.Id == "generic-comp-1"), Is.True, + "Ignore must leave the generic Component in place"); + } + + /// DeviceValidationLevel = Warning accepts the Device with the generic Component preserved. + [Test] + public void InvalidComponent_DeviceValidationLevel_Warning_Accepts_Device_Preserves_Component() + { + using var agent = NewAgent(DeviceValidationLevel.Warning, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "Warning must not reject the Device"); + Assert.That(added.Components?.Any(c => c.Id == "generic-comp-1"), Is.True, + "Warning must not silently drop the generic Component"); + } + + /// DeviceValidationLevel = Remove accepts the Device but drops the generic Component. + [Test] + public void InvalidComponent_DeviceValidationLevel_Remove_Drops_Component_Keeps_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Remove, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "Remove must accept the Device"); + Assert.That(added.Components?.Any(c => c.Id == "generic-comp-1"), Is.Not.True, + "Remove must drop the generic Component from the accepted Device"); + } + + /// DeviceValidationLevel = Strict rejects the entire Device on the first generic Component. + [Test] + public void InvalidComponent_DeviceValidationLevel_Strict_Rejects_Entire_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Strict, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Null, + "Strict MUST reject the entire Device on the first generic Component"); + } + + + // -------------------------------------------------------------------- // + // DeviceValidationLevel arms x invalid Composition // + // -------------------------------------------------------------------- // + + /// DeviceValidationLevel = Strict rejects a Device carrying a generic (unknown-type) Composition. + [Test] + public void InvalidComposition_DeviceValidationLevel_Strict_Rejects_Entire_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Strict, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComposition(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Null, + "Strict MUST reject the entire Device on the first generic Composition"); + } + + /// DeviceValidationLevel = Remove drops the generic Composition but accepts the rest of the Device. + [Test] + public void InvalidComposition_DeviceValidationLevel_Remove_Drops_Composition_Keeps_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Remove, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComposition(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "Remove must accept the Device"); + Assert.That(added.Compositions?.Any(x => x.Id == "generic-composition-1"), Is.Not.True, + "Remove must drop the generic Composition from the accepted Device"); + } + + + // -------------------------------------------------------------------- // + // DeviceValidationLevel arms x invalid DataItem // + // -------------------------------------------------------------------- // + + /// DeviceValidationLevel = Strict rejects a Device carrying a generic (unknown-type) DataItem. + [Test] + public void InvalidDataItem_DeviceValidationLevel_Strict_Rejects_Entire_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Strict, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericDataItem(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Null, + "Strict MUST reject the entire Device on the first generic DataItem"); + } + + /// DeviceValidationLevel = Remove drops the generic DataItem but accepts the rest of the Device. + [Test] + public void InvalidDataItem_DeviceValidationLevel_Remove_Drops_DataItem_Keeps_Device() + { + using var agent = NewAgent(DeviceValidationLevel.Remove, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericDataItemOnChildComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "Remove must accept the Device"); + var allDataItems = added.GetDataItems() ?? Enumerable.Empty(); + Assert.That(allDataItems.Any(d => d.Id == "generic-di-1"), Is.False, + "Remove must drop the generic DataItem from the accepted Device (including sub-Components)"); + } + + + // -------------------------------------------------------------------- // + // Independence: DeviceValidationLevel does not depend on // + // InputValidationLevel and vice versa // + // -------------------------------------------------------------------- // + + /// + /// InputValidationLevel = Strict does NOT trigger device rejection - the split + /// isolates the observation-input axis from the device-shape axis. + /// + [Test] + public void InputValidationLevel_Strict_Does_Not_Reject_Device_With_Generic_Component() + { + using var agent = NewAgent(DeviceValidationLevel.Ignore, InputValidationLevel.Strict); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, + "InputValidationLevel = Strict with DeviceValidationLevel = Ignore MUST NOT reject the Device"); + Assert.That(added.Components?.Any(c => c.Id == "generic-comp-1"), Is.True, + "the split makes InputValidationLevel irrelevant to device-shape validation"); + } + + /// + /// DeviceValidationLevel = Strict rejects regardless of a permissive InputValidationLevel - + /// the two axes truly separate. + /// + [Test] + public void DeviceValidationLevel_Strict_Rejects_Even_When_InputValidationLevel_Is_Ignore() + { + using var agent = NewAgent(DeviceValidationLevel.Strict, InputValidationLevel.Ignore); + var device = NewDeviceWithGenericComponent(); + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Null, + "DeviceValidationLevel = Strict must reject regardless of a permissive InputValidationLevel"); + } + + + // -------------------------------------------------------------------- // + // Helpers // + // -------------------------------------------------------------------- // + + private static MTConnectAgentBroker NewAgent( + DeviceValidationLevel deviceLevel, + InputValidationLevel inputLevel) + { + var config = new AgentConfiguration + { + DeviceValidationLevel = deviceLevel, + InputValidationLevel = inputLevel, + }; + var agent = new MTConnectAgentBroker(config, initializeAgentDevice: false); + agent.Start(); + return agent; + } + + private static Device NewDeviceWithGenericComponent() + { + var device = new Device + { + Id = DeviceId, + Name = DeviceId, + Uuid = DeviceUuid, + }; + + // Generic Component: Type is unknown to the SDK, so it will be treated as an invalid component by NormalizeDevice. + var genericComponent = new Component + { + Id = "generic-comp-1", + Name = "generic-comp-1", + Type = "UNKNOWN_COMPONENT_TYPE", + }; + device.AddComponent(genericComponent); + + return device; + } + + private static Device NewDeviceWithGenericComposition() + { + var device = new Device + { + Id = DeviceId, + Name = DeviceId, + Uuid = DeviceUuid, + }; + + // Attach the generic Composition directly to the Device so NormalizeDevice's + // obj.RemoveComposition(id) call reaches it. Placing the Composition on a sub-Component + // would exercise a different (non-recursive) removal path and would not exhibit the + // "Remove strips it" contract. + device.AddComposition(new Composition + { + Id = "generic-composition-1", + Name = "generic-composition-1", + Type = "UNKNOWN_COMPOSITION_TYPE", + }); + + return device; + } + + private static Device NewDeviceWithGenericDataItem() + { + var device = new Device + { + Id = DeviceId, + Name = DeviceId, + Uuid = DeviceUuid, + }; + + device.AddDataItem(new DataItem + { + Id = "generic-di-1", + Name = "generic-di-1", + Category = DataItemCategory.EVENT, + Type = "UNKNOWN_EVENT_TYPE", + Representation = DataItemRepresentation.VALUE, + }); + + return device; + } + + private static Device NewDeviceWithGenericDataItemOnChildComponent() + { + var device = new Device + { + Id = DeviceId, + Name = DeviceId, + Uuid = DeviceUuid, + }; + + // Attach the generic DataItem to a valid child Component so NormalizeDevice's + // obj.RemoveDataItem(id) traversal (which walks child Components, not the Device's + // own DataItems collection) finds and drops it. + var host = new MTConnect.Devices.Components.LinearComponent + { + Id = "host-linear", + Name = "host-linear", + }; + host.AddDataItem(new DataItem + { + Id = "generic-di-1", + Name = "generic-di-1", + Category = DataItemCategory.EVENT, + Type = "UNKNOWN_EVENT_TYPE", + Representation = DataItemRepresentation.VALUE, + }); + device.AddComponent(host); + + return device; + } + } +} diff --git a/tests/MTConnect.NET-SHDR-Tests/ShdrDataItemParseEmptyValueTests.cs b/tests/MTConnect.NET-SHDR-Tests/ShdrDataItemParseEmptyValueTests.cs new file mode 100644 index 000000000..8a5a57764 --- /dev/null +++ b/tests/MTConnect.NET-SHDR-Tests/ShdrDataItemParseEmptyValueTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Linq; +using MTConnect.Observations; +using MTConnect.Shdr; +using NUnit.Framework; + +namespace MTConnect.Tests.Shdr +{ + /// + /// Pins the SHDR parse-side complement of the value-class-aware empty-Result coerce. + /// + /// Prior to this PR silently DROPPED + /// any key-value pair whose value token was missing (a trailing DataItemKey with no + /// |value segment). That masked genuine empty-value updates from the adapter and + /// prevented the agent's value-class-aware coerce from ever seeing them. The fix + /// (ShdrDataItem.FromKeyValuePairs, else branch on y != null) preserves + /// the DataItem with an empty-string Result so the coerce runs at the correct layer. + /// + /// + /// These tests exercise the parser directly. They are the RED that would have failed + /// against the pre-fix ShdrDataItem and turn GREEN with the fix in place. They + /// pin the load-bearing observable: the count of parsed DataItems is one higher than + /// the pre-fix behaviour for a trailing-key line, and the trailing DataItem carries an + /// empty value rather than being absent from the output. + /// + /// + [TestFixture] + [Category("ShdrDataItemParseEmptyValue")] + public class ShdrDataItemParseEmptyValueTests + { + private const string Timestamp = "2000-01-01T00:00:00.0000000Z"; + + + // -------------------------------------------------------------------- // + // Trailing key with no |value segment (the load-bearing case) // + // -------------------------------------------------------------------- // + + /// + /// A line with a single trailing key that lacks a |value segment yields exactly + /// one DataItem whose Result is the empty string - previously the parser dropped it. + /// + [Test] + public void FromString_TrailingKey_Without_Value_Segment_Yields_Empty_Result() + { + var items = ShdrDataItem.FromString($"{Timestamp}|program").ToList(); + + Assert.That(items.Count, Is.EqualTo(1), + "the trailing 'program' key with no |value MUST no longer be dropped"); + Assert.That(items[0].DataItemKey, Is.EqualTo("program"), + "DataItemKey must survive parse"); + Assert.That(items[0].GetValue(ValueKeys.Result), Is.EqualTo(string.Empty), + "the fix assigns an empty-string Result rather than leaving Values unset"); + } + + /// + /// In a multi-pair line where the LAST key lacks a |value segment, the earlier + /// pairs still parse normally AND the trailing key lands with an empty Result. Before + /// the fix the parser returned only the earlier pairs; the trailing key was silently + /// dropped and the adapter update was invisible to the agent. + /// + [Test] + public void FromString_MultiPair_With_Trailing_Bare_Key_Preserves_All_Items() + { + var items = ShdrDataItem.FromString($"{Timestamp}|avail|AVAILABLE|program").ToList(); + + Assert.That(items.Count, Is.EqualTo(2), + "trailing bare key must not be dropped from a multi-pair line"); + Assert.That(items[0].DataItemKey, Is.EqualTo("avail")); + Assert.That(items[0].GetValue(ValueKeys.Result), Is.EqualTo("AVAILABLE"), + "the concrete pair MUST parse unchanged"); + Assert.That(items[1].DataItemKey, Is.EqualTo("program")); + Assert.That(items[1].GetValue(ValueKeys.Result), Is.EqualTo(string.Empty), + "the trailing bare key MUST land with an empty-string Result"); + } + + + // -------------------------------------------------------------------- // + // Trailing pipe (key|) - also newly recovered by the fix. // + // Pre-fix the parser dropped this too because GetNextSegment("key|") // + // returns null when the pipe is the last character, driving the same // + // no-value-segment path that the else branch now handles. // + // -------------------------------------------------------------------- // + + /// A line with key| (trailing pipe, empty value token) yields one DataItem with an empty Result. + [Test] + public void FromString_TrailingKey_With_Empty_Value_Token_Yields_Empty_Result() + { + var items = ShdrDataItem.FromString($"{Timestamp}|program|").ToList(); + + Assert.That(items.Count, Is.EqualTo(1), + "'program|' MUST parse as one DataItem with an empty Result; pre-fix the parser dropped it"); + Assert.That(items[0].DataItemKey, Is.EqualTo("program")); + Assert.That(items[0].GetValue(ValueKeys.Result), Is.EqualTo(string.Empty)); + } + + /// + /// A multi-pair line with an empty-value pair in the middle (program||) parses + /// every pair and the middle DataItem carries an empty Result - the pre-fix corollary. + /// + [Test] + public void FromString_MultiPair_With_Empty_Middle_Value_Preserves_All_Items() + { + var items = ShdrDataItem.FromString($"{Timestamp}|avail|AVAILABLE|program||execution|READY").ToList(); + + Assert.That(items.Count, Is.EqualTo(3), "all three pairs MUST parse"); + Assert.That(items[0].DataItemKey, Is.EqualTo("avail")); + Assert.That(items[0].GetValue(ValueKeys.Result), Is.EqualTo("AVAILABLE")); + Assert.That(items[1].DataItemKey, Is.EqualTo("program")); + Assert.That(items[1].GetValue(ValueKeys.Result), Is.EqualTo(string.Empty), + "middle empty value MUST parse as empty string, not drop"); + Assert.That(items[2].DataItemKey, Is.EqualTo("execution")); + Assert.That(items[2].GetValue(ValueKeys.Result), Is.EqualTo("READY")); + } + + + // -------------------------------------------------------------------- // + // Baseline preservation: a concrete value MUST still round-trip // + // -------------------------------------------------------------------- // + + /// A concrete value on a single-pair line survives parse unchanged. + [Test] + public void FromString_TrailingKey_With_Concrete_Value_Preserves_Value_Verbatim() + { + var items = ShdrDataItem.FromString($"{Timestamp}|program|TEST.NC").ToList(); + + Assert.That(items.Count, Is.EqualTo(1)); + Assert.That(items[0].DataItemKey, Is.EqualTo("program")); + Assert.That(items[0].GetValue(ValueKeys.Result), Is.EqualTo("TEST.NC"), + "concrete Result MUST NOT be affected by the empty-value fix"); + } + } +} From 9f4135e2df60d115532fefb111b75082d1d91b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 00:30:18 +0200 Subject: [PATCH 10/14] test(common): pin non-VALUE representation SAMPLE payload preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four RED cases to `AddObservationEmptyResultCoerceTests` that pin the value-class classifier and empty-Result coerce paths for the DATA_SET, TABLE, and TIME_SERIES representations on SAMPLE DataItems. Each case asserts that a legitimate structured payload survives the coerce path verbatim — Result key is not rewritten to UNAVAILABLE, SampleCount / Count is not overwritten to 0, and Samples / Entries survive: - `Sample_TimeSeries_Payload_Preserved_Not_Coerced` - `Sample_DataSet_Payload_Preserved_Not_Coerced` - `Sample_Table_Payload_Preserved_Not_Coerced` - `GetValueClass_Sample_NonValueRepresentation_Is_String` Spec authority: MTConnect Standard, Part 2 — Devices Information Model, Value Properties of Sample. The DATA_SET / TABLE / TIME_SERIES representations carry structured payloads (Entries, Cells, Samples) rather than a single Result. `TimeSeriesObservationInput`, `DataSetObservationInput`, and `TableObservationInput` legitimately omit the Result key by design — corrupting them with the UNAVAILABLE sentinel would break spec compliance for every legitimate multi-value observation. Reproduces the classifier hole on HEAD `7a0e8529`: `GetValueClass` returns Numeric for any SAMPLE DataItem regardless of Representation, so `IsEmptyResult(input)` sees a null Result key on a structured input and `ShouldCoerceEmptyResultToUnavailable` fires — overwriting Result with UNAVAILABLE and (via `IsUnavailable = true`) triggering the representation switch to set SampleCount / Count = 0. All four cases are RED against this commit; the immediately-following commit introduces the classifier gate that turns them GREEN. Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- .../AddObservationEmptyResultCoerceTests.cs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs index e22310219..04984dca6 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs @@ -404,6 +404,145 @@ public void GetValueClass_Classifies_Representative_DataItems() } + // -------------------------------------------------------------------- // + // Structured-representation SAMPLE tests // + // Spec: MTConnect Part 2, Value Properties of Sample - the // + // DATA_SET / TABLE / TIME_SERIES representations carry structured // + // payloads (Entries, Cells, Samples) rather than a single Result. // + // The empty-Result coerce MUST NOT fire for these representations: // + // their inputs legitimately omit the Result key by design, and // + // corrupting them with the UNAVAILABLE sentinel would break spec // + // compliance for every legitimate multi-value observation. // + // -------------------------------------------------------------------- // + + /// A TIME_SERIES SAMPLE observation with a real Samples payload MUST NOT be coerced to UNAVAILABLE despite the absence of a Result key. + [Test] + public void Sample_TimeSeries_Payload_Preserved_Not_Coerced() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TIME_SERIES, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedSamples = new[] { 1.0, 2.0, 3.0 }; + var input = new MTConnect.Input.TimeSeriesObservationInput(dataItemKey, expectedSamples, sampleRate: 10.0) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + input.SampleCount = expectedSamples.Length; + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "TIME_SERIES SAMPLE observation must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "TIME_SERIES SAMPLE observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "TIME_SERIES SAMPLE payload MUST NOT be corrupted to UNAVAILABLE - the Result key is absent by design"); + Assert.That(current.GetValue(ValueKeys.SampleCount).ToInt(), Is.EqualTo(expectedSamples.Length), + "TIME_SERIES SAMPLE SampleCount MUST survive - the coerce path must not overwrite it with 0"); + Assert.That(TimeSeriesObservation.GetSamples(current.Values).ToArray(), Is.EqualTo(expectedSamples), + "TIME_SERIES SAMPLE Samples payload MUST survive verbatim - no structural loss to the coerce path"); + } + + /// A DATA_SET SAMPLE observation with real Entries MUST NOT be coerced to UNAVAILABLE. + [Test] + public void Sample_DataSet_Payload_Preserved_Not_Coerced() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.DATA_SET, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new IDataSetEntry[] + { + new DataSetEntry("a", "1.0"), + new DataSetEntry("b", "2.0"), + }; + var input = new MTConnect.Input.DataSetObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "DATA_SET SAMPLE observation must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "DATA_SET SAMPLE observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "DATA_SET SAMPLE payload MUST NOT be corrupted to UNAVAILABLE - the Result key is absent by design"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "DATA_SET SAMPLE Count MUST survive - the coerce path must not overwrite it with 0"); + } + + /// A TABLE SAMPLE observation with real Cells MUST NOT be coerced to UNAVAILABLE. + [Test] + public void Sample_Table_Payload_Preserved_Not_Coerced() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TABLE, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new ITableEntry[] + { + new TableEntry("row1", new ITableCell[] + { + new TableCell("col1", "1.0"), + new TableCell("col2", "2.0"), + }), + }; + var input = new MTConnect.Input.TableObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "TABLE SAMPLE observation must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "TABLE SAMPLE observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "TABLE SAMPLE payload MUST NOT be corrupted to UNAVAILABLE - the Result key is absent by design"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "TABLE SAMPLE Count MUST survive - the coerce path must not overwrite it with 0"); + } + + /// Direct classifier assertion: SAMPLE DataItems with non-VALUE representations are classified as String (their coercion is not this classifier's concern). + [Test] + public void GetValueClass_Sample_NonValueRepresentation_Is_String() + { + var timeSeries = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TIME_SERIES, + }; + Assert.That(DataItem.GetValueClass(timeSeries), Is.EqualTo(DataItemValueClass.String), + "SAMPLE + TIME_SERIES carries a Samples payload rather than a single Result - the classifier must not report Numeric"); + + var dataSet = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.DATA_SET, + }; + Assert.That(DataItem.GetValueClass(dataSet), Is.EqualTo(DataItemValueClass.String), + "SAMPLE + DATA_SET carries an Entries payload rather than a single Result - the classifier must not report Numeric"); + + var table = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TABLE, + }; + Assert.That(DataItem.GetValueClass(table), Is.EqualTo(DataItemValueClass.String), + "SAMPLE + TABLE carries a Cells payload rather than a single Result - the classifier must not report Numeric"); + } + + // -------------------------------------------------------------------- // // Helpers // // -------------------------------------------------------------------- // From 56670c57a43a340109fc4e0211a7ce2ff3f3d005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 00:30:36 +0200 Subject: [PATCH 11/14] fix(common): gate value-class Numeric classification on VALUE representation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the value-class classifier introduced in 5fd11302 so DATA_SET, TABLE, and TIME_SERIES observations bypass the empty-Result coerce path for both SAMPLE and EVENT categories. Before this fix, `GetValueClass` returned `Numeric` for every SAMPLE DataItem regardless of Representation, so a legitimate `TimeSeriesObservationInput` / `DataSetObservationInput` / `TableObservationInput` (which by design carries `ValueKeys.SampleN` / `Count` / structured `Entries` rather than `ValueKeys.Result`) tripped `IsEmptyResult` — the coerce then rewrote the observation to `UNAVAILABLE` and set `IsUnavailable = true`, which in turn caused the downstream representation switch in `AddObservation` to overwrite the caller's `SampleCount` / `Count` with `0`. Every legitimate multi-value SAMPLE observation was silently corrupted. The classifier now short-circuits every non-VALUE representation to `String` at the top of `GetValueClass`, mirroring the already-correct handling for non-VALUE EVENTs. Structured payloads are outside the empty-Result classifier's remit for both categories. Spec authority: MTConnect Standard, Part 2 — Devices Information Model, Value Properties of Sample and Representation. The RED cases added in the immediately-preceding commit turn GREEN with this change; the pre-existing 4013-case suite continues to pass unchanged. Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- .../MTConnect.NET-Common/Devices/DataItem.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/libraries/MTConnect.NET-Common/Devices/DataItem.cs b/libraries/MTConnect.NET-Common/Devices/DataItem.cs index 05c06738c..987754530 100644 --- a/libraries/MTConnect.NET-Common/Devices/DataItem.cs +++ b/libraries/MTConnect.NET-Common/Devices/DataItem.cs @@ -1242,17 +1242,19 @@ private static Dictionary GetAllTypes() /// /// The classification follows the MTConnect Standard, Part 2 - Devices Information Model: /// - /// SAMPLE observations are Numeric: the Value Properties of Sample - /// section states "Sample MUST always be reported in float". + /// SAMPLE observations with a VALUE representation are Numeric: the + /// Value Properties of Sample section states "Sample MUST always be reported in + /// float". /// EVENT observations with a VALUE representation are classified from /// the DataItem's Type: an enumeration is inferred when a matching enum type exists in /// MTConnect.Observations.Events; the numeric-typed Event list mirrors the /// Standard SysML model; every other Type falls back to String, matching the default /// value type for Observation::result. - /// EVENT observations with a DATA_SET, TABLE, or TIME_SERIES - /// representation carry structured payloads rather than a single Result and are - /// reported as String from this API (their coercion is not this classifier's - /// concern). + /// SAMPLE or EVENT observations with a DATA_SET, TABLE, or + /// TIME_SERIES representation carry structured payloads (Entries, Cells, Samples) + /// rather than a single Result and are reported as String from this API (their + /// coercion is not this classifier's concern - their inputs legitimately omit the + /// Result key by design). /// CONDITION observations report a condition state rather than a /// Result value and are reported as String from this API (their coercion is governed /// by ). @@ -1264,12 +1266,17 @@ public static DataItemValueClass GetValueClass(IDataItem dataItem) { if (dataItem == null) return DataItemValueClass.String; + if (dataItem.Category == DataItemCategory.CONDITION) return DataItemValueClass.String; + + // DATA_SET / TABLE / TIME_SERIES observations carry structured payloads rather than a + // single Result and are outside the empty-Result classifier's remit for both SAMPLE + // and EVENT categories. + if (dataItem.Representation != DataItemRepresentation.VALUE) return DataItemValueClass.String; + if (dataItem.Category == DataItemCategory.SAMPLE) return DataItemValueClass.Numeric; if (dataItem.Category != DataItemCategory.EVENT) return DataItemValueClass.String; - if (dataItem.Representation != DataItemRepresentation.VALUE) return DataItemValueClass.String; - return ClassifyEventValueByType(dataItem.Type); } From 89731f0401a707bb28ab57bb8e2a11874ade363a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 00:59:39 +0200 Subject: [PATCH 12/14] docs(concepts): reflect InputValidationLevel/DeviceValidationLevel split and value-class coerce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `docs/concepts/agent-validation-events.md`: mermaid diagram now shows the two independent axes — device-shape validators (Invalid Component / Composition / DataItem / Device) branch on `DeviceValidationLevel`; observation and asset validators (Invalid Observation / Asset) branch on `InputValidationLevel`. Wire-up example and the contributor extension template updated to gate each noun on the appropriate axis, so a contributor adding a new device-shape validator does not accidentally wire it to `InputValidationLevel`. Cross-reference block enumerates both knobs. - `docs/concepts/observations.md`: new "Value-class-aware empty-Result coerce" subsection under "The Unavailable sentinel" pins the wire-visible contract — the value class each DataItem belongs to (Numeric, Enumeration, String), the empty-Result policy for each, and the representation-aware short-circuit that preserves DATA_SET / TABLE / TIME_SERIES payloads. - `libraries/MTConnect.NET-Common/Agents/InputValidationLevel.cs`: XML summary narrowed from "input data fails validation against the device model" to "an observation or asset input fails per-DataItem validation"; cross-references `DeviceValidationLevel` for the device-shape axis and documents the common integrator profile of `InputValidationLevel = Strict` alongside `DeviceValidationLevel = Warning`. Closes the documentation-audit findings raised in the Ultrareview cycle against `b2dcd165` — stale `InputValidationLevel` references in the validation-events concept page, missing subsection on the coerce policy in the observations concept page, and the stale surface summary on `InputValidationLevel`. Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- docs/concepts/agent-validation-events.md | 66 +++++++++++++------ docs/concepts/observations.md | 12 ++++ .../Agents/InputValidationLevel.cs | 7 +- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/docs/concepts/agent-validation-events.md b/docs/concepts/agent-validation-events.md index c3dc53649..e1f134a4e 100644 --- a/docs/concepts/agent-validation-events.md +++ b/docs/concepts/agent-validation-events.md @@ -24,17 +24,29 @@ The family is closed across the five Device-tree element kinds plus the top-leve ```mermaid flowchart TB - Input["Add input (Device / Observation / Asset)"] --> Validate{"Validate"} - Validate -- pass --> Apply["Apply to agent state"] - Validate -- fail --> Decide{"InputValidationLevel"} - Decide -- Ignore --> Apply - Decide -- Warn --> RaiseW["Raise Invalid*Added"] - Decide -- Remove --> RaiseR["Raise Invalid*Added + remove subtree"] - Decide -- Strict --> RaiseS["Raise Invalid*Added + reject whole Device"] - RaiseW --> Apply - RaiseR --> Apply + Input["Add input"] --> Kind{"Kind"} + Kind -- Device shape
(Component / Composition / DataItem) --> ValidateD{"Validate"} + Kind -- Observation / Asset --> ValidateI{"Validate"} + ValidateD -- pass --> Apply["Apply to agent state"] + ValidateD -- fail --> DecideD{"DeviceValidationLevel"} + DecideD -- Ignore --> Apply + DecideD -- Warning --> RaiseDW["Raise Invalid<Component/Composition/DataItem>Added"] + DecideD -- Remove --> RaiseDR["Raise Invalid*Added + prune subtree"] + DecideD -- Strict --> RaiseDS["Raise Invalid*Added + reject whole Device"] + ValidateI -- pass --> Apply + ValidateI -- fail --> DecideI{"InputValidationLevel"} + DecideI -- Ignore --> Apply + DecideI -- Warning --> RaiseIW["Raise InvalidObservationAdded / InvalidAssetAdded"] + DecideI -- Remove --> RaiseIR["Raise Invalid*Added + drop input"] + DecideI -- Strict --> RaiseIS["Raise Invalid*Added + reject the input"] + RaiseDW --> Apply + RaiseDR --> Apply + RaiseIW --> Apply + RaiseIR --> Apply ``` +The two axes are independent — a common integrator profile is `InputValidationLevel = Strict` alongside `DeviceValidationLevel = Warning`, so bad observations are rejected while minor device-model shape drift is merely logged. + ## Consumer POV ### Why events, not exceptions @@ -42,7 +54,7 @@ flowchart TB `MTConnectAgent` is a long-running service. Throwing on the first invalid Component or stray Observation would crash the host process and take the rest of the agent down with it. The event-based contract lets callers: - log the failure (with the rich [`ValidationResult`](/api/MTConnect.ValidationResult) payload) and keep serving valid data; -- decide centrally how strict to be — set [`InputValidationLevel`](/api/MTConnect.Agents.InputValidationLevel) to `Ignore`, `Warn`, `Remove`, or `Strict` on the agent configuration, and the same handler runs across every level above `Ignore`; +- decide centrally how strict to be — set [`DeviceValidationLevel`](/api/MTConnect.Agents.DeviceValidationLevel) (governing Component / Composition / DataItem shape) or [`InputValidationLevel`](/api/MTConnect.Agents.InputValidationLevel) (governing Observation / Asset input) to `Ignore`, `Warning`, `Remove`, or `Strict` on the agent configuration, and the same handler runs across every level above `Ignore`; - attribute the failure to the source `deviceUuid` so multi-device hosts can route the diagnostic appropriately. ### Wire-up @@ -59,7 +71,11 @@ using MTConnect.Observations; var agent = new MTConnectAgent(new AgentConfiguration { - InputValidationLevel = InputValidationLevel.Warn, + // Two independent axes: device-model shape (Component, Composition, DataItem) vs + // observation / asset input. A typical integrator profile is `InputValidationLevel = Strict` + // alongside `DeviceValidationLevel = Warning`. + DeviceValidationLevel = DeviceValidationLevel.Warning, + InputValidationLevel = InputValidationLevel.Warning, }); agent.InvalidDeviceAdded += (device, result) => @@ -123,12 +139,12 @@ agent.AddDevice(device); ### What happens to the rejected input -The handler runs first; what the agent does next depends on `InputValidationLevel`: +The handler runs first; what the agent does next depends on the applicable knob — [`DeviceValidationLevel`](/api/MTConnect.Agents.DeviceValidationLevel) for `InvalidComponentAdded` / `InvalidCompositionAdded` / `InvalidDataItemAdded` / `InvalidDeviceAdded`, and [`InputValidationLevel`](/api/MTConnect.Agents.InputValidationLevel) for `InvalidObservationAdded` / `InvalidAssetAdded`: - **`Ignore`** — the event does not fire, and the input is kept. Useful only for debugging. -- **`Warn`** — the event fires; the input is kept. -- **`Remove`** — the event fires; the offending node is pruned from its parent (e.g. `device.RemoveDataItem(id)`). -- **`Strict`** — the event fires; the entire Device is rejected (the `AddDevice` call returns `false` and no part of the tree is added). +- **`Warning`** — the event fires; the input is kept. +- **`Remove`** — the event fires; the offending node is pruned from its parent (e.g. `device.RemoveDataItem(id)`), or the input is dropped for the observation / asset case. +- **`Strict`** — the event fires; the entire Device is rejected (the `AddDevice` call returns `false` and no part of the tree is added), or the observation / asset input is rejected. ## Contributor POV @@ -144,16 +160,27 @@ The event family is designed to grow. When a new element class becomes validatab 1. Add the delegate to [`libraries/MTConnect.NET-Common/Delegates.cs`](https://github.com/TrakHound/MTConnect.NET/blob/master/libraries/MTConnect.NET-Common/Delegates.cs). The first parameter is normally the `deviceUuid`; the second is the offending element; the third is the `ValidationResult`. (`InvalidAssetAdded` is the documented exception — assets are not tied to a single device, so the asset itself stands in for the device UUID.) 2. Add the event to `MTConnectAgent` next to the existing five, with an XML doc-comment that mirrors the others (`/// Raised when an Invalid is Added`). -3. At the Add* call site, raise the event when the validation result fails and `_configuration.InputValidationLevel > InputValidationLevel.Ignore`: +3. At the Add* call site, gate the event on the axis appropriate to the noun — device-shape validators (Invalid Component / Composition / DataItem / Device) branch on `_configuration.DeviceValidationLevel`; observation / asset validators branch on `_configuration.InputValidationLevel`: ```csharp + // Device-shape example — new "DeviceModel" noun gates on DeviceValidationLevel. if (!validationResult.IsValid) { - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { InvalidDeviceModelAdded?.Invoke(deviceUuid, deviceModel, validationResult); } - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; + } + + // Observation / asset example — new noun gates on InputValidationLevel instead. + if (!validationResult.IsValid) + { + if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + { + InvalidObservationLikeAdded?.Invoke(deviceUuid, dataItemKey, validationResult); + } + if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return false; } ``` @@ -180,4 +207,5 @@ The event family is designed to grow. When a new element class becomes validatab - [v7 migration: ValidationResult consolidation](/migration/v7-validation-result) — how the three pre-v7 per-domain structs collapsed into the universal `MTConnect.ValidationResult` used by every member of this family. - [TrakHound/MTConnect.NET#169](https://github.com/TrakHound/MTConnect.NET/pull/169) — the canonical worked example of extending the family (adds `InvalidDeviceAdded`, `ValidateDevice`, and the `DeviceNull` / `DeviceUuidMissing` codes). - [`MTConnectAgent`](/api/MTConnect.Agents.MTConnectAgent) — the surface where every entry lives. -- [`InputValidationLevel`](/api/MTConnect.Agents.InputValidationLevel) — the agent-wide knob that gates whether the family fires at all. +- [`DeviceValidationLevel`](/api/MTConnect.Agents.DeviceValidationLevel) — the agent-wide knob that gates the device-shape validators (Invalid Component / Composition / DataItem / Device). +- [`InputValidationLevel`](/api/MTConnect.Agents.InputValidationLevel) — the agent-wide knob that gates the observation and asset validators (Invalid Observation / Asset). diff --git a/docs/concepts/observations.md b/docs/concepts/observations.md index 533ec130f..09bf5102c 100644 --- a/docs/concepts/observations.md +++ b/docs/concepts/observations.md @@ -59,6 +59,18 @@ public const string Unavailable = "UNAVAILABLE"; Every serializer, every codec, every Output transformer (under `MTConnect.NET-Common/Observations/Output/`) treats `Unavailable` as the canonical absent-value sentinel. Source: MTConnect Standard `Part_2.0` Streams §3 ([docs.mtconnect.org](https://docs.mtconnect.org/)). +### Value-class-aware empty-Result coerce + +`MTConnectAgent.AddObservation` coerces a null, empty, or whitespace-only Result to `UNAVAILABLE` only when the DataItem's value class forbids the empty string. The classifier — [`DataItem.GetValueClass`](/api/MTConnect.Devices.DataItem) — returns one of three [`DataItemValueClass`](/api/MTConnect.Devices.DataItems.DataItemValueClass) values, each with a distinct policy: + +| Value class | DataItems | Empty-Result behaviour | +| --- | --- | --- | +| **Numeric** | Every VALUE-representation SAMPLE (Part 2 — "Sample MUST always be reported in float") plus the numeric-typed VALUE-representation Events enumerated in the SysML model — `PART_COUNT`, `LINE_NUMBER`, `BLOCK_COUNT`, `HARDNESS`, `TOOL_OFFSET`, and kindred integer / float `result` attributes. | Always coerced to `UNAVAILABLE`. | +| **Enumeration** | VALUE-representation EVENT DataItems whose Type has a controlled vocabulary (`EXECUTION`, `CONTROLLER_MODE`, `AVAILABILITY`, and every other Event whose value is defined by an `MTConnect.Observations.Events.` enum). | Coerced to `UNAVAILABLE` by default. The [`IAgentConfiguration.AllowEmptyResultForEnumEvents`](/api/MTConnect.Configurations.IAgentConfiguration) flag (default `false`) preserves the empty Result when an integrator needs parity with adapters that emit empty values for these Events. | +| **String** | Free-form VALUE-representation EVENT DataItems (`PROGRAM`, `MESSAGE`, `TOOL_ID`, `ASSET_CHANGED`, and every other non-vocabulary Type); also SAMPLE or EVENT DataItems with a DATA_SET / TABLE / TIME_SERIES representation, whose structured payloads are outside the classifier's remit; also CONDITION observations (coercion is governed by `ConditionLevel`). | Never coerced. The standard's default value type for `Observation::result` is `string`, does not forbid the empty string, and the reference cppagent accepts empty strings for these Events. | + +The classifier is representation-aware — DATA_SET / TABLE / TIME_SERIES inputs legitimately omit the Result key by design (they carry `Entries`, `Cells`, or `Samples` in dedicated value keys), so those observations pass through unchanged. + ## Samples A SAMPLE observation carries a numeric value plus the SAMPLE-specific metadata that the DataItem declares. The four concrete sample subclasses map to the four DataItem `Representation` modes: diff --git a/libraries/MTConnect.NET-Common/Agents/InputValidationLevel.cs b/libraries/MTConnect.NET-Common/Agents/InputValidationLevel.cs index b0786c5e4..fd0debc86 100644 --- a/libraries/MTConnect.NET-Common/Agents/InputValidationLevel.cs +++ b/libraries/MTConnect.NET-Common/Agents/InputValidationLevel.cs @@ -4,8 +4,11 @@ namespace MTConnect.Agents { /// - /// Controls how the Agent reacts when input data fails validation against - /// the device model. + /// Controls how the Agent reacts when an observation or asset input fails per-DataItem validation + /// against the DataItem's Type. Device-shape validation (Component, Composition, DataItem) is + /// governed by so integrators can pick each axis independently + /// — for example, InputValidationLevel = Strict alongside DeviceValidationLevel = Warning + /// to reject bad observations while tolerating minor device-model shape drift. /// public enum InputValidationLevel { From d663c4a8a17e20eeda997cf3517b779cf25b8550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 01:11:02 +0200 Subject: [PATCH 13/14] test(common): pin EVENT non-VALUE + explicit-empty-Result + CONDITION/null classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cycle-2 coverage-FLOOR gaps identified during Ultrareview: 1. Symmetric EVENT + non-VALUE representation coverage. The classifier's non-VALUE short-circuit fires for BOTH SAMPLE and EVENT categories, but only SAMPLE had end-to-end tests. Adds: - `EnumEvent_DataSet_Payload_Preserved_Not_Coerced` (Availability + DATA_SET Entries survive) - `EnumEvent_Table_Payload_Preserved_Not_Coerced` (Availability + TABLE Cells survive) - `GetValueClass_Event_NonValueRepresentation_Is_String` (direct classifier: EVENT + DATA_SET/TABLE/TIME_SERIES all short-circuit to String, including a numeric-typed Event whose Type would otherwise resolve to Numeric). 2. Explicit empty / whitespace Result on non-VALUE representations. The cycle-1 SAMPLE tests exercise the no-Result-key case. Adds the caller- writes-Result-explicitly path where `IsEmptyResult` returns true but the classifier short-circuits to String, so the switch default in `ShouldCoerceEmptyResultToUnavailable` returns false and the payload survives verbatim: - `Sample_TimeSeries_Explicit_EmptyResult_Preserved` (Result="", Samples + SampleCount survive) - `Sample_DataSet_Explicit_WhitespaceResult_Preserved` (Result=" ", Count survives) - `Sample_Table_Explicit_EmptyResult_Preserved` (Result="", Count survives) 3. `GetValueClass_Condition_Is_String` pins the CONDITION short-circuit added at the top of `GetValueClass` alongside the non-VALUE gate. The pre-fix code also delivered String for CONDITION (via the `Category != EVENT` fallthrough) but neither state has a direct test. 4. `GetValueClass_Null_DataItem_Is_String` pins the null-guard so a caller with an unresolved DataItem observes the fail-safe classification rather than a `NullReferenceException`. All 81 `AddObservationEmptyResultCoerce` fixture cases pass on bluefin under `dotnet test -c Debug`. The three explicit-empty-Result SAMPLE tests would fail against pre-fix `b2dcd165` (coerce would fire and stamp Result=UNAVAILABLE + IsUnavailable=true + Count/SampleCount=0); they turn GREEN with the classifier gate at `8909eb41`. The remaining five cases close coverage-FLOOR branches that pre-fix already handled correctly but lacked test surface. Composition: - CONVENTIONS §1.0d-trigies-novodecies (coverage FLOOR) - §10 + §10a (100 % + positive-and-negative) - §1.0d-trigies-octies (TDD-before-fix satisfied by cycle-1 09bed7c0 for the non-VALUE-SAMPLE bug class; this commit extends coverage rather than introducing a new fix) Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- .../AddObservationEmptyResultCoerceTests.cs | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs index 04984dca6..d1195af65 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs @@ -542,6 +542,294 @@ public void GetValueClass_Sample_NonValueRepresentation_Is_String() "SAMPLE + TABLE carries a Cells payload rather than a single Result - the classifier must not report Numeric"); } + /// Direct classifier assertion: EVENT DataItems with non-VALUE representations are classified as String, symmetric with the SAMPLE side. + [Test] + public void GetValueClass_Event_NonValueRepresentation_Is_String() + { + // A representative Enumeration Event (AVAILABILITY) classifies as Enumeration on the + // VALUE path; the same DataItem instance with a non-VALUE representation must + // short-circuit to String at the top of GetValueClass, mirroring the SAMPLE side. + var eventDataSet = new AvailabilityDataItem(DeviceId) + { + Representation = DataItemRepresentation.DATA_SET, + }; + Assert.That(DataItem.GetValueClass(eventDataSet), Is.EqualTo(DataItemValueClass.String), + "EVENT + DATA_SET carries structured Entries rather than a single Result - the classifier must not report Enumeration"); + + var eventTable = new AvailabilityDataItem(DeviceId) + { + Representation = DataItemRepresentation.TABLE, + }; + Assert.That(DataItem.GetValueClass(eventTable), Is.EqualTo(DataItemValueClass.String), + "EVENT + TABLE carries structured Cells rather than a single Result - the classifier must not report Enumeration"); + + var eventTimeSeries = new AvailabilityDataItem(DeviceId) + { + Representation = DataItemRepresentation.TIME_SERIES, + }; + Assert.That(DataItem.GetValueClass(eventTimeSeries), Is.EqualTo(DataItemValueClass.String), + "EVENT + TIME_SERIES (though not spec-permitted for EVENTs) must still short-circuit to String rather than fall through the Enumeration path"); + + // Also cover a numeric-typed Event allow-list entry with a non-VALUE representation: + // the non-VALUE short-circuit must override the numeric-Event allow-list too. + var numericEventDataSet = new DataItem + { + Id = $"{DeviceId}_PART_COUNT_DS", + Category = DataItemCategory.EVENT, + Type = "PART_COUNT", + Representation = DataItemRepresentation.DATA_SET, + }; + Assert.That(DataItem.GetValueClass(numericEventDataSet), Is.EqualTo(DataItemValueClass.String), + "EVENT + DATA_SET on a numeric-typed Event Type must still be String - representation trumps the SysML numeric allow-list"); + } + + /// Direct classifier assertion: CONDITION DataItems classify as String; the top-of-function short-circuit added alongside the non-VALUE gate applies regardless of Type or Representation. + [Test] + public void GetValueClass_Condition_Is_String() + { + // A CONDITION DataItem carries a condition state (Normal / Warning / Fault / Unavailable) + // rather than a Result value; the coerce path is gated by ConditionLevel and MUST NOT + // be driven by the empty-Result classifier. + var conditionValue = new DataItem + { + Id = $"{DeviceId}_SYSTEM", + Category = DataItemCategory.CONDITION, + Type = "SYSTEM", + Representation = DataItemRepresentation.VALUE, + }; + Assert.That(DataItem.GetValueClass(conditionValue), Is.EqualTo(DataItemValueClass.String), + "CONDITION observations report a condition state; the classifier must short-circuit to String even when Representation == VALUE"); + + // Belt-and-braces: a CONDITION with a non-VALUE representation still classifies as String. + var conditionDataSet = new DataItem + { + Id = $"{DeviceId}_SYSTEM_DS", + Category = DataItemCategory.CONDITION, + Type = "SYSTEM", + Representation = DataItemRepresentation.DATA_SET, + }; + Assert.That(DataItem.GetValueClass(conditionDataSet), Is.EqualTo(DataItemValueClass.String), + "CONDITION + DATA_SET classifies as String; the CONDITION short-circuit fires ahead of the representation gate"); + } + + /// Null-guard: returns String for a null DataItem argument rather than throwing. + [Test] + public void GetValueClass_Null_DataItem_Is_String() + { + Assert.That(DataItem.GetValueClass(null), Is.EqualTo(DataItemValueClass.String), + "GetValueClass(null) must return String rather than throw - defensively fails safe for callers with an unresolved DataItem"); + } + + + // -------------------------------------------------------------------- // + // Symmetric EVENT + non-VALUE representation tests // + // Spec: MTConnect Part 2, Value Properties of Event - DATA_SET and // + // TABLE representations carry structured payloads (Entries / Cells) // + // rather than a single Result. The classifier's non-VALUE short- // + // circuit fires for EVENT the same way it fires for SAMPLE; these // + // tests exercise the branch end-to-end via AddObservation so any // + // regression that re-narrows the short-circuit to SAMPLE-only is // + // caught here. // + // -------------------------------------------------------------------- // + + /// An EVENT + DATA_SET observation with real Entries MUST NOT be coerced to UNAVAILABLE despite the absence of a Result key. + [Test] + public void EnumEvent_DataSet_Payload_Preserved_Not_Coerced() + { + // Use AVAILABILITY (a controlled-vocabulary Enum Event) with DATA_SET representation. + // On the VALUE path it classifies as Enumeration and would trigger coerce on an empty + // Result; on the DATA_SET path it MUST classify as String and preserve the payload. + const string dataItemKey = "avail_ds"; + var dataItem = new DataItem + { + Id = $"{DeviceId}_AVAIL_DS", + Name = dataItemKey, + Category = DataItemCategory.EVENT, + Type = "AVAILABILITY", + Representation = DataItemRepresentation.DATA_SET, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new IDataSetEntry[] + { + new DataSetEntry("channel_1", "AVAILABLE"), + new DataSetEntry("channel_2", "UNAVAILABLE"), + }; + var input = new MTConnect.Input.DataSetObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "EVENT + DATA_SET observation must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "EVENT + DATA_SET observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "EVENT + DATA_SET payload MUST NOT be corrupted to UNAVAILABLE - the Result key is absent by design"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "EVENT + DATA_SET Count MUST survive - the coerce path must not overwrite it with 0"); + } + + /// An EVENT + TABLE observation with real Cells MUST NOT be coerced to UNAVAILABLE despite the absence of a Result key. + [Test] + public void EnumEvent_Table_Payload_Preserved_Not_Coerced() + { + const string dataItemKey = "avail_tab"; + var dataItem = new DataItem + { + Id = $"{DeviceId}_AVAIL_TAB", + Name = dataItemKey, + Category = DataItemCategory.EVENT, + Type = "AVAILABILITY", + Representation = DataItemRepresentation.TABLE, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new ITableEntry[] + { + new TableEntry("row_1", new ITableCell[] + { + new TableCell("state", "AVAILABLE"), + new TableCell("nested", "UNAVAILABLE"), + }), + }; + var input = new MTConnect.Input.TableObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "EVENT + TABLE observation must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "EVENT + TABLE observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "EVENT + TABLE payload MUST NOT be corrupted to UNAVAILABLE - the Result key is absent by design"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "EVENT + TABLE Count MUST survive - the coerce path must not overwrite it with 0"); + } + + + // -------------------------------------------------------------------- // + // Explicit empty / whitespace Result on non-VALUE representations // + // A caller who explicitly writes Result="" or Result=" " onto a // + // TIME_SERIES / DATA_SET / TABLE observation MUST see the value // + // preserved verbatim - the coerce path is out of scope for these // + // representations regardless of what the caller wrote. Exercises the // + // switch default: return false; branch of // + // ShouldCoerceEmptyResultToUnavailable via IsEmptyResult == true. // + // -------------------------------------------------------------------- // + + /// A TIME_SERIES SAMPLE with an explicit empty-string Result key MUST preserve the payload: IsEmptyResult fires but the classifier short-circuits to String so the coerce does not. + [Test] + public void Sample_TimeSeries_Explicit_EmptyResult_Preserved() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TIME_SERIES, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedSamples = new[] { 4.0, 5.0, 6.0 }; + var input = new MTConnect.Input.TimeSeriesObservationInput(dataItemKey, expectedSamples, sampleRate: 10.0) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + input.SampleCount = expectedSamples.Length; + + // Explicitly write an empty-string Result key on top of the structured payload - + // simulates a caller (or upstream layer) that stamps ValueKeys.Result unconditionally. + input.AddValue(ValueKeys.Result, string.Empty); + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "TIME_SERIES SAMPLE with explicit empty Result must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "empty Result on a TIME_SERIES SAMPLE must NOT be coerced to UNAVAILABLE - the non-VALUE short-circuit governs"); + Assert.That(current.GetValue(ValueKeys.SampleCount).ToInt(), Is.EqualTo(expectedSamples.Length), + "SampleCount MUST survive - IsUnavailable must remain false so the representation switch does not stamp SampleCount=0"); + Assert.That(TimeSeriesObservation.GetSamples(current.Values).ToArray(), Is.EqualTo(expectedSamples), + "Samples payload MUST survive verbatim"); + } + + /// A DATA_SET SAMPLE with an explicit whitespace-only Result key MUST preserve the payload. + [Test] + public void Sample_DataSet_Explicit_WhitespaceResult_Preserved() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.DATA_SET, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new IDataSetEntry[] + { + new DataSetEntry("a", "1.0"), + new DataSetEntry("b", "2.0"), + new DataSetEntry("c", "3.0"), + }; + var input = new MTConnect.Input.DataSetObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + input.AddValue(ValueKeys.Result, " "); + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "DATA_SET SAMPLE with explicit whitespace Result must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "whitespace Result on a DATA_SET SAMPLE must NOT be coerced to UNAVAILABLE - the non-VALUE short-circuit governs"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "Count MUST survive - IsUnavailable must remain false so the representation switch does not stamp Count=0"); + } + + /// A TABLE SAMPLE with an explicit empty-string Result key MUST preserve the payload. + [Test] + public void Sample_Table_Explicit_EmptyResult_Preserved() + { + const string dataItemKey = SpindleSpeedDataItem.NameId; + var dataItem = new SpindleSpeedDataItem(DeviceId, SpindleSpeedDataItem.SubTypes.ACTUAL) + { + Representation = DataItemRepresentation.TABLE, + }; + using var agent = NewAgent(InputValidationLevel.Warning, dataItem: dataItem); + + var expectedEntries = new ITableEntry[] + { + new TableEntry("row1", new ITableCell[] + { + new TableCell("col1", "1.0"), + }), + }; + var input = new MTConnect.Input.TableObservationInput(dataItemKey, expectedEntries) + { + DeviceKey = DeviceKey, + Timestamp = UnixDateTime.Now, + }; + input.AddValue(ValueKeys.Result, string.Empty); + + var added = agent.AddObservation(input); + + Assert.That(added, Is.True, "TABLE SAMPLE with explicit empty Result must reach the buffer"); + var current = agent.GetCurrentObservations(DeviceKey, dataItemKey).SingleOrDefault(); + Assert.That(current, Is.Not.Null, "observation must be retrievable from the current-observations buffer"); + Assert.That(current!.GetValue(ValueKeys.Result), Is.Not.EqualTo(Observation.Unavailable), + "empty Result on a TABLE SAMPLE must NOT be coerced to UNAVAILABLE - the non-VALUE short-circuit governs"); + Assert.That(current.GetValue(ValueKeys.Count).ToInt(), Is.EqualTo(expectedEntries.Length), + "Count MUST survive - IsUnavailable must remain false so the representation switch does not stamp Count=0"); + } + // -------------------------------------------------------------------- // // Helpers // From 6b214d6350f3cdb1060d7ab7ceaba8eca354d67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 01:14:21 +0200 Subject: [PATCH 14/14] docs: cover deviceValidationLevel + allowEmptyResultForEnumEvents on CLI + agent README Closes the three residual cycle-2 documentation-audit findings against head 0281e9b7: - `docs/concepts/agent-validation-events.md` (contributor test template): the step-4 hypothetical `InvalidDeviceModelAdded` test now configures `DeviceValidationLevel = Strict` (matching step 3, which categorises DeviceModel as a device-shape noun). A contributor copying the template would previously have got a test that exercised the wrong axis. - `docs/cli/agent.md` (top-level config keys table): adds rows for `deviceValidationLevel` (Ignore(0) / Warning(1) / Remove(2) / Strict(3), default Warning, governs the Invalid Component / Composition / DataItem raise-sites) and `allowEmptyResultForEnumEvents` (bool, default false, preserves empty Result for controlled-vocabulary Events). Existing `inputValidationLevel` row narrowed to the observation / asset axis it actually governs post-split. - `agent/MTConnect.NET-Agent/README.md`: fixes the stale `inputValidationLevel` bullet (previously called level 2 "Strict" and omitted "Remove") and adds companion bullets for the two new peer keys. Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- agent/MTConnect.NET-Agent/README.md | 6 +++++- docs/cli/agent.md | 4 +++- docs/concepts/agent-validation-events.md | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/agent/MTConnect.NET-Agent/README.md b/agent/MTConnect.NET-Agent/README.md index aa17f8eda..5278df6a6 100644 --- a/agent/MTConnect.NET-Agent/README.md +++ b/agent/MTConnect.NET-Agent/README.md @@ -252,7 +252,11 @@ defaultVersion: 2.3 * `ignoreObservationCase` - Sets the default for Ignoring the case of Observation values. Applicable values will be converted to uppercase -* `inputValidationLevel` - Sets the default input validation level when new Observations are added to the Agent. 0 = Ignore, 1 = Warning, 2 = Strict +* `inputValidationLevel` - Sets the default per-DataItem validation level applied when new Observations and Assets are added to the Agent. Governs `InvalidObservationAdded` and `InvalidAssetAdded`. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + +* `deviceValidationLevel` - Sets the default device-shape validation level applied while a Device is being added or normalised (Component, Composition, DataItem). Independent from `inputValidationLevel` — a common integrator profile is `inputValidationLevel: Strict` alongside `deviceValidationLevel: Warning`. Governs `InvalidComponentAdded`, `InvalidCompositionAdded`, `InvalidDataItemAdded`, and `InvalidDeviceAdded`. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + +* `allowEmptyResultForEnumEvents` - When `true`, preserves an empty Result verbatim on VALUE-representation EVENT DataItems whose Type has a controlled vocabulary (`EXECUTION`, `CONTROLLER_MODE`, `AVAILABILITY`, etc.). Default `false` coerces the empty Result to `UNAVAILABLE`. Free-form String and Numeric-typed Events are unaffected. * `monitorConfigurationFiles` - Sets whether Configuration files are monitored. If enabled and a configuration file is changed, the Agent will restart diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 615611aa1..53de5f271 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -105,7 +105,9 @@ Backed by `AgentApplicationConfiguration` and the inherited `AgentConfiguration` | `convertUnits` | bool | `true` | Convert observation values to the units declared on the data item when they arrive in a different compatible unit. | | `ignoreObservationCase` | bool | `false` | Case-insensitive comparison of incoming observation values to the data item's value-space (CONDITION severities, enum members, etc.). | | `enableValidation` | bool | `false` | Emit per-observation validation diagnostics on the `agent-validation` logger. | -| `inputValidationLevel` | enum: `Ignore` (`0`), `Warning` (`1`), `Remove` (`2`), `Strict` (`3`) | `Warning` | What the agent does when an observation or asset arrives that fails validation. `Ignore` accepts everything; `Warning` accepts and logs; `Remove` rejects but does not log; `Strict` rejects and logs. | +| `inputValidationLevel` | enum: `Ignore` (`0`), `Warning` (`1`), `Remove` (`2`), `Strict` (`3`) | `Warning` | What the agent does when an observation or asset arrives that fails per-DataItem validation. `Ignore` accepts everything; `Warning` accepts and logs; `Remove` rejects but does not log; `Strict` rejects and logs. Governs `InvalidObservationAdded` and `InvalidAssetAdded`. | +| `deviceValidationLevel` | enum: `Ignore` (`0`), `Warning` (`1`), `Remove` (`2`), `Strict` (`3`) | `Warning` | What the agent does when device-shape validation fails on a Component, Composition, or DataItem while a Device is being added or normalised. Independent from `inputValidationLevel` — a common integrator profile is `inputValidationLevel: Strict` alongside `deviceValidationLevel: Warning` (reject bad observations, tolerate minor device-model drift). Governs `InvalidComponentAdded`, `InvalidCompositionAdded`, `InvalidDataItemAdded`, and `InvalidDeviceAdded`. | +| `allowEmptyResultForEnumEvents` | bool | `false` | When `true`, preserves an empty Result verbatim on VALUE-representation EVENT DataItems whose Type has a controlled vocabulary (`EXECUTION`, `CONTROLLER_MODE`, `AVAILABILITY`, and every other Event whose value is defined by an `MTConnect.Observations.Events.` enum). Default coerces the empty Result to `UNAVAILABLE`. Free-form String and Numeric-typed Events are unaffected — free-form String preserves the empty Result unconditionally; Numeric-typed always coerces. | | `enableAgentDevice` | bool | `true` | Whether the agent emits its own meta-device (`Agent`) on `/probe`, exposing availability and the `mtconnect:ChangeToken` data item. | | `enableMetrics` | bool | `true` | Emit per-minute observation-rate and asset-update-rate metrics on the `agent-metrics` logger. | | `modules` | list of single-key maps | `[]` | Each entry instantiates one agent module. See [Modules](/modules/) for the per-module schema. | diff --git a/docs/concepts/agent-validation-events.md b/docs/concepts/agent-validation-events.md index e1f134a4e..7d702f182 100644 --- a/docs/concepts/agent-validation-events.md +++ b/docs/concepts/agent-validation-events.md @@ -190,7 +190,9 @@ The event family is designed to grow. When a new element class becomes validatab [Test] public void InvalidDeviceModelAdded_fires_and_the_device_is_not_added() { - var agent = new MTConnectAgent(new AgentConfiguration { InputValidationLevel = InputValidationLevel.Strict }); + // DeviceModel is a device-shape noun, so the test gates on + // DeviceValidationLevel — the same axis step 3 wires the raise-site to. + var agent = new MTConnectAgent(new AgentConfiguration { DeviceValidationLevel = DeviceValidationLevel.Strict }); var fired = false; agent.InvalidDeviceModelAdded += (_, _, _) => fired = true;