From f8f8eb9fb84aa03da73794e3f94abb2de321a3e1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 19:15:32 -0400 Subject: [PATCH 1/2] feat(mtconnect): serve DATA_SET/TABLE as compact JSON strings; park EVENT enumerations (#650) Parser captures Entry/Cell content; the observation index merges /sample deltas into the /current baseline (resetTriggered replaces, removed deletes, UNAVAILABLE drops). Fixture gains a TABLE item and periodic DATA_SET/TABLE churn. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/MTConnect.md | 51 +++-- .../MTConnectDriver.cs | 6 +- .../MTConnectDtos.cs | 31 +++- .../MTConnectObservationIndex.cs | 155 +++++++++++++++- .../MTConnectStreamsParser.cs | 83 ++++++++- .../Docker/Devices.xml | 7 +- .../Docker/README.md | 3 +- .../Docker/adapter.py | 20 +- .../MTConnectAgentIntegrationTests.cs | 34 ++++ .../MTConnectDataSetTests.cs | 175 ++++++++++++++++++ .../MTConnectObservationIndexTests.cs | 11 +- 11 files changed, 537 insertions(+), 39 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 824d1808..8067880d 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -103,8 +103,8 @@ request/response handshake — is a different mechanism and remains deferred; se | `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) | | One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) | | Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) | -| EVENT controlled vocabularies as OPC UA enumerations | **Not supported** (stay String); not scheduled | See [Deferred](#deferred-not-in-this-build) | -| `DATA_SET` / `TABLE` representations | **Not supported** - read as `BadNotSupported` | [Quality mapping](#quality-mapping) | +| EVENT controlled vocabularies as OPC UA enumerations | **Parked / won't fix** (stay String) - see rationale | [EVENT enumerations](#event-enumerations-650-parked--wont-fix) | +| `DATA_SET` / `TABLE` representations (#650) | **Supported** as a String carrying compact JSON | [DATA_SET / TABLE](#data_set--table-650) | | MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) | **`IReadable.ReadAsync` is never called by the running server.** The @@ -245,7 +245,7 @@ Other status codes an observation can surface: | `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all | | `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent | | `BadDecodingError` (`0x80070000`) | A `TIME_SERIES` vector whose element count disagrees with the observation's own `sampleCount` (or the tag's fixed `ArrayDim`) — see [TIME_SERIES](#time_series-arrays-627) | -| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a structured `DATA_SET`/`TABLE` observation (decided: stays unsupported, #627), or an array tag declared as anything other than Float64 | +| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `DATA_SET`/`TABLE` observation whose children are not `` elements (a well-formed one is served as JSON since #650), or an array tag declared as anything other than Float64 | | `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet | | `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed | @@ -272,8 +272,29 @@ in the Tag modal's array section). but the OPC UA node has no description/property slot through `DriverAttributeInfo`, and a sibling property node would need a new address-space mechanism. Decision: document it — the rate is a property of the Agent's DataItem (`sampleRate` in -`/probe`), read it there. **`DATA_SET` / `TABLE` stay `BadNotSupported`** (decision, #627): they are -key/value maps with no scalar or array OPC UA shape that preserves the keys. +`/probe`), read it there. + +### DATA_SET / TABLE (#650) + +`DATA_SET` and `TABLE` observations carry their content in `` children (a `TABLE` entry holds +`` children). They are served as a **String carrying compact, deterministic JSON** (OPC UA type +String, never an array or structure - no new node shape, history/ACL/alarm paths unchanged; discovery and +type inference already type these items String): + +- `DATA_SET` -> `{"offsetX":1.25,"toolNumber":7}`; `TABLE` -> `{"G54":{"X":1.0,"Y":2},"G55":{"X":3}}`. +- Keys are ordinal-sorted at every level, no whitespace - the same content always serialises to the same + bytes, so value-dedup works. Values that are *strict JSON numbers* are emitted as numbers with their original + token (lossless: `1.0` stays `1.0`); anything else (`007`, `+5`, `on`) is a string. +- **Merge semantics follow MTConnect 2.x.** A `/current` document states the *whole* set and **replaces** it; + a `/sample` chunk carries *deltas* that **merge** by key (a `TABLE` row is replaced as a unit by a later entry + for the same row key). An observation with `resetTriggered` replaces the accumulated set, and a + `resetTriggered` observation with no entries is the empty set `{}`. Several observations of one item in one + document fold in document order. The accumulated set is dropped on an Agent restart (`instanceId` change). +- `removed="true"` deletes the key (or row): it is **absent** from the JSON, never `null`. +- `UNAVAILABLE` is `BadNoCommunication` as for any observation, and also drops the accumulated set, so a + later delta cannot resurrect pre-outage entries. +- An observation whose children are not `` elements stays `BadNotSupported`. Writes to these items stay + refused (`WriteAsync` excludes them). ### Subscribe — the `/sample` pump @@ -483,9 +504,13 @@ and records a state only when an alarm handler was attached to receive it. Conse unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it. - An alarm-only driver (no value tags) still runs the `/sample` pump. -**EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority).** EVENT values (`Execution`, -`ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type -vocabulary table that vendors extend freely. +### EVENT enumerations (#650): parked / won't fix + +EVENT values (`Execution`, `ControllerMode`, …) stay Strings carrying the Agent's own word. Modelling them as +OPC UA enumerations was considered and **declined**: the vocabulary is vendor-extensible, so it needs a per-type +table plus an unknown-value fallback, a per-type enum node shape through discovery and raw-tag type inference, +and it would break every consumer that reads these items as String (ScadaBridge) for little gain. Reopen only +with an opt-in, tag-level `enumValues` design. ## Known limitations @@ -563,9 +588,8 @@ for anything beyond values-and-conditions. pin the parsing rule itself. 5. **A CONDITION alarm shows one condition per DataItem** — the most severe active `nativeCode`, not one Part 9 condition per code (see [CONDITION alarms](#condition-alarms-626)). -6. **Structured `DATA_SET`/`TABLE` observations surface as - `BadNotSupported`**, not an approximation — see the Quality mapping table. - `TIME_SERIES` is served as `double[]` (#627, see +6. **`DATA_SET`/`TABLE` observations are JSON Strings** (#650, see + [DATA_SET / TABLE](#data_set--table-650)); `TIME_SERIES` is served as `double[]` (#627, see [TIME_SERIES arrays](#time_series-arrays-627)); the sample rate is not surfaced on the node. @@ -585,9 +609,8 @@ for anything beyond values-and-conditions. - **P1.5 fast-follow** (design [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9 alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT - controlled-vocabulary values → OPC UA enumerations is **not built** (EVENTs stay Strings), and - structured `DATA_SET`/`TABLE` observations stay `BadNotSupported`; both are tracked as a low-priority - follow-up, not part of P1.5. + controlled-vocabulary values → OPC UA enumerations is **parked / won't fix** (EVENTs stay Strings, #650); + `DATA_SET`/`TABLE` are served as JSON Strings (#650). - **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"` switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses auto-discovery (no device model without an Agent in front, so diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index e59aeb5e..91c46b04 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -747,7 +747,7 @@ public sealed partial class MTConnectDriver // Built from the SAME authored tags as the shared index, so the authored-vs-unknown // distinction and every coercion behave identically — it is a snapshot, not a variant. var snapshot = new MTConnectObservationIndex(binding.IndexTags); - snapshot.Apply(current); + snapshot.Apply(current, baseline: true); var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < results.Length; i++) @@ -2337,7 +2337,7 @@ public sealed partial class MTConnectDriver private void ApplyAndFanOut(MTConnectStreamsResult document, bool baseline = false) { var index = ObservationIndex; - index.Apply(document); + index.Apply(document, baseline); // #626: CONDITION observations also feed the alarm path, which keeps its own multi-condition state // (a /sample Normal for one nativeCode is not a return to normal while another code is active). @@ -2801,7 +2801,7 @@ public sealed partial class MTConnectDriver } var index = new MTConnectObservationIndex(binding.IndexTags); - index.Apply(current); + index.Apply(current, baseline: true); Volatile.Write(ref _index, index); // #626: the CONDITION tracker is per-session state like the index. Nothing is RAISED here — the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index 14ba87ec..3d035169 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -167,6 +167,17 @@ public sealed record MTConnectStreamsResult( /// observation. Carried for the parser contract; not yet surfaced on the served node (see /// docs/drivers/MTConnect.md). null when absent. /// +/// +/// The parsed <Entry key="…"> children of a DATA_SET / TABLE observation (#650), in +/// document order, or null when the observation had none. A TABLE entry carries +/// . The observation index folds these into a JSON String — see +/// . stays true beside them: it is the +/// wire-shape fact, this is the content. +/// +/// +/// true when the observation carried a non-empty resetTriggered attribute — the Agent's statement that +/// the set was reset, so this observation replaces the accumulated set rather than merging into it. +/// /// /// The CONDITION-only detail (#626) — set exactly when the observation came from a /// <Condition> container, null for every Sample/Event. Its presence is what marks an @@ -179,7 +190,25 @@ public sealed record MTConnectObservation( bool IsStructured = false, int? SampleCount = null, double? SampleRate = null, - MTConnectConditionDetail? Condition = null); + MTConnectConditionDetail? Condition = null, + IReadOnlyList? Entries = null, + bool ResetTriggered = false); + +/// One <Cell key="…"> of a TABLE row (#650). +public sealed record MTConnectDataSetCell(string Key, string Value); + +/// +/// One <Entry key="…"> of a DATA_SET / TABLE observation (#650). +/// +/// The entry's key attribute, verbatim. +/// A DATA_SET entry's trimmed text; null for a TABLE row or a removed entry. +/// true when the entry carried removed="true": the key is deleted from the set. +/// A TABLE row's cells in document order; null for a DATA_SET entry. +public sealed record MTConnectDataSetEntry( + string Key, + string? Value, + bool Removed, + IReadOnlyList? Cells = null); /// /// What an MTConnect CONDITION observation says beyond its state word (#626). Every field is the 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 7e95659d..f3c0bfce 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; @@ -50,7 +53,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// Shapes this build does not represent are coded BadNotSupported with a null /// value rather than approximated: a non-Float64 array tag and a DATA_SET / TABLE -/// observation (a Float64 TIME_SERIES vector is served as double[], #627). The latter carries its +/// observation whose children are not <Entry> elements (a Float64 TIME_SERIES vector is served as double[], #627). The latter carries its /// real content in <Entry key=…> child elements, which reach a text-valued /// observation concatenated with the keys discarded — a two-entry set reads as the single /// token "12" — and because the type inference correctly demotes those @@ -111,6 +114,18 @@ internal sealed class MTConnectObservationIndex }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _snapshots = new(StringComparer.Ordinal); + + // #650: the accumulated DATA_SET / TABLE content per dataItemId. A /sample chunk carries only the entries + // that changed, so the served JSON is the merge of the baseline and every delta since. Row value is the + // entry's text (DATA_SET) or its cell map (TABLE). Only the single pump (or a one-shot snapshot) calls + // Apply, but the lock keeps a concurrent one-shot read-through from corrupting a shared index. + private readonly Dictionary> _sets = new(StringComparer.Ordinal); + private readonly object _setsLock = new(); + + private sealed record SetEntry(string? Value, SortedDictionary? Cells); + + private static readonly Regex JsonNumber = new( + @"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly FrozenDictionary _tags; private readonly TimeProvider _timeProvider; @@ -159,7 +174,11 @@ internal sealed class MTConnectObservationIndex /// The parsed streams document. An observation-free result is a legal /// keep-alive and leaves the index untouched. /// is null. - public void Apply(MTConnectStreamsResult result) + /// + /// true for a /current document, which states the whole DATA_SET / TABLE and so replaces + /// the accumulated set; false (default) for a /sample chunk, whose entries are deltas merged into it. + /// + public void Apply(MTConnectStreamsResult result, bool baseline = false) { ArgumentNullException.ThrowIfNull(result); @@ -170,9 +189,12 @@ internal sealed class MTConnectObservationIndex // — writing every observation and merging snapshots in place — would need a read-modify-write // per key and could expose a half-merged state to a concurrent reader. var resolved = new Dictionary(StringComparer.Ordinal); - foreach (var observation in result.Observations) + var seenSets = new HashSet(StringComparer.Ordinal); + foreach (var raw in result.Observations) { - if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue; + if (raw is null || string.IsNullOrWhiteSpace(raw.DataItemId)) continue; + + var observation = FoldDataSet(raw, baseline, seenSets); resolved[observation.DataItemId] = resolved.TryGetValue(observation.DataItemId, out var incumbent) @@ -217,7 +239,130 @@ internal sealed class MTConnectObservationIndex /// instanceId changes, every value the index holds describes a device model that no /// longer exists, and serving it would be worse than serving nothing. /// - public void Clear() => _snapshots.Clear(); + public void Clear() + { + _snapshots.Clear(); + lock (_setsLock) _sets.Clear(); + } + + /// + /// DATA_SET / TABLE (#650). Folds an observation's <Entry> children into the accumulated set for + /// its dataItemId and returns a plain String observation carrying the merged JSON; any other observation is + /// returned untouched, except that an UNAVAILABLE one drops the accumulated set (a later delta must not + /// resurrect pre-outage entries). + /// + /// Semantics (MTConnect 2.x): a /current document () or an observation + /// with resetTriggered replaces the set; otherwise entries merge by key. removed="true" + /// deletes the key (the key is absent from the JSON, never null). A TABLE row is replaced as a unit + /// by a later entry for the same row key; a removed row key disappears. Several observations of one item in + /// a single document fold in document order — only the first replaces on a baseline. + /// + /// + private MTConnectObservation FoldDataSet(MTConnectObservation observation, bool baseline, HashSet seenSets) + { + var id = observation.DataItemId; + + if (observation.Entries is not { Count: > 0 } entries) + { + if (observation.ResetTriggered && !observation.IsStructured) + { + // resetTriggered with no entries: an empty set, which is a legitimate value rather than an absence. + lock (_setsLock) _sets[id] = new SortedDictionary(StringComparer.Ordinal); + seenSets.Add(id); + return observation with { Value = "{}", ResetTriggered = false }; + } + + if (string.IsNullOrWhiteSpace(observation.Value) || + string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal)) + { + lock (_setsLock) _sets.Remove(id); + } + + return observation; + } + + string json; + lock (_setsLock) + { + var first = seenSets.Add(id); + if (!_sets.TryGetValue(id, out var set) || observation.ResetTriggered || (baseline && first)) + { + set = new SortedDictionary(StringComparer.Ordinal); + _sets[id] = set; + } + + foreach (var entry in entries) + { + if (entry.Removed) + { + set.Remove(entry.Key); + } + else if (entry.Cells is not null) + { + var cells = new SortedDictionary(StringComparer.Ordinal); + foreach (var cell in entry.Cells) cells[cell.Key] = cell.Value; + set[entry.Key] = new SetEntry(null, cells); + } + else + { + set[entry.Key] = new SetEntry(entry.Value ?? string.Empty, null); + } + } + + json = RenderSet(set); + } + + return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false }; + } + + /// + /// Compact, deterministic JSON: keys ordinal-sorted, no whitespace. Values that are strict JSON numbers are + /// emitted as numbers with their original token (lossless — 007 and +5 stay strings); everything + /// else is a string. + /// + private static string RenderSet(SortedDictionary set) + { + using var stream = new MemoryStream(); + using (var w = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + w.WriteStartObject(); + foreach (var (key, entry) in set) + { + w.WritePropertyName(key); + if (entry.Cells is null) + { + WriteScalar(w, entry.Value ?? string.Empty); + continue; + } + + w.WriteStartObject(); + foreach (var (cellKey, cellValue) in entry.Cells) + { + w.WritePropertyName(cellKey); + WriteScalar(w, cellValue); + } + + w.WriteEndObject(); + } + + w.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteScalar(Utf8JsonWriter w, string text) + { + if (JsonNumber.IsMatch(text) && + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && double.IsFinite(d)) + { + w.WriteRawValue(text); + } + else + { + w.WriteStringValue(text); + } + } private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs index ba631790..01a8a6bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs @@ -363,11 +363,13 @@ internal static class MTConnectStreamsParser /// null when there is none. Text found anywhere below an observation belongs to it — /// that is what XElement.Value meant. /// + /// The enclosing observation's DATA_SET / TABLE entry list (#650), or null outside one. private static void ReadElement( XmlReader reader, ContainerKind parent, List observations, - ValueSink? textSink) + ValueSink? textSink, + List? entryCollector = null) { var isObservation = parent != ContainerKind.None; var isCondition = parent == ContainerKind.Condition; @@ -379,6 +381,8 @@ internal static class MTConnectStreamsParser string? conditionState = null; int? sampleCount = null; double? sampleRate = null; + var resetTriggered = false; + List? entries = null; (string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default; if (isObservation) @@ -405,6 +409,8 @@ internal static class MTConnectStreamsParser // against the declared sampleCount only when one parses). if (!isCondition) { + resetTriggered = !string.IsNullOrWhiteSpace(MTConnectXml.OptionalAttribute(reader, "resetTriggered")); + entries = []; sampleCount = int.TryParse( MTConnectXml.OptionalAttribute(reader, "sampleCount"), NumberStyles.Integer, @@ -431,7 +437,7 @@ internal static class MTConnectStreamsParser // enclosing observation's sink: a condition is never nested inside a text-valued observation. var sink = isObservation ? new ValueSink() : textSink; - var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink); + var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink, entries ?? entryCollector); if (!isObservation) { @@ -475,7 +481,9 @@ internal static class MTConnectStreamsParser IsStructured: !isCondition && hasElements, SampleCount: sampleCount, SampleRate: sampleRate, - Condition: condition); + Condition: condition, + Entries: entries is { Count: > 0 } ? entries : null, + ResetTriggered: resetTriggered); } /// @@ -486,7 +494,8 @@ internal static class MTConnectStreamsParser XmlReader reader, ContainerKind kind, List observations, - ValueSink? sink) + ValueSink? sink, + List? entries) { var hasElements = false; @@ -496,7 +505,17 @@ internal static class MTConnectStreamsParser { case XmlNodeType.Element: hasElements = true; - ReadElement(reader, kind, observations, sink); + + // #650: an directly under a non-condition observation is DATA_SET / TABLE + // content, captured structurally rather than as concatenated text. + if (entries is not null && kind == ContainerKind.None && MTConnectXml.IsNamed(reader, "Entry")) + { + ReadEntry(reader, entries, sink); + } + else + { + ReadElement(reader, kind, observations, sink, entries); + } break; @@ -520,6 +539,60 @@ internal static class MTConnectStreamsParser return hasElements; } + /// + /// Consumes one <Entry> (and its <Cell> children, for a TABLE row), honouring the + /// cursor contract: returns on the entry's end tag, or on the entry itself when empty. + /// An entry with no key is dropped — it cannot be addressed. + /// + private static void ReadEntry(XmlReader reader, List entries, ValueSink? sink) + { + var key = MTConnectXml.OptionalAttribute(reader, "key"); + var removed = string.Equals(MTConnectXml.OptionalAttribute(reader, "removed")?.Trim(), "true", StringComparison.OrdinalIgnoreCase); + var empty = reader.IsEmptyElement; + var text = new System.Text.StringBuilder(); + List? cells = null; + + while (!empty && reader.Read()) + { + if (reader.NodeType == XmlNodeType.EndElement) break; + + if (reader.NodeType == XmlNodeType.Element && MTConnectXml.IsNamed(reader, "Cell")) + { + cells ??= []; + var cellKey = MTConnectXml.OptionalAttribute(reader, "key"); + var cellText = new System.Text.StringBuilder(); + if (!reader.IsEmptyElement) + { + while (reader.Read() && reader.NodeType != XmlNodeType.EndElement) + { + if (reader.NodeType is XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace) + { cellText.Append(reader.Value); sink?.Append(reader.Value); } + else if (reader.NodeType == XmlNodeType.Element) + reader.Skip(); + } + } + + if (!string.IsNullOrEmpty(cellKey)) cells.Add(new MTConnectDataSetCell(cellKey, cellText.ToString().Trim())); + } + else if (reader.NodeType is XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace) + { + { text.Append(reader.Value); sink?.Append(reader.Value); } + } + else if (reader.NodeType == XmlNodeType.Element) + { + reader.Skip(); + } + } + + if (string.IsNullOrEmpty(key)) return; + + entries.Add(removed + ? new MTConnectDataSetEntry(key, null, true) + : cells is not null + ? new MTConnectDataSetEntry(key, null, false, cells) + : new MTConnectDataSetEntry(key, text.ToString().Trim(), false)); + } + /// /// A condition's state is its element name. Unavailable is normalized to the /// spelling so downstream quality mapping has exactly one diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml index 8b7238b2..62195905 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml @@ -27,7 +27,8 @@ EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode EVENT, free text -> String fixture_program, fixture_block (never fed), fixture_writeback (never fed; write target) - EVENT, DATA_SET representation -> String fixture_varset + EVENT, DATA_SET representation -> String fixture_varset (JSON object, #650) + EVENT, TABLE representation -> String fixture_worktable (JSON object of objects, #650) CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it @@ -113,8 +114,10 @@ legal value, and never fed by the adapter, so nothing races the assertion. --> - + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md index d9fa1e8c..4bcb7923 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md @@ -83,7 +83,8 @@ observation correlation key is visible. | `fixture_linenumber` | `Pline` | EVENT | LINE_NUMBER | | Int64 | | `fixture_program` | `Pprogram` | EVENT | PROGRAM | | String | | `fixture_block` | `Pblock` | EVENT | BLOCK | | String — **never fed ⇒ UNAVAILABLE** | -| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String (structured ⇒ BadNotSupported) | +| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String — JSON object (#650); `toolNumber`/`offsetX`/`offsetZ` stable, `cycle` moves every ~5 s | +| `fixture_worktable` | `Ptab` | EVENT | WORK_OFFSET | TABLE | String — JSON object of objects (#650); `G54`/`G55` stable, `G56` row moves every ~5 s | | `fixture_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm | | `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String | diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py index 06019cc3..45bcf84d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py @@ -89,9 +89,12 @@ class Feed: [ ("Favail", "AVAILABLE"), ("Pprogram", "OTOPCUA-FIXTURE.NC"), - # DATA_SET representation: the Agent renders these as children, - # which is precisely the wire shape the driver demotes to String / BadNotSupported. - ("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"), + # DATA_SET / TABLE representations: the Agent renders these as + # children, which the driver serves as a JSON String (#650). The three Pvars keys + # are STABLE for the life of the fixture; the periodic feed only touches `cycle` + # (Pvars) and the G56 row (Ptab). + ("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5 cycle=0"), + ("Ptab", "G54={X=1.0 Y=2.0} G55={X=3.0} G56={X=0 Z=0.5}"), ] ) self.send_conditions() @@ -142,6 +145,17 @@ class Feed: [("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])] ) + # DATA_SET / TABLE churn (#650): every ~5 s change one key / one row so a live gate can + # observe a /sample delta merge. The other keys / rows never change. + if self._ticks % 20 == 0: + n = self._ticks // 20 + self.send_observations( + [ + ("Pvars", f"cycle={n}"), + ("Ptab", f"G56={{X={n} Z=0.5}}"), + ] + ) + # Re-assert conditions occasionally so a late-attaching driver still sees them. if self._ticks % 60 == 0: self.send_conditions() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 48c909a1..925a4fe3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -448,6 +448,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) results.ShouldAllBe(r => r.Value == null); } + /// + /// #650: the fixture's DATA_SET (fixture_varset) and TABLE (fixture_worktable) read as Good + /// Strings carrying JSON. Only the keys the adapter never mutates are asserted exactly; the periodic + /// cycle key / G56 row are asserted present, because they move every ~5 s. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Read_serves_data_set_and_table_as_json_strings() + { + SkipIfDown(); + + await using var live = await LiveDriverAsync( + [Tag("fixture_varset", DriverDataType.String), Tag("fixture_worktable", DriverDataType.String)]); + + var results = await live.Driver.ReadAsync(["fixture_varset", "fixture_worktable"], Ct); + + if (results.Any(r => r.StatusCode == BadNoCommunication)) + { + Assert.Skip("The fixture's adapter is not feeding Pvars/Ptab (UNAVAILABLE). Is the adapter service running?"); + } + + results.ShouldAllBe(r => r.StatusCode == Good); + + using var set = JsonDocument.Parse(results[0].Value.ShouldBeOfType()); + set.RootElement.GetProperty("toolNumber").GetInt32().ShouldBe(7); + set.RootElement.GetProperty("offsetX").GetDouble().ShouldBe(1.25); + set.RootElement.GetProperty("offsetZ").GetDouble().ShouldBe(-0.5); + set.RootElement.TryGetProperty("cycle", out _).ShouldBeTrue(); + + using var table = JsonDocument.Parse(results[1].Value.ShouldBeOfType()); + table.RootElement.GetProperty("G54").GetProperty("Y").GetDouble().ShouldBe(2.0); + table.RootElement.GetProperty("G55").GetProperty("X").GetDouble().ShouldBe(3.0); + table.RootElement.GetProperty("G56").GetProperty("Z").GetDouble().ShouldBe(0.5); + } + // ---- subscriptions: the /sample long poll ---- /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs new file mode 100644 index 00000000..92755eeb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs @@ -0,0 +1,175 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// #650 — DATA_SET / TABLE observations are served as a String carrying compact, deterministic JSON. +/// Pins the parse (entries, cells, removed, resetTriggered) and the index's merge semantics +/// (/current replaces, /sample merges, UNAVAILABLE drops the accumulated set). +/// +public sealed class MTConnectDataSetTests +{ + private const uint Good = 0x00000000u; + private const uint BadNoCommunication = 0x80310000u; + + private static string Doc(string events, string attrs = "") => + $""" + +
+ {events} + + """; + + private static string DataSet(string body, string attrs = "") => + $"""{body}"""; + + private static string Table(string body, string attrs = "") => + $"""{body}"""; + + private static MTConnectObservationIndex NewIndex() => + new([new MTConnectTagDefinition("ds", DriverDataType.String), new MTConnectTagDefinition("tb", DriverDataType.String)]); + + private static void Apply(MTConnectObservationIndex idx, string events, bool baseline = false) => + idx.Apply(MTConnectStreamsParser.Parse(Doc(events)), baseline); + + [Fact] + public void Parser_captures_entries_cells_removed_and_reset() + { + var result = MTConnectStreamsParser.Parse(Doc( + DataSet("""1""", " resetTriggered=\"MANUAL\"") + + Table("""1.52"""))); + + var ds = result.Observations[0]; + ds.ResetTriggered.ShouldBeTrue(); + ds.Entries!.Count.ShouldBe(2); + ds.Entries[0].ShouldBe(new MTConnectDataSetEntry("a", "1", false)); + ds.Entries[1].Removed.ShouldBeTrue(); + + var tb = result.Observations[1]; + tb.Entries!.ShouldHaveSingleItem().Cells!.Select(c => (c.Key, c.Value)) + .ShouldBe([("X", "1.5"), ("Y", "2")]); + } + + [Fact] + public void Data_set_is_served_as_sorted_compact_json_with_numbers_and_strings() + { + var idx = NewIndex(); + Apply(idx, DataSet("""71.25A & "B"007"""), baseline: true); + + var snap = idx.Get("ds"); + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe("""{"name":"A & \"B\"","offsetX":1.25,"part":"007","toolNumber":7}"""); + } + + [Fact] + public void Table_is_served_as_nested_json() + { + var idx = NewIndex(); + Apply(idx, Table("""321.0"""), baseline: true); + + idx.Get("tb").Value.ShouldBe("""{"G54":{"X":1.0,"Y":2},"G55":{"X":3}}"""); + } + + [Fact] + public void Sample_delta_merges_and_removed_entry_disappears() + { + var idx = NewIndex(); + Apply(idx, DataSet("""12"""), baseline: true); + + Apply(idx, DataSet("""3x""")); + idx.Get("ds").Value.ShouldBe("""{"a":1,"b":3,"c":"x"}"""); + + Apply(idx, DataSet("""""")); + idx.Get("ds").Value.ShouldBe("""{"b":3,"c":"x"}"""); + } + + [Fact] + public void Table_row_delta_replaces_the_row_and_removed_row_disappears() + { + var idx = NewIndex(); + Apply(idx, Table("""123"""), baseline: true); + + Apply(idx, Table("""9""")); + + idx.Get("tb").Value.ShouldBe("""{"G54":{"X":9}}"""); + } + + [Fact] + public void Current_baseline_replaces_rather_than_merges() + { + var idx = NewIndex(); + Apply(idx, DataSet("""12"""), baseline: true); + Apply(idx, DataSet("""3"""), baseline: true); + + idx.Get("ds").Value.ShouldBe("""{"c":3}"""); + } + + [Fact] + public void ResetTriggered_replaces_the_accumulated_set_on_a_sample() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + Apply(idx, DataSet("""9""", " resetTriggered=\"MANUAL\"")); + + idx.Get("ds").Value.ShouldBe("""{"z":9}"""); + } + + [Fact] + public void ResetTriggered_with_no_entries_is_an_empty_set_not_an_absence() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + Apply(idx, DataSet("", " resetTriggered=\"MANUAL\"")); + + var snap = idx.Get("ds"); + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe("{}"); + } + + [Fact] + public void Unavailable_is_BadNoCommunication_and_drops_the_accumulated_set() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + + Apply(idx, DataSet("UNAVAILABLE")); + var snap = idx.Get("ds"); + snap.StatusCode.ShouldBe(BadNoCommunication); + snap.Value.ShouldBeNull(); + + // A delta after the outage must not resurrect the pre-outage entry. + Apply(idx, DataSet("""2""")); + idx.Get("ds").Value.ShouldBe("""{"b":2}"""); + } + + [Fact] + public void Several_observations_in_one_document_fold_in_order() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1""") + DataSet("""2"""), baseline: true); + + idx.Get("ds").Value.ShouldBe("""{"a":1,"b":2}"""); + } + + [Fact] + public void Clear_forgets_the_accumulated_set() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + idx.Clear(); + Apply(idx, DataSet("""2""")); + + idx.Get("ds").Value.ShouldBe("""{"b":2}"""); + } + + [Fact] + public void Structured_content_that_is_not_entries_stays_BadNotSupported() + { + var idx = NewIndex(); + Apply(idx, DataSet("1"), baseline: true); + + idx.Get("ds").StatusCode.ShouldBe(0x803D0000u); + } +} 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 84657e3e..8b2c614a 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 @@ -589,11 +589,12 @@ public sealed class MTConnectObservationIndexTests /// /// End-to-end through the real parser: the entry list that concatenates to the single token - /// "12" is caught by the parser's structured flag and never surfaces as a Good value. This is - /// the leg that would regress silently if the parser stopped setting the flag. + /// "12" must never surface as that noise. Since #650 it is served as JSON carrying the keys + /// (full semantics in MTConnectDataSetTests); the structured-without-entries shape above + /// stays BadNotSupported. /// [Fact] - public void A_parsed_data_set_observation_never_surfaces_as_a_good_value() + public void A_parsed_data_set_observation_never_surfaces_as_concatenated_text() { const string xml = """ @@ -612,8 +613,8 @@ public sealed class MTConnectObservationIndexTests var snap = idx.Get("ds1"); - snap.StatusCode.ShouldBe(BadNotSupported); - snap.Value.ShouldBeNull(); + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe("""{"V1":1,"V2":2}"""); } /// An ordinary text observation is not structured — the flag must not be over-applied. From 9631a40f1ff747b413c5631273a060225270c764 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 19:20:23 -0400 Subject: [PATCH 2/2] fix(mtconnect): resetTriggered only resets data sets; empty set is Good {}; re-baseline test (#650 review) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/MTConnect.md | 1 + .../MTConnectDtos.cs | 9 +++- .../MTConnectObservationIndex.cs | 39 +++++++++++++---- .../MTConnectStreamsParser.cs | 8 +++- .../MTConnectDataSetTests.cs | 43 ++++++++++++++++++- .../MTConnectSubscribeTests.cs | 36 ++++++++++++++++ 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 8067880d..aa6d614e 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -291,6 +291,7 @@ type inference already type these items String): `resetTriggered` observation with no entries is the empty set `{}`. Several observations of one item in one document fold in document order. The accumulated set is dropped on an Agent restart (`instanceId` change). - `removed="true"` deletes the key (or row): it is **absent** from the JSON, never `null`. +- **Empty set is Good `{}`**: a data-set-shaped observation (it carries the `count` attribute, which only DATA_SET/TABLE have) with no entries - `count="0"` in a `/current`, or `resetTriggered` - is the empty set, `{}`, never an absence; an all-removed set renders the same. `resetTriggered` on any other observation (e.g. an accumulating `PartCount`) is ignored. - `UNAVAILABLE` is `BadNoCommunication` as for any observation, and also drops the accumulated set, so a later delta cannot resurrect pre-outage entries. - An observation whose children are not `` elements stays `BadNotSupported`. Writes to these items stay diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index 3d035169..7771b997 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -178,6 +178,12 @@ public sealed record MTConnectStreamsResult( /// true when the observation carried a non-empty resetTriggered attribute — the Agent's statement that /// the set was reset, so this observation replaces the accumulated set rather than merging into it. /// +/// +/// true when the element is data-set shaped: it carries the count attribute every DATA_SET / TABLE +/// observation has (and no SAMPLE or ordinary EVENT does) or has <Entry> children. This — not +/// — is what lets the index serve an entry-less set as {} rather than as +/// an absent value; resetTriggered also appears on ordinary accumulating SAMPLEs and must not matter there. +/// /// /// The CONDITION-only detail (#626) — set exactly when the observation came from a /// <Condition> container, null for every Sample/Event. Its presence is what marks an @@ -192,7 +198,8 @@ public sealed record MTConnectObservation( double? SampleRate = null, MTConnectConditionDetail? Condition = null, IReadOnlyList? Entries = null, - bool ResetTriggered = false); + bool ResetTriggered = false, + bool IsDataSet = false); /// One <Cell key="…"> of a TABLE row (#650). public sealed record MTConnectDataSetCell(string Key, string Value); 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 f3c0bfce..0785bec0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs @@ -121,6 +121,7 @@ internal sealed class MTConnectObservationIndex // Apply, but the lock keeps a concurrent one-shot read-through from corrupting a shared index. private readonly Dictionary> _sets = new(StringComparer.Ordinal); private readonly object _setsLock = new(); + private bool _anySets; private sealed record SetEntry(string? Value, SortedDictionary? Cells); @@ -264,18 +265,37 @@ internal sealed class MTConnectObservationIndex if (observation.Entries is not { Count: > 0 } entries) { - if (observation.ResetTriggered && !observation.IsStructured) + var unavailable = string.IsNullOrWhiteSpace(observation.Value) && !observation.IsDataSet || + string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal); + + if (unavailable) { - // resetTriggered with no entries: an empty set, which is a legitimate value rather than an absence. - lock (_setsLock) _sets[id] = new SortedDictionary(StringComparer.Ordinal); - seenSets.Add(id); - return observation with { Value = "{}", ResetTriggered = false }; + // Hot path for every ordinary UNAVAILABLE observation: skip the lock until a set exists at all. + if (Volatile.Read(ref _anySets)) lock (_setsLock) _sets.Remove(id); + + return observation; } - if (string.IsNullOrWhiteSpace(observation.Value) || - string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal)) + if (observation.IsDataSet && !observation.IsStructured) { - lock (_setsLock) _sets.Remove(id); + // An entry-less data-set-shaped observation (count="0", or resetTriggered) is the EMPTY set - a + // legitimate value, Good "{}", not an absence. A reset or a /current baseline discards what + // was accumulated; a bare empty delta leaves the set as it is. + string emptyJson; + lock (_setsLock) + { + var first = seenSets.Add(id); + if (!_sets.TryGetValue(id, out var set) || observation.ResetTriggered || (baseline && first)) + { + set = new SortedDictionary(StringComparer.Ordinal); + _sets[id] = set; + Volatile.Write(ref _anySets, true); + } + + emptyJson = RenderSet(set); + } + + return observation with { Value = emptyJson, ResetTriggered = false, IsDataSet = false }; } return observation; @@ -289,6 +309,7 @@ internal sealed class MTConnectObservationIndex { set = new SortedDictionary(StringComparer.Ordinal); _sets[id] = set; + Volatile.Write(ref _anySets, true); } foreach (var entry in entries) @@ -312,7 +333,7 @@ internal sealed class MTConnectObservationIndex json = RenderSet(set); } - return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false }; + return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false, IsDataSet = false }; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs index 01a8a6bb..0681c853 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs @@ -382,6 +382,7 @@ internal static class MTConnectStreamsParser int? sampleCount = null; double? sampleRate = null; var resetTriggered = false; + var hasCount = false; List? entries = null; (string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default; @@ -410,6 +411,7 @@ internal static class MTConnectStreamsParser if (!isCondition) { resetTriggered = !string.IsNullOrWhiteSpace(MTConnectXml.OptionalAttribute(reader, "resetTriggered")); + hasCount = MTConnectXml.OptionalAttribute(reader, "count") is not null; entries = []; sampleCount = int.TryParse( MTConnectXml.OptionalAttribute(reader, "sampleCount"), @@ -483,7 +485,11 @@ internal static class MTConnectStreamsParser SampleRate: sampleRate, Condition: condition, Entries: entries is { Count: > 0 } ? entries : null, - ResetTriggered: resetTriggered); + + // resetTriggered only means "replace the set" on a data-set-shaped observation; on an ordinary + // accumulating SAMPLE/EVENT (PartCount resetTriggered="DAILY") it is not our concern. + ResetTriggered: resetTriggered && (hasCount || entries is { Count: > 0 }), + IsDataSet: !isCondition && (hasCount || entries is { Count: > 0 })); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs index 92755eeb..6701f73c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs @@ -23,10 +23,10 @@ public sealed class MTConnectDataSetTests """; private static string DataSet(string body, string attrs = "") => - $"""{body}"""; + $"""{body}"""; private static string Table(string body, string attrs = "") => - $"""{body}"""; + $"""{body}"""; private static MTConnectObservationIndex NewIndex() => new([new MTConnectTagDefinition("ds", DriverDataType.String), new MTConnectTagDefinition("tb", DriverDataType.String)]); @@ -172,4 +172,43 @@ public sealed class MTConnectDataSetTests idx.Get("ds").StatusCode.ShouldBe(0x803D0000u); } + + [Fact] + public void ResetTriggered_on_an_ordinary_sample_keeps_its_numeric_value() + { + // Regression: resetTriggered is also a standard attribute on accumulating SAMPLEs / counters. It must + // never turn one into an empty set. + var idx = new MTConnectObservationIndex( + [new MTConnectTagDefinition("pc", DriverDataType.Int64), new MTConnectTagDefinition("amt", DriverDataType.Float64)]); + idx.Apply(MTConnectStreamsParser.Parse(Doc( + """5""" + + """2.5"""))); + + idx.Get("pc").StatusCode.ShouldBe(Good); + idx.Get("pc").Value.ShouldBe(5L); + idx.Get("amt").Value.ShouldBe(2.5d); + } + + [Fact] + public void An_empty_current_data_set_is_Good_empty_json_like_a_reset_one() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + + Apply(idx, DataSet(""), baseline: true); + + var snap = idx.Get("ds"); + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe("{}"); + } + + [Fact] + public void An_all_removed_set_is_the_same_empty_json() + { + var idx = NewIndex(); + Apply(idx, DataSet("""1"""), baseline: true); + Apply(idx, DataSet("""""")); + + idx.Get("ds").Value.ShouldBe("{}"); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs index 84b5d790..f7863089 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs @@ -377,6 +377,42 @@ public sealed class MTConnectSubscribeTests For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(77.5d); } + /// + /// #650: a re-baseline /current states the WHOLE data set, so it must REPLACE what the deltas + /// accumulated - a key removed while the stream was down must not linger. Reverting the + /// baseline: true argument at the re-baseline call site leaves {a,b} merged and fails this. + /// (The init and one-shot-read call sites build a fresh index, so their flag is not observable.) + /// + [Fact] + public async Task Rebaseline_replaces_the_accumulated_data_set_rather_than_merging() + { + var (driver, client) = await InitializedDriverAsync(); + + static MTConnectStreamsResult SetChunk(long first, long next, params (string Key, string Value)[] entries) => + new(FixtureInstanceId, next, first, + [ + new MTConnectObservation( + "ds", "", ObservedAt, IsStructured: true, IsDataSet: true, + Entries: [.. entries.Select(e => new MTConnectDataSetEntry(e.Key, e.Value, false))]), + ]); + + const string Oor = "MTConnect /sample answered an MTConnectError document (OUT_OF_RANGE) under HTTP 200"; + + // First re-baseline seeds {a,b}; the stream then resumes from its nextSequence (500). + client.SampleFailures.Enqueue(new InvalidDataException(Oor)); + client.CurrentAnswers.Enqueue(SetChunk(490, 500, ("a", "1"), ("b", "2"))); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await client.PumpAsync(Chunk(500, 505, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + driver.ObservationIndex.Get("ds").Value.ShouldBe("""{"a":1,"b":2}"""); + + client.SampleFailures.Enqueue(new InvalidDataException(Oor)); + client.CurrentAnswers.Enqueue(SetChunk(600, 610, ("a", "1"))); + + await client.PumpAsync(Chunk(610, 615, ("dev1_pos", "77.5"))).WaitAsync(Watchdog, Ct); + + driver.ObservationIndex.Get("ds").Value.ShouldBe("""{"a":1}"""); + } + // ---- agent restart ---- ///