diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md
index abe6ffff9..8cfe9688a 100644
--- a/.claude/rules/drivers.md
+++ b/.claude/rules/drivers.md
@@ -312,6 +312,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
the adaptive raised count is clamped to the known `bufferSize`, #846). Every parse prohibits DTDs; the streams walk is
depth-bounded (64 — an unbounded recursion is a process-killing `StackOverflowException`). Non-finite floats are
never Good; integer-valued decimals serve integral tags losslessly. Matrix: `docs/drivers/MTConnect.md`.
+- **MTConnect empty value (#824, Java parity):** an empty/whitespace observation on a string-typed (or unauthored) item is
+ Good `""`; on a non-string item (numeric/bool/DateTime/array) it stays `BadNoCommunication` (no value — not
+ `BadTypeMismatch`). Only the literal `UNAVAILABLE` is the no-value sentinel on a string item; it, the comms-loss
+ fan-out and the condition path are unchanged. A ScadaBridge string tag that used to go Bad on a blank value now
+ receives Good `""`.
- **MTConnect refused-connection fast path (#846):** a `/sample`/re-baseline failure whose chain holds
`SocketError.ConnectionRefused`, unbroken for `CommsLossConfirmMs` (default 2000; 0 = first; negative = off), publishes
every subscribed value `BadNoCommunication` once per outage; the first delivery afterwards republishes all of them from
diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md
index 1b84a9bee..d79624288 100644
--- a/docs/drivers/MTConnect.md
+++ b/docs/drivers/MTConnect.md
@@ -271,6 +271,18 @@ item. A `CONDITION` observation's value is taken from its **element name**
condition element normalises onto the same `UNAVAILABLE` sentinel as every
other category so one comparison covers all three.
+**An empty value is not `UNAVAILABLE` (#824, owner decision 2026-11-11 — Java parity).** A
+genuinely empty observation (``, ``, or whitespace-only text,
+which the parser trims to `""`) on a **string-typed** item (`String`/`Reference`, or an unauthored
+item, which coerces as String) is served **Good `""`** — an Agent can legitimately report a blank
+`Program`, `Block` or `Message`. On any **non-string** item (numeric, `Boolean`, `DateTime`, a
+`TIME_SERIES` array) `""` cannot be a value, so it stays **`BadNoCommunication`** with a null value
+— deliberately not `BadTypeMismatch`, which would tell the operator the Agent sent text of the wrong
+type and point them at the tag's authored data type; the Agent sent no value at all. Only the literal
+`UNAVAILABLE` sentinel is the "no value" statement on a string item. Before #824 every empty value
+was `BadNoCommunication`. A `DATA_SET`/`TABLE`-shaped observation is unaffected (its entry-less
+form is still Good `{}`; non-`` children are still `BadNotSupported`).
+
**Integer-valued decimals are served on integral tags (#804).** Agents format integer-valued items as
`42.000000`; an `Int*`/`UInt*` tag accepts any decimal whose value *is* an integer in range (`42.000000` → `42`,
`1.5e3` → `1500`) losslessly. A real fraction stays `BadOutOfRange` — deliberately stricter than the Java
@@ -296,7 +308,7 @@ limitations 4; a probe that does declare one yields a fixed `ArrayDim`).
Parsing: the observation text is split on any whitespace, each token parsed
invariant-culture. The result is **all-or-nothing** — one non-numeric /
non-finite token is `BadTypeMismatch`, a literal `UNAVAILABLE` token or an
-empty body is `BadNoCommunication`, and a count that disagrees with the
+empty body is `BadNoCommunication` (an array is non-string, so #824's Good `""` never applies), and a count that disagrees with the
observation's own `sampleCount` attribute (or a fixed `ArrayDim`) is
`BadDecodingError`. A partial vector is never served as Good. Only a
**Float64** array tag is served; any other declared element type stays
@@ -677,7 +689,7 @@ deliberately different, kept.
| Type inference SAMPLE→Double, numeric EVENT→Int64, else String (`MTConnectTypes`) | covered, **.NET exceeds** (TIME_SERIES → `double[]`, separator-insensitive types) | `MTConnectDataTypeInference` · `MTConnectDataTypeInferenceTests` |
| UNAVAILABLE → `Bad_NoCommunication`, null value, source timestamp kept (`MTConnectValues`) | covered | `MTConnectObservationIndex.BuildSnapshot` · `Unavailable_observation_maps_to_BadNoCommunication_with_null_value`, `Unavailable_observation_keeps_the_agent_source_timestamp` |
| UNAVAILABLE matched case-insensitively | divergence (kept) | ordinal match on the parser-normalised token, so a free-text EVENT reading "Unavailable" stays a value · `Unavailable_sentinel_is_matched_on_the_exact_literal` |
-| Blank text on a String node is a Good empty string | divergence (kept) | empty text is `BadNoCommunication` (an empty element is not an empty-string value) |
+| Blank text on a String node is a Good empty string | **covered** (was a divergence until #824, owner decision 2026-11-11) | `BuildSnapshot` serves Good `""` on a string-typed or unauthored item; a non-string item stays `BadNoCommunication` (Java has no typed node to refuse `""` on); `UNAVAILABLE` unchanged · `Empty_observation_value_on_a_string_item_is_a_Good_empty_string`, `A_parsed_empty_event_on_a_string_item_is_a_Good_empty_string`, controls `Empty_observation_value_on_a_non_string_item_stays_BadNoCommunication`, `Unavailable_on_a_string_item_is_still_BadNoCommunication` |
| No fabricated numbers: narrow-integer range checks, float overflow ≠ Infinity, NaN/Infinity refused (`MTConnectValues.coerce`) | covered for integers; **gap → closed** for floats (`1e400`, `NaN`, `Infinity`, `1e39` on Float32 were served Good) | `Coerce`, `NonFiniteStatus` · `A_non_finite_float_is_never_served_as_good` |
| Integer-valued decimal (`42.000000`) on an integral node | **gap → closed** (was `BadOutOfRange`) — lossless only; a real fraction is still refused, never truncated (Java truncates) | `TryIntegral` · `An_integer_valued_decimal_converts_losslessly`, control `A_fraction_or_an_out_of_range_decimal_stays_out_of_range` |
| Source timestamps preserved; a non-Zulu offset normalised | covered | `ReadTimestampUtc` · `A_timestamp_with_a_non_zulu_offset_is_normalized_to_the_same_utc_instant` |
@@ -932,7 +944,7 @@ for anything beyond values-and-conditions.
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
(canned-XML fixtures, no network) — 714/714 at #804 (2026-11-10). `MTConnectJavaParityTests` holds the #804 gap tests and the ported Java regression cases (see [Java reference parity](#java-reference-parity-804)). Covers
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
- `UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
+ `UNAVAILABLE` → `BadNoCommunication` (an empty string-typed value is Good `""`, #824), CONDITION state-word mapping,
multipart chunk framing, and ring-buffer re-baseline paging (both the
sequence-gap and the `OUT_OF_RANGE` legs). The write path adds
`MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
index 40f6d0ada..5e24da78f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
@@ -76,7 +76,10 @@ internal sealed class MTConnectObservationIndex
private const uint Good = 0x00000000u;
- /// The Agent reported UNAVAILABLE, or supplied no text at all.
+ ///
+ /// The Agent reported UNAVAILABLE, or supplied no text at all for a non-string item
+ /// (an empty string-typed item is a Good "" — lmxopcua#824).
+ ///
private const uint BadNoCommunication = 0x80310000u;
/// The tag is authored but the Agent has not yet reported it.
@@ -443,10 +446,8 @@ internal sealed class MTConnectObservationIndex
var serverTimestamp = Now;
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
- // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
- // the only way to report absence, so an empty element is not an empty string value.
- if (string.IsNullOrWhiteSpace(observation.Value) ||
- string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
+ // UNAVAILABLE is MTConnect's one explicit "no value" sentinel — always BadNoCommunication.
+ if (string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
{
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
}
@@ -456,7 +457,7 @@ internal sealed class MTConnectObservationIndex
// Publishing that as a Good string would be noise an operator would trust. Checked before
// the tag lookup because it is a fact about the wire shape, true whether or not the data
// item is authored — and after the sentinel, so an unavailable data set still reports the
- // truthful no-comms.
+ // truthful no-comms, but BEFORE the empty-text test (#824).
if (observation.IsStructured)
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
@@ -464,6 +465,24 @@ internal sealed class MTConnectObservationIndex
_tags.TryGetValue(observation.DataItemId, out var tag);
+ // A genuinely EMPTY element (no text, or whitespace the parser trimmed away) is NOT the
+ // sentinel. Owner decision on lmxopcua#824 (Java parity): on a string-typed item it is a
+ // Good empty string — an Agent can legitimately report a blank Program/Message/Block.
+ // An unauthored item coerces as String (below), so it is served the same way.
+ // On any other type (numeric, Boolean, DateTime, any array) "" cannot be a value, so it
+ // stays Bad — and stays BadNoCommunication rather than BadTypeMismatch on purpose:
+ // the Agent supplied no value at all (the same meaning as UNAVAILABLE, just spelled
+ // degenerately), whereas BadTypeMismatch tells the operator the Agent sent text of the
+ // wrong type and points them at the tag's authored data type, which is not the fault.
+ // Checked AFTER the structured test above, so element-children with no text stay
+ // BadNotSupported rather than becoming a Good "".
+ if (string.IsNullOrWhiteSpace(observation.Value))
+ {
+ return tag is null or { IsArray: false, DriverDataType: DriverDataType.String or DriverDataType.Reference }
+ ? new DataValueSnapshot(string.Empty, Good, sourceTimestamp, serverTimestamp)
+ : new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
+ }
+
// TIME_SERIES vectors arrive as one whitespace-separated string. Checked AFTER the
// sentinel so an unavailable array tag still reports the truthful no-comms.
if (tag is { IsArray: true })
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
index d8aab2f87..f064f6ff7 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
@@ -320,21 +320,107 @@ public sealed class MTConnectObservationIndexTests
.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc));
}
- /// An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.
+ ///
+ /// lmxopcua#824 (Java parity): an empty element on a string-typed item is a Good empty string, not an
+ /// absence. Whitespace-only text is the same (the parser trims it).
+ ///
[Theory]
- [InlineData("")]
- [InlineData(" ")]
- public void Empty_observation_value_maps_to_BadNoCommunication(string value)
+ [InlineData("", DriverDataType.String)]
+ [InlineData(" ", DriverDataType.String)]
+ [InlineData("", DriverDataType.Reference)]
+ public void Empty_observation_value_on_a_string_item_is_a_Good_empty_string(string value, DriverDataType type)
{
- var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
idx.Apply(Synthetic("x", value));
var snap = idx.Get("x");
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe(string.Empty);
+ }
+
+ /// An unauthored item coerces as String, so its empty value is a Good empty string too (#824).
+ [Fact]
+ public void Empty_observation_value_on_an_unauthored_item_is_a_Good_empty_string()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(Synthetic("x", ""));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe(string.Empty);
+ }
+
+ ///
+ /// #824: on a non-string item "" cannot be a value, so it stays BadNoCommunication (the Agent supplied
+ /// no value) — not BadTypeMismatch, which would blame the tag's authored type.
+ ///
+ [Theory]
+ [InlineData(DriverDataType.Float64)]
+ [InlineData(DriverDataType.Float32)]
+ [InlineData(DriverDataType.Int32)]
+ [InlineData(DriverDataType.Int64)]
+ [InlineData(DriverDataType.Boolean)]
+ [InlineData(DriverDataType.DateTime)]
+ public void Empty_observation_value_on_a_non_string_item_stays_BadNoCommunication(DriverDataType type)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
+ idx.Apply(Synthetic("x", " "));
+
+ var snap = idx.Get("x");
+
snap.StatusCode.ShouldBe(BadNoCommunication);
snap.Value.ShouldBeNull();
}
+ ///
+ /// #824 control: the UNAVAILABLE sentinel is unaffected — still BadNoCommunication on a String item, where an
+ /// empty value is now Good.
+ ///
+ [Fact]
+ public void Unavailable_on_a_string_item_is_still_BadNoCommunication()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", "UNAVAILABLE"));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(BadNoCommunication);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// #824 end to end through the parser: an empty, a self-closing and a blank element are all Good "".
+ [Theory]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ [InlineData(""" """)]
+ public void A_parsed_empty_event_on_a_string_item_is_a_Good_empty_string(string element)
+ {
+ var idx = new MTConnectObservationIndex([Tag("p", DriverDataType.String)]);
+ idx.Apply(MTConnectStreamsParser.Parse($"""
+
+
+ {element}
+
+ """));
+
+ var snap = idx.Get("p");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe(string.Empty);
+ }
+
+ /// A structured observation with no text stays BadNotSupported — it never becomes a Good "" (#824).
+ [Fact]
+ public void A_structured_observation_with_no_text_stays_BadNotSupported()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", "", isStructured: true));
+
+ idx.Get("x").StatusCode.ShouldBe(BadNotSupported);
+ }
+
// -------------------------------------------------------------------------- unknown lookups
/// A dataItemId that is neither authored nor ever observed is unknown to this driver.