docs(mtconnect): Java reference parity matrix + live validation record (#804)

This commit is contained in:
Joseph Doherty
2026-11-10 23:08:42 -05:00
parent d6392dae17
commit f17ff1e643
2 changed files with 164 additions and 11 deletions
+8
View File
@@ -291,6 +291,14 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
- **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing - **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing
with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device
(`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second). (`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second).
- **MTConnect Java-parity review (#804):** a 2.x Agent serves its error documents under **non-2xx** (`OUT_OF_RANGE`
= HTTP 400, `NO_DEVICE` = 404); the client lifts them (≤ 64 KiB read) into the refusal-marked
`InvalidDataException` — before #804 the 400 was a transport fault and the `OUT_OF_RANGE` re-baseline never ran
live. The restart (`instanceId`) test also runs on every re-baseline snapshot (on 2.x that is where a restart is
first seen). 3 re-baselines with no `/sample` chunk ⇒ Degraded with `LastSuccessfulRead` still advancing (no
watchdog reconnect loop over a `SampleCount` ≥ `bufferSize`). 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 CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig` - **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig`
carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break
existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe
+156 -11
View File
@@ -60,6 +60,16 @@ the whole vendor component (and every DataItem beneath it) rather than fail
loudly. Attributes are read **unqualified** for the same reason — so an loudly. Attributes are read **unqualified** for the same reason — so an
`xsi:type` attribute is never mistaken for a DataItem's own `type`. `xsi:type` attribute is never mistaken for a DataItem's own `type`.
**Hardened against the peer (#804).** The Agent is unauthenticated, so every parse
refuses a `DOCTYPE` outright (`DtdProcessing.Prohibit` — the `/probe` and write-answer
parses used to go through `XDocument.Parse`, which *parses* DTDs and expands internal
entities), the streams walk refuses nesting deeper than 64 levels (it recurses per
level, and an unbounded depth is a `StackOverflowException` .NET cannot catch — the
whole server process, not the driver, would die), and a structural element
(`Description`/`Configuration`/`References`/`Compositions`/`DataItems`) sitting
directly under `<Components>` or `<Devices>` is skipped rather than failing the whole
probe on its missing `id`.
## Project split ## Project split
| Project | Target | Role | | Project | Target | Role |
@@ -127,7 +137,8 @@ checks the answer's `instanceId`: a changed one (an Agent restart seen by a brow
cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump. cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump.
**An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g. **An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g.
`NO_DEVICE` for a wrong `DeviceName` scope) still fails the batch (`BadCommunicationError` per ref), but it is the `NO_DEVICE` for a wrong `DeviceName` scope — a 2.x Agent serves it under **HTTP 404**, an older one under 200;
both are lifted since #804) still fails the batch (`BadCommunicationError` per ref), but it is the
Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog
reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation
clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all
@@ -258,12 +269,17 @@ item. A `CONDITION` observation's value is taken from its **element name**
condition element normalises onto the same `UNAVAILABLE` sentinel as every condition element normalises onto the same `UNAVAILABLE` sentinel as every
other category so one comparison covers all three. other category so one comparison covers all three.
**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
reference driver, which truncates toward zero.
Other status codes an observation can surface: Other status codes an observation can surface:
| Code | Meaning | | Code | Meaning |
|---|---| |---|---|
| `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all | | `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all — including a `NaN`/`Infinity` spelling on a float tag (#804; it used to be served Good) |
| `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent | | `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent — an integer overflow, a real fraction on an integral tag (`3.5` is refused, never truncated), or a float literal that overflows to ±Infinity (`1e400`; #804, it used to be served as a Good Infinity) |
| `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) | | `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 `DATA_SET`/`TABLE` observation whose children are not `<Entry>` elements (a well-formed one is served as JSON since #650), 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 `<Entry>` 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 | | `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet |
@@ -332,15 +348,41 @@ what the driver's cursor expects) is detected **two ways**, both triggering a
1. `IMTConnectAgentClient.IsSequenceGap` — the next chunk's `firstSequence` 1. `IMTConnectAgentClient.IsSequenceGap` — the next chunk's `firstSequence`
is newer than the driver's expected cursor. is newer than the driver's expected cursor.
2. An `MTConnectError` document reporting `OUT_OF_RANGE` — real Agents return 2. An `MTConnectError` document reporting `OUT_OF_RANGE` — older Agents serve it
this both under HTTP 200 (a normal MTConnect error document) and as a bare under HTTP 200; a 2.x Agent (the fixture's `mtconnect/agent:2.7`) serves it
HTTP 400, and the client handles both. under **HTTP 400**. ⚠️ **Until #804 only the 200 shape was handled**: the client
threw a bare `HttpRequestException` for the 400, the pump read it as transport
loss and reconnected at the same evicted cursor for ever, so this leg never ran
against a real 2.x Agent (the comms-loss watchdog's re-initialize was the only
escape). The client now reads up to 64 KiB of any non-2xx body and lifts an
`MTConnectError` into the same refusal-marked `InvalidDataException` the 200
shape produces; a non-MTConnect body (a proxy page) stays a status-only
`HttpRequestException`.
**Re-baselines without progress are bounded *and visible* (#804).** The reconnect
backoff already paced a stream that only ever re-baselines (`A_rebaseline_that_delivers_nothing…`),
but every `/current` also marked the stream delivered, so the driver read **Healthy**
for ever while no `/sample` chunk arrived — the live case is a `SampleCount` at or
above the Agent's `bufferSize`, refused `OUT_OF_RANGE` (`'count' must be less than N`)
on every open. After **3** consecutive re-baselines with no chunk in between
(`MaxRebaselinesWithoutProgress`, the Java driver's `MAX_REBASELINES_WITHOUT_PROGRESS`)
the stream is **Degraded** with `LastError = "repeated /sample re-baselines with no
observations in between"` and one Warning naming the `bufferSize` cause; it stays
Degraded across further re-baselines until a chunk is delivered. Values keep flowing
off the re-baselines and `LastSuccessfulRead` keeps advancing — each `/current` is the
Agent answering — so the comms-loss watchdog does not reconnect-loop a configuration
problem. Size `SampleCount` below the Agent's `bufferSize` (in the `/probe` header).
**An Agent `instanceId` change means the Agent restarted**, and is checked **An Agent `instanceId` change means the Agent restarted**, and is checked
**before** the sequence-gap check (a restart also usually trips the gap, so **before** the sequence-gap check (a restart also usually trips the gap, so
the two need disambiguating, not just OR-ing together). On a changed the two need disambiguating, not just OR-ing together). On a changed
`instanceId` the driver clears its cached probe model and raises `instanceId` the driver clears its cached probe model and raises
`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI `OnRediscoveryNeeded`. **The test also runs on every re-baseline's `/current`
(#804)** — on a 2.x Agent that is usually where a restart is first seen, because the
restarted process refuses the dead one's cursor with HTTP 400 `OUT_OF_RANGE` and no
chunk ever reaches the in-stream check; the re-baseline used to adopt the new id
silently (live-confirmed on the fixture). Either way the driver drops every held
observation (subscribers are told) and raises the rediscovery signal, which surfaces a **re-browse prompt** on the AdminUI
`/hosts` page — see [Known limitations](#known-limitations) for what that does `/hosts` page — see [Known limitations](#known-limitations) for what that does
and does not do. and does not do.
@@ -472,7 +514,11 @@ and whose instance reports online discovery, renders the AdminUI **Browse**
button, constructs the driver, runs `InitializeAsync` (the `/probe` connect) button, constructs the driver, runs `InitializeAsync` (the `/probe` connect)
+ `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the + `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the
throwaway instance down. Each captured leaf's NodeId is the DataItem `id`, throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
committed directly as `TagConfig.FullName` on pick. See committed directly as `TagConfig.FullName` on pick. Sibling containers that resolve to
the **same folder name** (two components named alike, or one's `name` equal to another's
`id`) share **one** folder (#804): the picker keys folders by path, so two same-named
siblings used to list both but expand only the first one's children. A DataItem named
like a sibling folder is not a collision — leaves are keyed by DataItem id. See
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md). [`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
## CONDITION alarms (#626) ## CONDITION alarms (#626)
@@ -571,6 +617,106 @@ table plus an unknown-value fallback, a per-type enum node shape through discove
and it would break every consumer that reads these items as String (ScadaBridge) for little gain. Reopen only 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. with an opt-in, tag-level `enumValues` design.
## Java reference parity (#804)
Gitea #804 reviewed this driver against the read-only Java MTConnect driver in the `ignitionoee` repo
(`modules/drivers/gateway/src/{main,test}/java/com/zb/mom/driver/mtconnect/`, live-validated on Ignition
2026-08-10). That Java driver is largely a port of this one, so most rows are **covered**; the review found
nine concrete gaps, all closed on `feat/804-mtconnect-parity`. Code paths below are relative to
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/`, tests to `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
(unit) and `…MTConnect.IntegrationTests/` (live); Java classes are `MTConnect*.java` in the package above. The
Java driver's live evidence does not validate this one — this driver's own live results are at the end.
**Status key:** **covered** — .NET already did it (test named); **gap → closed** — verified missing, fixed in
#804 with a test that failed first; **.NET exceeds** — beyond the Java baseline, kept; **divergence** —
deliberately different, kept.
### Transport, protocol and configuration
| Java capability (class) | .NET status | .NET code · tests |
|---|---|---|
| HTTP/HTTPS Agent base URI, http/https only (`MTConnectConfigValidator`) | covered | `MTConnectAgentClient.ResolveAgentRoot` · `MTConnectFactoryTests.Malformed_or_non_http_agentUri_returns_not_ok` |
| Optional device scope `{agentUri}/{deviceName}/…` (`MTConnectAgentClient`) | covered (+ a belt-and-braces scope match in discovery) | `BuildRequestUri`, `MTConnectDriver.InDeviceScope` · `MTConnectDiscoverTests.Discover_with_a_device_scope_*` |
| `/probe` at connect, fault-fast (`MTConnectDevice.connect`) | covered | `StartCoreAsync` (initialize fails → Faulted) · `MTConnectDriverLifecycleTests` |
| `/current` snapshot; `/sample` multipart long poll with `from`/`interval`/`count`/`heartbeat` | covered | `CurrentAsync`/`SampleAsync` · `MTConnectStreamsParseTests.SampleAsync_composes_the_from_interval_count_and_heartbeat_query` |
| Strictly-positive timing knobs (`requirePositive`) | covered | `MTConnectDriver.RequirePositive` · `MTConnectDriverOptionsTests` |
| One HTTP client per device, not per reconnect (`SharedHttp`) | covered — two `HttpClient`s per agent client, rebuilt only on a request-shaping config change | `RequiresClientRebuild` · `MTConnectDriverLifecycleTests.Reinitialize_with_a_changed_device_scope_rebuilds_the_client` |
| Agent error document under a **non-2xx** status lifted (`probeAgentError`/`streamsAgentError`) — a 2.x Agent serves `OUT_OF_RANGE` as **400** and `NO_DEVICE` as **404** | **gap → closed** | `EnsureAgentAnsweredAsync`, `MTConnectXml.AgentRefusalFromErrorBody` · `MTConnectJavaParityTests.Sample_answered_400_…`, `Current_answered_404_…`, `Probe_answered_404_…`; live `A_sample_cursor_outside_the_buffer_is_the_agents_out_of_range_refusal` |
| Failure body read only to a cap (`MAX_ERROR_BODY_BYTES`, 64 KiB) | **gap → closed** (with the row above) | `MaxErrorBodyBytes` · `An_error_document_beyond_the_body_cap_is_not_read_in_full`, `A_non_2xx_proxy_page_is_still_a_plain_http_failure` |
| Demand-read TTL cache for un-monitored reads (`MTConnectDemandSource`, `demandReadTtlMs`) | covered, different model | reads answer from the live pump index (no round trip) or one `/current` per **batch**; the server never calls `ReadAsync` on its hot path · `MTConnectReadPathTests` |
| No credentials / no `SecretConfig` | divergence | a `user:pass@` URI is accepted and **redacted** everywhere (`MTConnectUriRedaction`) · `MTConnectUriRedactionTests` |
### Parsing, browse and values
| Java capability (class) | .NET status | .NET code · tests |
|---|---|---|
| Match on LocalName, read attributes unqualified (`MTConnectXml` rules 1–2) | covered | `MTConnectXml` · `Probe_parse_is_agnostic_to_the_document_namespace_version`, `Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace`, `Parse_reads_only_unqualified_attributes` |
| DOCTYPE refused on **every** parse (`disallow-doctype-decl`) | **gap → closed** for `/probe` and write answers (the streams parser already prohibited it) | `MTConnectXml.ParseHardened`/`LoadHardened` · `Probe_parse_refuses_a_doctype_from_a_string`/`…_stream`, `A_write_answered_with_a_doctype_document_is_not_confirmed` |
| Component nesting bound 64 (`MAX_COMPONENT_DEPTH`) | covered | `MTConnectProbeParser.MaxComponentDepth` · `MTConnectProbeParseTests` |
| Streams element nesting bound 64 (`MAX_ELEMENT_DEPTH`) | **gap → closed** — the walk recursed without bound: an uncatchable `StackOverflowException`, i.e. a server-process crash | `MTConnectStreamsParser.MaxElementDepth` · `Streams_parse_refuses_absurd_nesting_…`, controls `Streams_parse_accepts_ordinary_device_nesting`, `An_absurdly_deep_error_document_still_yields_the_agents_words` |
| Structural elements under `<Components>`/`<Devices>` skipped (`NOT_A_COMPONENT`) | **gap → closed** (one stray `<Description>` failed the whole probe) | `MTConnectProbeParser.ComponentLikeChildren` · `A_structural_element_under_components_or_devices_is_skipped_not_fatal` |
| Malformed XML, missing header, missing `dataItemId` throw | covered | `Probe_parse_throws_on_malformed_xml`, `Parse_throws_on_malformed_xml`, `Parse_throws_on_a_malformed_observation` |
| BOM / framing whitespace tolerated on a chunk | covered | `Parse_from_bytes_tolerates_the_multipart_frames_trailing_line_break` |
| Multipart framing: Content-length authoritative, boundary-scan fallback, preamble, LF-only, split reads, truncation throws, 32 MiB part cap (`MTConnectMultipartReader`) | covered | `MultipartStreamReader` · `SampleAsync_frames_*`, `A_part_without_content_length_warns_once…`; Java cases ported as `A_declared_content_length_is_authoritative…`, `A_preamble_is_skipped_and_line_feed_only_framing_is_accepted`, `A_part_shorter_than_its_declared_content_length_is_a_lost_stream`, `A_part_larger_than_the_buffer_cap_is_refused` |
| Typed Device → Component → DataItem tree; `name` else `id`; leaf ref = DataItem id (`MTConnectBrowseTree`) | covered | `DiscoverAsync`/`StreamContainer` · `MTConnectDiscoverTests` |
| Sibling containers resolving to one name share **one** folder | **gap → closed** (the picker listed both, expanded only the first) | `MTConnectDriver.SiblingFolders` · `Sibling_components_resolving_to_one_name_share_one_folder_and_keep_every_data_item` |
| DataItem named like a sibling component (leaf-before-folder ordering) | covered — not a collision here: leaves are keyed by DataItem id, folders by path | `Commons/Browsing/CapturingAddressSpaceBuilder` |
| Bounded tree (`maxBrowseNodes` per-folder cap + truncation flag) | covered, different mechanism | the browse capture caps the whole tree at 50 000 nodes and flags `Truncated`; the *served* address space is authored raw tags, not the browse |
| Display-name trimming (`segmentsAreTrimmed`) | divergence (not closed) | names are used verbatim — cosmetic, never seen on a real Agent |
| 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) |
| 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` |
| An unparseable timestamp falls back to *now* | divergence (kept) | the chunk is refused (`InvalidDataException` → re-baseline): a fabricated SourceTimestamp is never served |
| Transport failure distinct from UNAVAILABLE | covered | `BadCommunicationError` per ref on a failed read; the host fans `BadNoCommunication` on comms loss (#481) |
### Stream lifecycle
| Java capability (class) | .NET status | .NET code · tests |
|---|---|---|
| Running cursor; gap = `firstSequence > expected`, strictly (`MTConnectPump.onDocument`) | covered | `IsSequenceGap` · `Sequence_gap_is_strictly_firstSequence_greater_than_expected_from`, `Sequence_gap_triggers_exactly_one_current_rebaseline_then_resumes` |
| `instanceId` checked before the gap; a restart drops held values | covered | `ConsumeStreamAsync` · `Agent_restart_clears_the_index_and_is_detected_before_the_gap_path` |
| The restart test also runs on the re-baseline snapshot | **gap → closed** — on a 2.x Agent a restart is usually seen ONLY here (the old cursor gets a 400) | `RebaselineAsync`, `TryDropForAgentRestart` · `An_agent_restart_seen_only_by_the_rebaseline_…`, control `An_out_of_range_rebaseline_against_the_same_agent_raises_no_rediscovery` |
| Restart → reconnect + re-probe + rebuild the tree | divergence (v3 design) | re-baseline in place + an `IRediscoverable` re-browse prompt; the served tree is authored, never grafted (Known limitations 1) |
| `OUT_OF_RANGE` (200 **or** 400) → re-baseline (`onError`) | covered for 200; **gap → closed** for 400 (Transport table) | the `InvalidDataException` arm of `ConsumeStreamAsync` · `Out_of_range_stream_error_also_triggers_a_rebaseline` |
| Any other Agent error → reconnect, not re-baseline | divergence | every unusable answer re-baselines; the backoff ladder and the no-progress cap below bound it |
| Re-baselines without progress bounded (`MAX_REBASELINES_WITHOUT_PROGRESS` = 3) | paced already (backoff); **gap → closed** for visibility — it read Healthy for ever | `MaxRebaselinesWithoutProgress`, `TryHoldStreamStalled` · `Repeated_rebaselines_without_a_delivered_chunk_report_the_stream_degraded`, control `A_rebaseline_short_of_the_cap_stays_healthy`; live `A_sample_count_the_agent_refuses_is_reported_degraded_while_values_keep_flowing` |
| Heartbeat watchdog; any part resets it (`watchdogExpired`, 2 × heartbeat) | covered (2 × `HeartbeatMs` + `RequestTimeoutMs`) | `MultipartStreamReader` idle timeout · `SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent` |
| Bounded reconnect backoff (fixed 2 s → 30 s, jitter) | covered, configurable (`Reconnect.*`; ladder reset after a delivering stream) | `BackoffFor` · `MTConnectSubscribeTests.Reconnect_backoff_*` |
| A superseded generation speaks for nothing (`PumpState`, `Host.running`) | covered | session generation · `MTConnectPumpGenerationTests` |
| Deterministic cancellation and disposal | covered | `ShutdownAsync`, bounded teardown waits · `Shutdown_stops_the_pump_before_disposing_the_client_and_leaks_nothing`, `Shutdown_while_the_pump_is_mid_rebaseline_does_not_deadlock` |
### Representations and writes
| Java capability | .NET status | Notes |
|---|---|---|
| TIME_SERIES / DATA_SET / TABLE **excluded** from the tree (one WARN) | **.NET exceeds** — kept | TIME_SERIES → `double[]` (#627); DATA_SET/TABLE → compact JSON String (#650) · `MTConnectDataSetTests`; live `Discovery_types_samples_as_Float64_and_time_series_as_a_float_array`, `Read_serves_data_set_and_table_as_json_strings` |
| CONDITION → String state word | covered, **.NET exceeds** (opt-in native Part 9 alarms, #626) | [CONDITION alarms](#condition-alarms-626) |
| **No write path** (read-only, no `writeTag()`) | **divergence — flagged, not removed** | `IWritable` (`b29623cf`, 2026-08-05) is the Agent's `AllowPut` **observation injection**: it sets what the Agent *reports* for a non-CONDITION, non-array DataItem, and nothing reaches the controller. It is off at two independent gates (`Write.Enabled`, default false; the Agent's `AllowPut`, default off) and a node is only writable once its raw tag is authored `ReadWrite`. So there is no *controller* write path — but there is an Agent write path the Java driver does not have. See [Write-back](#write-back--observation-injection-off-by-default); rejection is live-tested by `Write_is_refused_without_touching_the_agent_when_write_is_not_enabled` |
### Live validation (2026-11-10, `mtconnect/agent:2.7.0.12` at `http://10.100.0.35:5000`)
- **Integration suite: 20/20** (`MTCONNECT_AGENT_ENDPOINT=http://10.100.0.35:5000`): browse, typed discovery,
read, UNAVAILABLE, DATA_SET/TABLE, subscribe, RawPath binding, the write round trip, the Agent's write refusal and
the `write.enabled` rejection — plus the two #804 legs, both of which failed before the fix.
- **Lifecycle harness** — the real `MTConnectDriver` subscribed to `fixture_c_speed`/`fixture_mode`/`fixture_execution`
with `HeartbeatMs` 5000, while the fixture containers were stopped/restarted/paused (all restored and healthy after):
- *Adapter stop:* the Agent marked its items UNAVAILABLE ~12 s later (its own adapter timeout); all three went
`BadNoCommunication` while the driver stayed **Healthy** (the Agent kept answering). *Adapter start:* Good ~3 s later.
- *Agent restart:* one `Connection refused` Degraded tick, the restart seen on the re-baseline, **rediscovery
raised**, Healthy and Good ~2 s after the Agent was back. Before the re-baseline restart fix the same leg recovered
its values but raised **no** rediscovery — that run is what found the gap.
- *Frozen Agent* (`docker pause`): the watchdog fired at 15 s (2 × 5000 + 5000); the driver went Degraded with
`LastSuccessfulRead` frozen (the comms-loss watchdog's evidence) and held its last values (the host stamps them
`BadNoCommunication` on the comms-loss edge, #481); reconnects timed out until *unpause*, then Healthy and Good within ~2 s.
- *Teardown:* unsubscribe + shutdown returned in 1 ms.
- **Deferred live checks:** these legs ran through the driver, **not** through a deployed OtOpcUa server and an
OPC UA client (the docker-dev rig was not used); the no-progress cap was observed via a `SampleCount` above the
Agent's `bufferSize`, not a genuinely lapped ring buffer; no real machine tool or non-cppagent Agent was exercised.
## Known limitations ## Known limitations
These are real, not placeholders — read them before relying on the driver These are real, not placeholders — read them before relying on the driver
@@ -679,7 +825,7 @@ for anything beyond values-and-conditions.
## Testing ## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/` - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
(canned-XML fixtures, no network) — 491/491 at time of writing. Covers (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, discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
`UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping, `UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
multipart chunk framing, and ring-buffer re-baseline paging (both the multipart chunk framing, and ring-buffer re-baseline paging (both the
@@ -690,8 +836,7 @@ for anything beyond values-and-conditions.
- **Integration tests** — - **Integration tests** —
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real cleanly when the fixture is unreachable, 20/20 against the real fixture Agent at #804. Read the fixture's own
Agent. Read the fixture's own
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) [`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
for the full recipe — summary only, here: for the full recipe — summary only, here:
- Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which - Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which