Skip to content
Open
4 changes: 2 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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.
Expand Down Expand Up @@ -323,7 +323,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
Expand Down
32 changes: 32 additions & 0 deletions libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Controls how the Agent reacts when Devices data fails validation against
/// the MTConnect Standard.
/// </summary>
public enum DeviceValidationLevel
{
/// <summary>
/// Accept invalid device information; perform no validation action.
/// </summary>
Ignore,

/// <summary>
/// Accept invalid device information but emit a validation warning.
/// </summary>
Warning,

/// <summary>
/// Drop the invalid device information and continue processing the remainder.
/// </summary>
Remove,

/// <summary>
/// Reject the entire device information on the first validation failure.
/// </summary>
Strict
}
}
64 changes: 55 additions & 9 deletions libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand All @@ -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;
}
}
}
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -2152,6 +2152,17 @@ 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 before the
// observation reaches the buffer. The MTConnect Part 1 Observation Information
// Model mandates "UNAVAILABLE" as the sole valid representation of a missing
// value; an empty string is never a Valid Data Value. The coerce runs above
// validation so Strict no longer silently drops empty-Result observations and
// every other validation level publishes the spec-compliant sentinel.
if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input) && _configuration.InputValidationLevel == InputValidationLevel.Strict)
{
CoerceEmptyResultToUnavailable(input);
}

// Add required properties
switch (dataItem.Representation)
{
Expand Down Expand Up @@ -2277,6 +2288,41 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput,
}


/// <summary>
/// Returns true when the observation's Result value is null, the empty string, or whitespace-only.
/// </summary>
/// <remarks>
/// An empty Result is, by definition, not a Valid Data Value under any typed DataItem schema; the
/// MTConnect Part 1 Observation Information Model mandates "UNAVAILABLE" as the sole valid
/// representation of a missing value. This predicate identifies the inputs the SDK coerces.
/// </remarks>
private static bool IsEmptyResult(IObservationInput input)
{
var result = input.GetValue(ValueKeys.Result);
if (result == null) return true;
return string.IsNullOrWhiteSpace(result);
}

/// <summary>
/// Rewrites the observation's Result value to <see cref="Observation.Unavailable"/> and flags the input as unavailable.
/// </summary>
/// <remarks>
/// Removes any prior Result entry (so the Values collection does not carry a duplicate ValueKey),
/// adds the UNAVAILABLE sentinel, and sets <see cref="IObservationInput.IsUnavailable"/> so downstream
/// consumers that branch on the flag observe the coerced state. Spec authority: MTConnect Part 1
/// Observation Information Model - Representation - Observation Values.
/// </remarks>
private static void CoerceEmptyResultToUnavailable(IObservationInput input)
{
var preserved = (input.Values ?? Enumerable.Empty<ObservationValue>())
.Where(v => v.Key != ValueKeys.Result)
.ToList();
input.Values = preserved;
input.AddValue(ValueKeys.Result, Observation.Unavailable);
input.IsUnavailable = true;
}


/// <summary>
/// Add new Observations for DataItems to the Agent
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ public string DefaultVersionValue
[JsonPropertyName("enableValidation")]
public bool EnableValidation { get; set; }

/// <summary>
/// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict
/// </summary>
[JsonPropertyName("deviceValidationLevel")]
public DeviceValidationLevel DeviceValidationLevel { get; set; }

/// <summary>
/// Gets or Sets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict
/// </summary>
Expand Down Expand Up @@ -155,6 +161,7 @@ public AgentConfiguration()
ObservationBufferSize = 131072;
AssetBufferSize = 1024;
DefaultVersion = MTConnectVersions.Max;
DeviceValidationLevel = DeviceValidationLevel.Warning;
InputValidationLevel = InputValidationLevel.Warning;
ConvertUnits = true;
IgnoreObservationCase = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ public interface IAgentConfiguration
/// </summary>
bool EnableValidation { get; }

/// <summary>
/// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict
/// </summary>
DeviceValidationLevel DeviceValidationLevel { get; }

/// <summary>
/// Gets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,13 @@ private static IEnumerable<ShdrDataItem> 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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// 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
{
/// <summary>
/// Regression pin for the empty-Result wire-level spec violation in
/// <see cref="MTConnectAgent.AddObservation(string, MTConnect.Input.IObservationInput, bool?, bool?, bool?, bool)"/>:
/// when the inbound observation carries a null, empty, or whitespace-only
/// Result value, the agent currently publishes that value verbatim on the
/// wire. The MTConnect Part 1 Observation Information Model mandates
/// "UNAVAILABLE" as the sole valid representation of a missing value, so
/// the SDK must coerce null / empty / whitespace to
/// <see cref="Observation.Unavailable"/> before the observation reaches
/// the buffer.
///
/// Spec: MTConnect Standard, Part 1 - Devices Information Model,
/// Observation Information Model - Representation - Observation Values
/// ("If an Agent cannot determine a Valid Data Value for a DataItem, the
/// value returned for the Result for the Data Entity MUST be reported as
/// UNAVAILABLE.").
///
/// This fixture pins the coerce on the canonical
/// <c>AddObservation(string, IObservationInput, ...)</c> overload that
/// every other AddObservation overload routes through. The convenience
/// overload <c>AddObservation(string deviceKey, string dataItemKey, object value, DateTime timestamp)</c>
/// at MTConnectAgent.cs:2007 is used as the exercise vector because it
/// builds a fresh ObservationInput and routes through the canonical path
/// at MTConnectAgent.cs:2123.
/// </summary>
[TestFixture]
[Category("AddObservationEmptyResultCoerce")]
public class AddObservationEmptyResultCoerceTests
{
private const string DeviceKey = "U-COERCE";
private const string DataItemKey = "availability";

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" },
};

/// <summary>Pins the positive contract: under every non-Strict input-validation level, an empty-string Result is coerced to <see cref="Observation.Unavailable"/> on the wire.</summary>
/// <param name="level">The input-validation level the agent operates under.</param>
[Test]
[TestCaseSource(nameof(_nonStrictLevels))]
public void AddObservation_EmptyStringResult_Coerced_To_Unavailable_Under_NonStrict_Levels(InputValidationLevel level)
{
using var agent = NewAgentWithAvailabilityDataItem(level);

var added = agent.AddObservation(DeviceKey, DataItemKey, (object)string.Empty, DateTime.UtcNow);

Assert.That(added, Is.True, "empty-Result observation must reach the buffer post-coerce");
Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable),
"Part 1 mandates UNAVAILABLE for any Result that is null, empty, or whitespace");
}

/// <summary>Pins the positive contract across the null / empty / whitespace family: every such Result is coerced to <see cref="Observation.Unavailable"/>.</summary>
/// <param name="badValue">The non-Valid-Data-Value Result the caller forwards in.</param>
[Test]
[TestCaseSource(nameof(_nullEmptyWhitespaceValues))]
public void AddObservation_NullEmptyOrWhitespaceResult_Coerced_To_Unavailable(object? badValue)
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning);

var added = agent.AddObservation(DeviceKey, DataItemKey, badValue!, DateTime.UtcNow);

Assert.That(added, Is.True);
Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable));
}

/// <summary>Pins the secondary defect's positive contract: under <see cref="InputValidationLevel.Strict"/>, an empty-Result observation is coerced and lands in the buffer rather than being silently dropped.</summary>
[Test]
public void AddObservation_EmptyResult_Under_Strict_Coerced_And_Lands()
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Strict);

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), Is.EqualTo(Observation.Unavailable));
}

/// <summary>Pins the negative contract: a concrete non-empty Result is preserved verbatim — the coerce only fires on the null / empty / whitespace family.</summary>
[Test]
public void AddObservation_ConcreteResult_Is_Preserved_Verbatim()
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning);

var added = agent.AddObservation(DeviceKey, DataItemKey, (object)"AVAILABLE", DateTime.UtcNow);

Assert.That(added, Is.True);
Assert.That(CurrentResult(agent), Is.EqualTo("AVAILABLE"),
"the coerce must not substitute a sentinel for a Valid Data Value");
}

private static MTConnectAgentBroker NewAgentWithAvailabilityDataItem(InputValidationLevel level)
{
var config = new AgentConfiguration { InputValidationLevel = level };
var agent = new MTConnectAgentBroker(config);
agent.Start();

var device = new Device
{
Id = "d-coerce",
Name = "d-coerce",
Uuid = DeviceKey,
};
device.AddDataItem(new AvailabilityDataItem(device.Id));

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)
{
var current = agent.GetCurrentObservations(DeviceKey, DataItemKey).SingleOrDefault();
return current?.GetValue(ValueKeys.Result);
}
}
}
Loading
Loading