docs(mtconnect): Java reference parity matrix + live validation record (#804)
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user