feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate (#506)
v2-ci / build (push) Successful in 6m38s
v2-ci / unit-tests (push) Failing after 3h0m33s

This commit was merged in pull request #506.
This commit is contained in:
2026-07-27 14:02:26 -04:00
75 changed files with 19098 additions and 16 deletions
+5
View File
@@ -84,6 +84,11 @@
ships net6.0/net8.0 only.
-->
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
<!-- Driver.MTConnect carries NO backend NuGet. The TrakHound MTConnect.NET-Common/-HTTP pins
Task 0 added were removed in Task 7 (2026-07-24): neither package can parse an MTConnect
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
+4
View File
@@ -32,6 +32,8 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
@@ -99,6 +101,8 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
+45
View File
@@ -0,0 +1,45 @@
# Isolated MTConnect live-gate overlay (Task 21).
#
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
# three sibling driver worktrees that share `otopcua-host:dev` are untouched.
# Mirrors how the mqtt-sparkplug worktree isolated itself (`otopcua-host:mqtt`,
# project `otopcua-mqtt`, ports 9210/4850).
#
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mtconnect .
# docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml \
# -f docker-dev/docker-compose.mtconnect.yml \
# up -d sql migrator cluster-seed central-1 central-2
#
# AdminUI -> http://localhost:9220 (login disabled; auto-admin)
# OPC UA -> opc.tcp://localhost:4860 (central-1) / :4861 (central-2)
# SQL -> localhost,14340
#
# Both MAIN nodes run because `cluster-seed` declares MAIN as a 2-node cluster:
# ConfigPublishCoordinator sources its expected-ack set from enabled ClusterNode
# rows, so deploying with one node down fails at the apply deadline.
#
# Traefik is deliberately not started — central-1 publishes its AdminUI port
# directly, which removes the :9200 round-robin that otherwise makes it
# ambiguous which node served a request.
# NOTE: `!override` is required on every `ports` list. Compose MERGES list-valued
# keys by appending, so a plain re-declaration would keep the base file's
# "14330:1433" / "4840:4840" alongside the offsets and collide with the running
# `otopcua-dev` rig ("Bind for 0.0.0.0:14330 failed: port is already allocated").
services:
sql:
ports: !override
- "14340:1433"
central-1:
image: otopcua-host:mtconnect
ports: !override
- "4860:4840"
- "9220:9000"
central-2:
image: otopcua-host:mtconnect
ports: !override
- "4861:4840"
+344
View File
@@ -0,0 +1,344 @@
# MTConnect Driver
Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is
the short path — for the full design rationale read
[`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md)
and the build-vs-plan record in
[`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md); for the
fixture recipe read
[`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
(not duplicated here).
> **Live gate:** see the LIVE-GATE RESULT note in the plan's Task 21
> (`docs/plans/2026-07-24-mtconnect-driver.md`) for the docker-dev `/run` verification outcome
> (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).
## What it talks to
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
front-ends a machine tool (or fronts an adapter that itself talks to the
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
three standard REST paths:
- `/probe` — the static device model (Device → Component → DataItem tree)
- `/current` — a snapshot of every DataItem's latest observation
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
deltas, keyed by a monotonic sequence number
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
because the mainstream Agent surface has no "set value" operation.
## Built vs. planned — read this before trusting the design doc's §2
The design doc ([`2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md))
picked the **TrakHound `MTConnect.NET-Common` / `-HTTP`** NuGet packages (MIT,
`netstandard2.0`) as the primary path, with a hand-rolled fallback. **The
hand-rolled fallback is what shipped.** Task 6/7 of the implementation plan
found by reflection + live invocation that the pinned TrakHound packages
ship **no XML formatter** (`Document Formatter Not found for "xml"` — the
formatter lives in a separate `MTConnect.NET-XML` package the design didn't
pin) and that `MTConnectHttpClientStream` exposes no injectable `HttpClient`
handler, so it cannot be unit-tested behind the driver's `IMTConnectAgentClient`
seam. Both package references were dropped. There is **no TrakHound
dependency anywhere in the shipped driver** — probe/current/sample parsing is
hand-rolled `System.Xml.Linq` plus a multipart boundary reader, entirely
behind `IMTConnectAgentClient`. See the CORRECTION block under Task 0 of the
implementation plan for the full record.
**Namespace-agnostic parsing.** Every parser matches XML elements on
`LocalName` only, ignoring the document's XML namespace entirely, so
MTConnect 1.3 through 2.x documents all parse without a schema-version
branch. This is deliberate, not sloppy: a real Agent injects vendor-extension
`Component`s in a foreign namespace whose child `DataItems` elements inherit
the *default* namespace, and namespace-strict matching would silently drop
the whole vendor component (and every DataItem beneath it) rather than fail
loudly. Attributes are read **unqualified** for the same reason — so an
`xsi:type` attribute is never mistaken for a DataItem's own `type`.
## Project split
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/` | net10.0 | In-process driver — `MTConnectDriver`, the hand-rolled `IMTConnectAgentClient` (probe/current/sample), the observation index, the factory |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/` | net10.0 | `MTConnectDriverOptions`, `MTConnectTagDefinition`, and the pure `MTConnectDataTypeInference` table shared by the driver, browse-commit, and the AdminUI typed editor |
No `.Browser` project — browse comes free from the Wave-0 universal
discovery browser (see [Browse](#browse--free-via-the-universal-browser)
below).
## Capability surface
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
(a request/response handshake, not a "set value" operation), and is out of
scope for this build — see [Deferred](#deferred-not-in-this-build).
| Capability | Path | Notes |
|------------|------|-------|
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
**`IReadable.ReadAsync` is never called by the running server.** The
production data plane is entirely `ISubscribable``DriverInstanceActor`
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
Client CLI (`... read -n ...`) and for the unit/integration suites; it is
correct and tested, just not on the hot path.
## Minimum deployment
```jsonc
"Drivers": {
"mtconnect-1": {
"Type": "MTConnect",
"Config": {
"AgentUri": "http://10.100.0.35:5000",
"DeviceName": null,
"RequestTimeoutMs": 5000,
"SampleIntervalMs": 1000,
"SampleCount": 1000,
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Tags": []
}
}
}
```
`RawTags[]` is not authored by hand — `DriverDeviceConfigMerger` injects it
at deploy time from the tags authored on the `/raw` tree.
### Config keys
Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`,
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`):
| Key | Default | Notes |
|---|---|---|
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
**All timing knobs are validated strictly positive** at `InitializeAsync`
(`RequirePositive`, mirroring the arch-review 01/S-6 lesson: a `0` timeout
does not mean "wait forever," it faults the driver). This applies to
`RequestTimeoutMs`, `HeartbeatMs`, `SampleIntervalMs`, `SampleCount`, and
(when the probe is enabled) `Probe.Interval` / `Probe.Timeout`.
**No save-time gate on a blank `AgentUri` in the AdminUI.** The driver form
renders an inline validation notice (`_form.Validate()`), but
`DriverConfigModal.SaveAsync` has no per-form validation seam to block the
Save button on it — no sibling driver form has one either. A blank
`AgentUri` saves cleanly and fails at deploy with the driver's own error
message, not at authoring time.
## Data plane
### FullName == DataItem `id`
`FullName` (both `MTConnectTagDefinition.FullName` and every `RawTags[]`
blob's identifier) is the MTConnect `DataItem@id` attribute — the value the
universal browser commits and the key the driver resolves reads/subscribes
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
three spellings in order — `fullName``dataItemId``address` — and takes
the first non-blank one, normalising onto `fullName` on save. `address` is
accepted because that's the field name `RawBrowseCommitMapper` writes for a
driver with no typed editor path, so a browse-committed tag stays readable
even before the editor round-trips it.
### Coercion-type precedence
The driver keys its data plane by **RawPath** (the v3 raw-tag identity), not
by DataItem id directly, resolving RawPath → dataItemId internally. Each raw
tag's coercion type (the `DriverDataType` its Agent observation is parsed
into) is resolved in this order, first non-null wins:
1. The `RawTags[]` blob's `driverDataType` or `dataType` field (both
spellings accepted — the typed editor writes `dataType`, the driver's own
`tags[]` shape uses `driverDataType`).
2. A matching `Tags[]` entry naming the same DataItem id.
3. The Agent's own `/probe` declaration, via `MTConnectDataTypeInference.Infer`
(category/type/units/representation → `DriverDataType`).
4. `DriverDataType.String` — the coercion that cannot fail.
### Quality mapping
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
**`BadNoCommunication`** (`0x80310000`). This is deliberately distinct from
the fleet-standard `BadCommunicationError` (`0x80050000`, used elsewhere for
the driver's *own* transport failure to the Agent): `UNAVAILABLE` means the
Agent is reachable and answered, it just has no device-backed value for that
item. A `CONDITION` observation's value is taken from its **element name**
(`<Normal/>` → the string `"Normal"`, `<Fault/>``"Fault"`); a `<Unavailable/>`
condition element normalises onto the same `UNAVAILABLE` sentinel as every
other category so one comparison covers all three.
Other status codes an observation can surface:
| Code | Meaning |
|---|---|
| `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 |
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `TIME_SERIES` vector, or a structured `DATA_SET`/`TABLE` observation (deferred to P1.5; see [Known limitations](#known-limitations)) |
| `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet |
| `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed |
### Subscribe — the `/sample` pump
One shared `/sample` long-poll stream per driver instance (the Agent streams
the whole device model regardless of which subset is subscribed, so
per-tag streams would be wasted round-trips), run under a heartbeat
watchdog — `HttpClient.Timeout` cannot bound a long-lived stream, so a
missing chunk **and** missing keep-alive heartbeat within
`HeartbeatMs × N` is what detects a frozen peer.
Ring-buffer overflow (the Agent's circular observation buffer wrapped past
what the driver's cursor expects) is detected **two ways**, both triggering a
`/current` re-baseline before the stream resumes:
1. `IMTConnectAgentClient.IsSequenceGap` — the next chunk's `firstSequence`
is newer than the driver's expected cursor.
2. An `MTConnectError` document reporting `OUT_OF_RANGE` — real Agents return
this both under HTTP 200 (a normal MTConnect error document) and as a bare
HTTP 400, and the client handles both.
**An Agent `instanceId` change means the Agent restarted**, and is checked
**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
`instanceId` the driver clears its cached probe model and raises
`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why
that signal currently has no consumer.
## Browse — free via the universal browser
MTConnect ships **no bespoke browser project**. Setting
`ITagDiscovery.SupportsOnlineDiscovery => true` is the entire integration:
the Wave-0 `DiscoveryDriverBrowser` sees a driver whose `TryCreate` succeeds
and whose instance reports online discovery, renders the AdminUI **Browse**
button, constructs the driver, runs `InitializeAsync` (the `/probe` connect)
+ `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the
throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
committed directly as `TagConfig.FullName` on pick. See
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
## CONDITION modelling (v1: plain String)
Each `CONDITION` DataItem materializes as a `String` variable whose value is
the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`.
There is no native OPC UA Part 9 alarm plumbing in this build;
`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so
the browse side-panel flags it and a future upgrade to native alarms doesn't
need re-authoring. See [Deferred](#deferred-not-in-this-build).
## Known limitations
These are real, not placeholders — read them before relying on the driver
for anything beyond values-and-conditions.
1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the
server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and
`IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to
`OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver`
wiring its own internal `DeployWatcher` sub-component. So a restarted
Agent (a changed `instanceId`) leaves a stale address space behind an
otherwise-Healthy driver. **This is a pre-existing fleet-wide gap
affecting every driver that implements either interface** (eight drivers
besides Galaxy), not something specific to MTConnect — this build simply
surfaced it again.
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
routing.** One driver instance owns exactly one Agent client scoped by
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
never read by the driver. Two `/raw` Devices authored under one MTConnect
driver instance both resolve to the same one Agent connection. Real
per-device routing (a client per device) would be a design change, not a
bug fix.
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
blob's coercion type.** The `/probe`-sourced inference
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
type, but it doesn't close the gap — an authored `DataType` mismatched
against the actual Agent observation still surfaces as `BadTypeMismatch`
/`BadOutOfRange` at runtime rather than at authoring time.
4. **`sampleCount` is not a legal `DataItem` attribute.** It belongs on a
`TIME_SERIES` *observation*, not the device-model declaration. A real
Agent that meets `sampleCount` on a `DataItem` declaration logs `The
following keys were present and not expected: sampleCount` and **drops
the entire data item** from the served model. A live TIME_SERIES tag
therefore always resolves to `ArrayDim = null` (variable-length array) in
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
declare a `sampleCount`, is unrealistic on this point and exists only to
pin the parsing rule itself.
5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above).
6. **`TIME_SERIES` arrays and structured `DATA_SET`/`TABLE` observations
surface as `BadNotSupported`**, not an approximation — see the Quality
mapping table.
## Deferred (not in this build)
- **Write-back (MTConnect Interfaces).** Design
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
the mainstream Agent surface is read-only by design; Interfaces is a rare,
optional request/response handshake that would mislead if modelled as
`IWritable`. Revisit only on a concrete deployment need.
- **P1.5 fast-follow** (design
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
native-alarm pattern); `TIME_SERIES` SAMPLE arrays materialized as real
OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations.
- **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
`SupportsOnlineDiscovery` would have to report `false` and tags would be
authored by hand, like Modbus). Niche; not built.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
(canned-XML fixtures, no network) — 491/491 at time of writing. Covers
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
`UNAVAILABLE``BadNoCommunication`, CONDITION state-word mapping,
multipart chunk framing, and ring-buffer re-baseline paging (both the
sequence-gap and the `OUT_OF_RANGE` legs).
- **Integration tests** —
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
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
Agent. Read the fixture's own
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
for the full recipe — summary only, here:
- Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which
does not exist on Docker Hub (the design's original assumption).
- The stack is **two services**: the Agent plus a stdlib-only SHDR adapter
(`Docker/adapter.py`, on `python:3.13-alpine`). An Agent with no adapter
reports every observation `UNAVAILABLE` forever, which proves nothing.
- Deployed at `/opt/otopcua-mtconnect/` on the shared docker host
(`10.100.0.35`, `project=lmxopcua` label). Endpoint
`http://10.100.0.35:5000`.
- `agent.cfg` must be **pure ASCII** — one non-ASCII byte anywhere,
including a comment, makes the config parser reject the whole file with
a bare `Failed / Stopped at line: N`.
- Bring-up: `lmxopcua-fix sync mtconnect` then `lmxopcua-fix up mtconnect`
(PowerShell helper, Windows-only) — or, directly on the docker host,
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
`docker compose up -d --wait`.
## Further reading
- [`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md) — full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasing
- [`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md) — the executable implementation plan and the task-by-task build record, including where it diverged from the design (TrakHound → hand-rolled)
- [`docs/plans/2026-07-24-driver-expansion-tracking.md`](../plans/2026-07-24-driver-expansion-tracking.md) — Wave-2 program tracking
- [Docker fixture README](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)
+6
View File
@@ -30,6 +30,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange``IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the sole historian backend. Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
## Per-driver documentation
@@ -50,6 +51,10 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
- [Mqtt.md](Mqtt.md) — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding, `#`-observation browser, CONNACK-rejection semantics, **Sparkplug B** (birth/alias/seq/rebirth state machine, birth-driven browse picker, death→STALE), and the **Known gaps** table
- **MTConnect** has a short getting-started doc because the hand-rolled-vs-TrakHound divergence, the
data-plane precedence rules, and a non-trivial known-limitations list need explaining up front:
- [MTConnect.md](MTConnect.md) — Agent config, `FullName`==DataItem `id`, `UNAVAILABLE``BadNoCommunication`, CONDITION-as-`String`, browse-via-universal-browser, fixture recipe, deferred write-back
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
@@ -68,6 +73,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
- [MTConnect](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — Dockerized `mtconnect/agent` + stdlib SHDR adapter (two services, the Agent alone reports everything `UNAVAILABLE`); 12/12 against the real Agent, canned XML unit fixtures cover the bulk (491/491)
## Related cross-driver docs
@@ -32,7 +32,7 @@ it reaches 📝.
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | **COMPLETE** — P1 Agent MVP, all 23 tasks + the 5-leg live gate PASSED (branch `feat/mtconnect-driver`, PR #506) | SM | Dockerized `mtconnect/agent:2.7.0.12` (not `cppagent`) — CI-simulatable, live at `10.100.0.35:5000` |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | ✅ **COMPLETE** — P1 + P2 shipped, both live-gated (Tasks 026) | ML | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, live at `10.100.0.35:8883` |
> Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8,
@@ -134,15 +134,36 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
Both CI-simulatable on the shared docker host, no hardware.
### MTConnect Agent — 📝 Plan ready
### MTConnect Agent — ✅ Done (P1 Agent MVP)
- **Design:** [`2026-07-15-mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks**. Task 0 is the TrakHound-vs-hand-rolled client decision; browse-picker live-verify is gated on Wave-0 #468.
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
- **Effort:** SM (≈11.5 wk with TrakHound, ≈2.53 wk hand-rolled).
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks; 22 built, Task 21 (live `/run` gate) tracked separately in the plan file.**
- **Driver guide:** [`docs/drivers/MTConnect.md`](../drivers/MTConnect.md) — config keys, capability
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
limitations** section.
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
(both the sequence-gap and `OUT_OF_RANGE` legs).
- **Diverged from the design:** Task 0/6/7 dropped the planned TrakHound `MTConnect.NET-Common`/`-HTTP`
dependency entirely — the pinned packages ship no XML formatter and the HTTP stream client has no
injectable handler to test behind the driver's seam. Parsing is hand-rolled `System.Xml.Linq`,
matching on `LocalName` only (namespace-agnostic, so 1.32.x all parse). See the driver guide's
"Built vs. planned" section.
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
drivers besides Galaxy), and no driver form blocks Save on a required-field validation error.
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/agent` +
stdlib-SHDR-adapter integration fixture (env-gated), deployed at `/opt/otopcua-mtconnect/` on the
shared docker host. See the fixture's own
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
- **Effort:** SM, hand-rolled (the TrakHound estimate in the design doc did not apply once that
path was ruled out).
### MQTT / Sparkplug B — ✅ **COMPLETE** (P1 + P2, both live-gated)
- **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
@@ -219,12 +240,12 @@ Both CI-simulatable on the shared docker host, no hardware.
## Next actions
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for BACnet (MTConnect
shipped its own Task-21 live `/run` gate independently — see the plan file).
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
table (subagent-driven-development in a git worktree).
3. **Build Wave 2** — MTConnect's browse leg wants #468 closed first. **MQTT / Sparkplug B is
COMPLETE** (P1 + P2, both live-gated); its only remaining work is optional — P3 write-through
(`IWritable`: NCMD/DCMD + plain publish) and the two browse-UI gaps recorded above.
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
Update this file's Summary table and per-wave status whenever a deliverable changes state.
+317
View File
@@ -48,6 +48,102 @@ git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
```
**DECISION (verified 2026-07-24): TrakHound path.** All three checks passed against real nuget.org.
(1) `dotnet package search MTConnect.NET-Common --exact-match` and `...-HTTP --exact-match` both list
versions `3.2.0` through `6.9.0.2` on `nuget.org`, confirming `6.9.0.2` is the current top-of-list
version for both package ids (no version mismatch between the two). (2) A throwaway `net10.0` console
project in the scratchpad (outside the repo, so unaffected by this repo's `packageSourceMapping`) ran
`dotnet add package MTConnect.NET-Common -v 6.9.0.2` + `...-HTTP -v 6.9.0.2` + `dotnet restore` — both
restored cleanly with no source-mapping or compatibility errors; `project.assets.json` resolved
`MTConnect.NET-Common`, `MTConnect.NET-HTTP`, and the transitive `MTConnect.NET-TLS` (all `6.9.0.2`)
against the `net10.0` target framework, and each package's `lib/` folder contains a `netstandard2.0`
build (alongside `net6.0``net9.0`, `net461``net472`, `net47`, `net48`), so net10.0 resolves via the
in-box compatibility mapping. (3) The pinned version carries **no embedded LICENSE file** in the
`.nupkg` (`unzip -l` shows no `LICENSE*` entry in either package) — instead both nuspecs declare the
modern SPDX license-expression form `<license type="expression">MIT</license>` +
`<licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>`, which nuget.org validates against the SPDX
list at push time and surfaces on the package page (confirmed live: the nuget.org page for
`MTConnect.NET-Common` `6.9.0.2` displays "MIT license" linked to `licenses.nuget.org/MIT`) — a
structurally stronger guarantee than a free-text embedded file for this specific version, so the
absence of a physical `LICENSE` file is not a fail. Task 1 adds
`PackageReference MTConnect.NET-Common 6.9.0.2` + `PackageReference MTConnect.NET-HTTP 6.9.0.2` to
`Driver.MTConnect.csproj`; the hand-roll fallback is not needed.
**CORRECTION (Task 6, commit `bdfefadc`) — the decision above rested on incomplete evidence, and the
hand-roll fallback IS in use for parsing.** Task 0 verified the packages *exist, restore, and are MIT*;
it never verified they can actually parse an MTConnect document. As pinned, they cannot. Task 6 proved
this empirically before writing any parser code: reflection over both referenced assemblies
(`MTConnect.NET-Common`, 1297 types; `-HTTP`, 410 types) found **zero `IResponseDocumentFormatter`
implementations and zero public static `FromXml`/parse entry points**, and a live invocation of
TrakHound's own entry point (`ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)`)
against this repo's `Fixtures/probe.xml`, with both DLLs loaded, returned:
```
Success = False
ERROR: Document Formatter Not found for "xml"
```
TrakHound 6.x resolves formatters from loaded assemblies at runtime, and the XML formatter ships in a
**third package, `MTConnect.NET-XML`**, which Task 0 did not evaluate. Even with it added, TrakHound
exposes no socket-free `Parse(string)` entry point of the shape this plan mandates. So `/probe` parsing
is a hand-rolled `System.Xml.Linq` walk (~250 LoC, `MTConnectProbeParser`); the driver above the
`IMTConnectAgentClient` seam is unaffected, exactly as the decision rule anticipated.
**The two `PackageReference`s remain and are, so far, dead weight.** Task 6 deliberately did not remove
them: the hard remaining problem is Task 7's `multipart/x-mixed-replace` chunk framing for `/sample`,
and `MTConnect.Clients.MTConnectHttpClientStream` in `-HTTP` may be usable for framing alone. **Task 7
owns the final call:** (a) use `-HTTP` for framing only, (b) add `MTConnect.NET-XML` and reconsider
wholesale, or (c) hand-roll the boundary reader and **drop both package references** — in which case the
Task-0 rationale comment in `Directory.Packages.props` goes with them. Record the outcome here.
**OUTCOME (Task 7) — option (c): hand-rolled boundary reader, BOTH package references DROPPED.**
`MTConnect.NET-Common`, `MTConnect.NET-HTTP` and the transitive `MTConnect.NET-TLS` are gone from
`Directory.Packages.props` and from `Driver.MTConnect.csproj`; the Task-0 rationale comments went with
them (each replaced by a comment naming this block). **The driver now depends on nothing but the BCL**
`HttpClient` + `System.Xml.Linq`. Reflection over `MTConnect.NET-HTTP` 6.9.0.2 (net9.0 lib, with
`-Common` resolved alongside) settled the one open candidate:
```
TYPE MTConnect.Clients.MTConnectHttpClientStream
.ctor(String url, String documentFormat)
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
```
Three independent disqualifiers, any one of which is fatal:
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 613 are unit-testable
against canned XML with no listener; adopting this type would have forced a live agent (or a real
loopback HTTP server) into the unit suite.
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
watchdog on its body.
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
a second: **check the constructor for a test seam before counting a library as usable** — a type that
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
---
## Task 1: Scaffold the two driver projects + the test project
@@ -289,6 +385,57 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "Ful
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
```
### Task 7 review remediation (post-`c46540ae`) — three contract changes Tasks 9/11/13 must build against
Code review of `c46540ae` approved the framing algorithm but moved the risk onto the **contract**. Three
changes here are breaking relative to the sketch above; the rest are hardening.
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
yielding "indefinitely, until `ct` is cancelled", but the implementation returned normally on EOF, on
the closing `--boundary--`, and — worst — on a response that was not multipart at all, where it
yielded one parsed snapshot and finished. A pump written to the documented contract has no reason to
handle normal completion, so it would silently drop its subscription or hot-loop reconnecting; the
non-multipart leg additionally reported a **configuration error as a healthy finished stream**, which
is the #485 quiet-successful-termination shape aimed straight at Task 11. Every non-cancellation end
now throws: `MTConnectStreamEndedException` (with `MTConnectStreamEndReason.ConnectionClosed` /
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
**not** yielded.
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
alone does not cover it. Documented on the seam.
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
nonsense (`"12"` for two entries) which the index would otherwise publish as Good, and only the
parser can tell that apart from a legitimate space-bearing `Message` — no value-shape heuristic
works. Deliberately **false for CONDITION** observations: their value comes from the element *name*,
so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
Hardening in the same pass: disposal mid-enumeration now surfaces as `OperationCanceledException`
(via an internal dispose-linked token) rather than a raw `ObjectDisposedException`/`IOException` that
Task 9's re-init would otherwise inflict on an enumerating pump; `HeartbeatMs`, `SampleIntervalMs` and
`SampleCount` join `RequestTimeoutMs` in construction-time positive-value validation (all three reach
the query string, and an Agent answers `heartbeat=0` with an HTTP-200 `MTConnectError` that would name
no config key); a `multipart/*` response with no `boundary` parameter fails fast instead of degrading
into a watchdog timeout; the no-`Content-length` framing fallback logs a one-shot warning (the client
now takes an optional `ILogger`); and the shared element/attribute reading rules moved into
`MTConnectXml`, used by both parsers.
**Coverage gap closed, and worth remembering as a pattern.** Every multipart test served the whole body
from one buffer, so **every framing test completed in a single `ReadAsync`** — the split-boundary path,
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
headline risk is framing. A `ChunkedStream` double returning N bytes per read (N = 1, 3, 7) now drives
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
split-boundary overlap, removing the split-header overlap, and collapsing `EnsureAsync`'s fill loop each
fail **only** the new chunked tests — every pre-existing multipart test stays green under all three.
---
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
@@ -747,6 +894,176 @@ git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/sub
---
### LIVE-GATE RESULT (2026-07-24, completed 2026-07-27) — ALL 5 LEGS PASSED
**Rig.** The shared `otopcua-dev` rig was NOT used: three sibling driver worktrees
(`modbus-rtu`, `mqtt-sparkplug`, `sql-poll-driver`) share its `otopcua-host:dev` image, and it had
been up for hours. Instead an isolated stack was built from this worktree —
`otopcua-host:mtconnect`, compose project `otopcua-mtc`, overlay
`docker-dev/docker-compose.mtconnect.yml`, MAIN pair only, ports AdminUI **9220** / OPC UA **4860**
/ SQL **14340**. Mirrors how the mqtt worktree isolated itself. The `otopcua-dev` rig and every
sibling worktree were left untouched.
**Fixture.** `mtconnect/agent:2.7.0.12` + the SHDR adapter deployed to
`/opt/otopcua-mtconnect/` on the shared docker host and brought up healthy; `http://10.100.0.35:5000`
serves the seeded `OtFixtureCnc` model with live-moving values. Note it answers in the MTConnect
**2.0** namespace while the canned fixtures are 1.3 — the namespace-agnostic `LocalName` parsing
(Task 6) is what makes both work, now demonstrated rather than argued.
| Leg | Result | Evidence |
|---|---|---|
| Integration suite vs. a REAL Agent | ✅ **12/12** | incl. `Discovery_types_an_upper_snake_numeric_event_as_Int64` (the `PART_COUNT` regression), `Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags` (the blocker fix), `Subscribe_delivers_a_changed_value_from_the_live_sample_stream`, `Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs` |
| 1. Driver creatable in `/raw` | ✅ | `MTConnect` present in `RawDriverTypeDialog`; `mtc-1` created. Proves the Host `Register` call is genuinely wired — **the one thing no test guards** (deleting it leaves the guard test green and substitutes a `StubbedDriver` while the deploy still seals green). |
| 2. Typed config modal renders | ✅ | "Configure driver · MTConnect" with all four sections (Agent / Polling / Connectivity probe / Reconnect), **not** the "no typed config form" banner. **This is the mutation that survived all 749 AdminUI tests** — the `switch` in `DriverConfigModal.razor` compiles to `BuildRenderTree` and is unreachable by reflection. |
| 3. Config persists + round-trips | ✅ | Stored `{"agentUri":"http://10.100.0.35:5000","requestTimeoutMs":5000,…,"probe":{"enabled":true,…},"reconnect":{…}}`; reopening the modal displays the saved URI (Razor binding verified — no bUnit in this repo). |
| 4. Browse picker → commit | ✅ | "Connect & browse" dialled the live Agent; tree rendered `Agent` + `OtFixtureCnc` with `Favail` (name ≠ id) beside id-named leaves, proving `browseName = Name ?? Id`. Committed TagConfig is **`{"fullName":"fixture_asset_changed","dataType":"String"}`** — the `fullName`-not-`address` fix, live. Wave-0 #468 did **not** block this. |
| 5. Deploy → OPC UA read/subscribe | ✅ **PASSED (2026-07-27, post-rebase)** | see "Leg 5" below |
---
#### Leg 5 — PASSED 2026-07-27, on the rebased branch
Re-run after the rebase onto master (`123ddc3f`), against a **freshly rebuilt** `otopcua-host:mtconnect`
image, so this gate exercises the merged code and not the pre-rebase tree.
**The cookie blocker was sidestepped, not worked around.** Rather than moving hosts or stopping the
`otopcua-dev` rig, the deploy went through the **headless deploy API**
`POST /api/deployments` with `X-Api-Key` (`DeployApiEndpoints`, `Security:DeployApiKey`). It is
`AllowAnonymous().DisableAntiforgery()` by design ("machine endpoint, not a browser form post"), so
the antiforgery cookie collision is structurally irrelevant to it. This is the better recipe for any
future isolated-stack gate — it needs no browser at all:
```bash
curl -s -X POST http://localhost:9220/api/deployments \
-H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" \
-d '{"CreatedBy":"mtconnect-live-gate"}'
# {"outcome":"Accepted","deploymentId":"…","revisionHash":"…"} HTTP 202
```
Both deployments sealed `Status = 2` (`DeploymentStatus.Sealed`) with `FailureReason = NULL` — i.e.
**both** MAIN nodes acked. No `MaintenanceMode` hatch was needed: the isolated stack's topology is
MAIN-only with both central nodes enabled and running.
**Tag set.** Leg 4's AdminUI-committed tag (`fixture_asset_changed`) is an `AssetChanged` EVENT that
never moves, so three live-changing tags were added **directly via SQL** (deliberately bypassing the
typed editor — see the finding below) to exercise all three type paths.
**Results — read (`Client.CLI read`, `opc.tcp://localhost:4860`):**
| NodeId (`ns=2`, Raw realm) | Value | Status |
|---|---|---|
| `s=mtc-1/vmc/fixture_partcount` | `48099` (Int64) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_x_pos` | `96.4416` (Float64) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_execution` | `ACTIVE` (String) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_asset_changed` | *(null)* | `0x80000000` — see finding 6 |
The RawPath is `mtc-1/vmc/<dataItemId>` (driver/device/tag; no folder prefix), confirmed by
`browse -r`, which rendered all four as `[Variable]` under the `vmc` device object.
**Results — subscribe (`Client.CLI subscribe -r` over the whole device, 500 ms):** all four monitored;
live updates flowed continuously from the `/sample` long-poll pump through to OPC UA — `fixture_x_pos`
tracing its sinusoid (`103.66 → 123.18 → 141.90 → 155.27 → 159.99 → 154.93 → 141.32 → 122.48 → 103.03
→ 87.75 → 80.35`), `fixture_partcount` incrementing monotonically (`48128 → 48129 → 48130`), and
`fixture_execution` transitioning `READY → INTERRUPTED`. **This closes the last unproven link**: the
driver seam was already covered by the live integration suite, and leg 5 adds `DeploymentArtifact`
address-space materialisation → OPC UA publish on top of it.
**Additional findings from leg 5:**
5. **A bad `dataType` degrades to exactly one skipped tag, loudly — verified by accident.** The
hand-written SQL used `"dataType":"Double"`; the vocabulary is `DriverDataType`, whose member is
**`Float64`**. The driver logged, per tag:
> `could not map the raw tag mtc-1/vmc/fixture_x_pos to an Agent DataItem; it names no readable id
> (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The
> tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.`
…and the other three tags kept streaming. That is the intended fail-isolated behaviour,
demonstrated live rather than argued. Correcting the row to `Float64` and redeploying turned the
tag Good with **no container restart** — which incidentally shows **MTConnect is not subject to the
separately-tracked config-edits-silently-discarded defect** that affects Modbus/FOCAS/OpcUaClient.
Note also that the typed AdminUI editor would have prevented the mistake outright (it offers a
`DriverDataType` dropdown); the error was an artifact of authoring straight into SQL.
6. **`fixture_asset_changed` reading `0x80000000` is NOT an MTConnect defect — it is a pre-existing,
deliberate fidelity gap on the shared publish path.** The agent does return the tag in `/current`
as `<AssetChanged …>UNAVAILABLE</AssetChanged>`, and the driver maps it correctly to
`BadNoCommunication` (`0x80310000`, `MTConnectObservationIndex.cs:255`). The node nevertheless
reports generic `Bad` (`0x80000000`) — while carrying the agent's exact source timestamp, which
proves the publish landed and only the status differs. Cause, verified by reading the source:
`DriverInstanceActor.QualityFromStatus` (`DriverInstanceActor.cs:1032-1041`) projects the driver's
`uint` onto the 3-state `OpcUaQuality` enum using only the top two severity bits (`statusCode >> 30`),
and `OtOpcUaNodeManager.StatusFromQuality` (`OtOpcUaNodeManager.cs:3318-3322`) re-expands `Bad` to
`StatusCodes.Bad`. It is intentional — `OpcUaQuality`'s own doc comment says *"Real SDK has
finer-grained codes; the engine actors only need this 3-state classification."*
The consequence appears undocumented and is client-visible: **no driver's Bad/Uncertain sub-code
ever reaches an OPC UA client.** Issue #497's 16 corrected constants and the new
`StatusCodeParityTests` guard keep the constants internally right, but a client cannot tell
`BadNoCommunication` from `BadTypeMismatch` from `BadNotSupported`. (The one survivor is
`BadWaitingForInitialData`, written directly at the node at `OtOpcUaNodeManager.cs:1806/1901`,
bypassing the projection.) Tracked separately; out of scope for this plan.
---
**Historical — why leg 5 was blocked on 2026-07-24 (superseded by the API-key recipe above).**
Browser cookies are scoped to a
**host, not a port**, so the AdminUI antiforgery cookie issued by the still-running `otopcua-dev`
rig on `localhost:9200` is sent to the isolated stack on `localhost:9220`, whose data-protection
keys differ:
```
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.
```
The `Deploy current configuration` POST is rejected before reaching any MTConnect code — no
`Deployment` row is created, and the UI surfaces nothing. Clearing JS-visible cookies does not help
(the antiforgery cookie is `HttpOnly`). This is a two-stacks-on-one-host collision, entirely outside
the driver.
The three browser-side remedies originally listed (distinct host / incognito profile / stop the
`otopcua-dev` rig) all remain valid, but none was needed — the headless deploy API above avoids the
browser entirely and is the recommended recipe.
**Incidental findings from the rig work:**
1. ~~`ClusterNode` has no `MaintenanceMode` column, so CLAUDE.md is ahead of the migration.~~
**CORRECTED — this was my error, not a repo inconsistency.** The migration
`20260722125506_AddClusterNodeMaintenanceMode` exists on master, along with `AkkaPort`/`GrpcPort`.
**This branch is 39 commits behind master** (base `963eec1b`, master `28c28667`) and simply
predates them, so the isolated stack's schema lacked the column and I reduced the seeded topology
by deleting the SITE-A/SITE-B rows instead. CLAUDE.md is accurate. **DONE 2026-07-27 — rebased**
onto master `123ddc3f` (67 commits by then, not 39). Conflicts were all of one shape — this driver
and the newly-merged SQL-poll driver each adding their entry to the same list — resolved by keeping
**both** in: `ZB.MOM.WW.OtOpcUa.slnx`, `DriverTypeNames.cs`, `DriverFactoryBootstrap.cs`,
`Core.Abstractions.Tests.csproj`, `TagConfigEditorMap.cs`, `TagConfigValidator.cs`,
`DriverConfigModal.razor`, `RawDriverTypeDialog.razor`. Post-rebase: solution build 0 errors,
MTConnect 491/491, Core.Abstractions 256/256, AdminUI 800/800. For a future partial-topology gate,
`MaintenanceMode = 1` is now available and is the correct hatch (it is what the SQL-poll driver's
gate used), in place of the row deletion I resorted to.
2. `sqlcmd` against this schema needs `-I` (`SET QUOTED_IDENTIFIER ON`); without it every DML
statement fails with `Msg 1934` because of the filtered/computed indexes.
3. **Status-code parity is pre-verified against master's new guard.** Master gained
`StatusCodeParityTests`, which reflects over every `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in its bin for
status-shaped `const uint` fields — including `private const` on `internal` types — and checks each
against `Opc.Ua.StatusCodes`. Task 16 added the `Driver.MTConnect` ProjectReference to that same
test project, so this driver's constants are in scope. All eight were verified against the pinned
SDK assembly directly: `BadCommunicationError 0x80050000`, `BadNoCommunication 0x80310000`,
`BadNodeIdUnknown 0x80340000`, `BadNotConnected 0x808A0000`, `BadNotSupported 0x803D0000`,
`BadOutOfRange 0x803C0000`, `BadTypeMismatch 0x80740000`, `BadWaitingForInitialData 0x80320000`
**0 mismatches**. Note the guard only sees *named* constants; an inline status literal at a call
site is invisible to it, so keep hoisting them. **Confirmed after the rebase**: the guard reports
9 MTConnect constants in scope (the 8 above plus `Good`), all passing — the pre-verification held.
4. **The separately-tracked `BadTypeMismatch` defect in FOCAS/TwinCAT/AbLegacy/AbCip is already fixed
on master** (issue #497 grew it to 16 wrong constants across 6 drivers). This driver independently
chose the correct `0x80740000` and pinned it with a mutation test, so the two agree; no action.
**Teardown when done:**
```bash
docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect.yml down -v
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mtconnect && docker compose down'
```
---
## Task 22: Docs + deferred-writeback note; update the tracking doc
**Classification:** trivial
+10 -2
View File
@@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{
| Stack dir | Purpose |
|---|---|
| `/opt/otopcua-mssql` | central SQL (always-on) |
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` | driver fixtures |
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` | driver fixtures |
| `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) |
| `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 |
@@ -71,10 +71,11 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
| FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) |
| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<pw> docker compose up -d` (stack `/opt/otopcua-mqtt`) |
| MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth |
| MTConnect | `mtconnect/agent:2.7.0.12` + `python:3.13-alpine` adapter | `http://10.100.0.35:5000` | `docker compose up -d --wait` |
**Endpoint overrides** point a suite at a real PLC instead of the sim:
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` ·
`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT`.
`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT`.
> **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous
> fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass),
@@ -84,6 +85,13 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
> copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`.
> It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md).
> **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone
> Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service
> (a stdlib SHDR feeder, `Docker/adapter.py`) is the data source; the Agent dials out to it. Its
> suite also probes over **HTTP**, not TCP — an Agent's port accepts connections before its device
> model is parsed. Full detail + the seeded device model:
> [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
> **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down`
> may leave a stale container bound. Force-remove before switching profiles:
> `docker rm -f $(docker ps -aq --filter name=otopcua-<driver>)` then `up --force-recreate`.
@@ -58,6 +58,9 @@ public static class DriverTypeNames
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
public const string Mqtt = "Mqtt";
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
public const string MTConnect = "MTConnect";
/// <summary>
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored <c>DriverType</c>).
@@ -75,5 +78,6 @@ public static class DriverTypeNames
Calculation,
Sql,
Mqtt,
MTConnect,
];
}
@@ -0,0 +1,266 @@
using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
/// <see cref="DriverDataType" /> plus the array shape that
/// <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
/// expect. Returned by value (a readonly record struct) so the discovery loop and the
/// AdminUI editor can call the inference on a hot path without allocating.
/// </summary>
/// <param name="DataType">The scalar element type of the observation.</param>
/// <param name="IsArray">
/// <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
/// observation carries a vector of samples rather than a single value.
/// </param>
/// <param name="ArrayDim">
/// The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
/// model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
/// (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
/// </param>
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
/// <summary>
/// Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
/// representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
/// no I/O, no logging, no mutable state.
/// </summary>
/// <remarks>
/// <para>
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
/// AdminUI typed tag editor (shows the inferred type and offers an override).
/// </para>
/// <para>
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
/// defines hundreds of <c>type</c> values with more added each revision. This is therefore a
/// <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
/// The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
/// fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
/// always round-trips and the author can retype it. So every default leans to
/// <c>String</c> except where the standard itself guarantees a number.
/// </para>
/// <para>
/// <b>Defaulting rule, per category</b> (design §3.3):
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
/// unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
/// category as a continuously-varying measured quantity, so the category alone is
/// the guarantee — a missing <c>units</c> attribute is a gap in the device model,
/// not evidence that the observation is text. <c>Float64</c> rather than an integer
/// type because a double parse accepts both <c>3</c> and <c>3.5</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
/// exception list (<see cref="NumericEventTypes" />) of standard types that are
/// defined as integers. The overwhelming majority of EVENT types are controlled
/// vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
/// text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
/// deliberately short — each entry is a coercion risk, while every omission is
/// merely a string the author can retype.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
/// is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
/// <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
/// Probe XML in the wild is inconsistent; with no category we have no evidence the
/// observation is numeric, so we pick the type that cannot fail to parse.
/// </description>
/// </item>
/// </list>
/// <para>
/// Representation overrides the category where the two disagree about shape:
/// <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
/// <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
/// defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
/// </para>
/// <para>
/// <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
/// <c>_</c>, <c>-</c> and whitespace are ignored wherever they appear, so
/// <c>PART_COUNT</c> ≡ <c>PartCount</c> ≡ <c>part-count</c> ≡ <c>partcount</c>. This is not
/// mere leniency: <b>MTConnect spells the same concept two different ways in two different
/// documents</b>. The Devices document (<c>/probe</c>) writes <c>DataItem@type</c> in
/// UPPER_SNAKE (<c>PART_COUNT</c>, <c>POSITION</c>, <c>PATH_FEEDRATE</c>), while the Streams
/// documents (<c>/current</c>, <c>/sample</c>) name the observation element in PascalCase
/// (<c>PartCount</c>, <c>Position</c>). Discovery reads the <i>probe</i> spelling, and plain
/// case-insensitivity does not bridge the underscore — so matching only the PascalCase
/// spelling types every real agent's <c>PART_COUNT</c> as <see cref="DriverDataType.String" />
/// while every unit test using the sketch's PascalCase spelling stays green.
/// </para>
/// </remarks>
public static class MTConnectDataTypeInference
{
private const string CategorySample = "SAMPLE";
private const string CategoryEvent = "EVENT";
private const string RepresentationTimeSeries = "TIME_SERIES";
private const string RepresentationDataSet = "DATA_SET";
private const string RepresentationTable = "TABLE";
/// <summary>
/// The <c>EVENT</c> types the standard defines as integers — the exception list to the
/// "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
/// every agent reports it as a parseable integer, and being wrong costs Bad quality.
/// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
/// both are mapped so a current-version agent is not silently mistyped.
/// <para>
/// The entries are written in the Streams (PascalCase) spelling, but the set's comparer
/// is <see cref="SeparatorInsensitiveComparer" />, so the probe document's
/// <c>PART_COUNT</c> / <c>LINE_NUMBER</c> hit the same entries. Adding a spelling variant
/// here would be redundant, not additional coverage.
/// </para>
/// </summary>
private static readonly FrozenSet<string> NumericEventTypes =
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
/// <summary>
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
/// metadata. See the type-level remarks for the full rule and its rationale.
/// </summary>
/// <param name="category">
/// The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
/// Case-insensitive; an unknown, blank, or <c>null</c> value yields
/// <see cref="DriverDataType.String" />.
/// </param>
/// <param name="type">
/// The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
/// <c>PART_COUNT</c> / <c>POSITION</c> or the streams' <c>PartCount</c> / <c>Position</c>.
/// Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
/// or <c>null</c> value falls back to the category default.
/// </param>
/// <param name="units">
/// The declared engineering units, if any. Accepted for signature stability and because every
/// call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
/// definition of the category, so units add no discriminating power there, and treating a
/// units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
/// </param>
/// <param name="representation">
/// The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
/// <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
/// </param>
/// <param name="sampleCount">
/// The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
/// <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
/// value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
/// </param>
/// <returns>The inferred scalar type plus array shape.</returns>
public static MTConnectInferredType Infer(
string? category,
string? type = null,
string? units = null,
string? representation = null,
int? sampleCount = null)
{
_ = units; // See the parameter doc: intentionally not load-bearing in v1.
var trimmedCategory = category.AsSpan().Trim();
var trimmedRepresentation = representation.AsSpan().Trim();
// A key-value observation is never a number, whatever the category claims. Checked first so
// a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
if (Matches(trimmedCategory, CategorySample))
{
// TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
if (Matches(trimmedRepresentation, RepresentationTimeSeries))
return new MTConnectInferredType(
DriverDataType.Float64,
IsArray: true,
ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);
return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
}
if (Matches(trimmedCategory, CategoryEvent))
{
// No Trim() here: the set's comparer already ignores whitespace and separators
// wherever they appear, so the raw attribute value is looked up allocation-free.
var dataType = type is { Length: > 0 } && NumericEventTypes.Contains(type)
? DriverDataType.Int64
: DriverDataType.String;
return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
}
// CONDITION (a state word) and every unknown / blank / missing category.
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
}
private static bool Matches(ReadOnlySpan<char> value, string expected)
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Ordinal-ignore-case equality that additionally skips <c>_</c>, <c>-</c> and whitespace
/// wherever they occur, so the probe document's <c>PART_COUNT</c> and the streams
/// document's <c>PartCount</c> are one key. Backs <see cref="NumericEventTypes" />.
/// </summary>
/// <remarks>
/// Comparing through a comparer rather than normalising the input keeps the lookup
/// allocation-free on the discovery hot path: no <c>string.Replace</c>, no
/// <c>Trim()</c>, no temporary buffer — the raw attribute value from the parser is passed
/// straight to <see cref="FrozenSet{T}.Contains" />.
/// </remarks>
private sealed class SeparatorInsensitiveComparer : IEqualityComparer<string>
{
/// <summary>The single shared instance; the comparer is stateless.</summary>
internal static readonly SeparatorInsensitiveComparer Instance = new();
private SeparatorInsensitiveComparer() { }
/// <inheritdoc />
public bool Equals(string? x, string? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
int i = 0, j = 0;
while (true)
{
while (i < x.Length && IsSeparator(x[i])) i++;
while (j < y.Length && IsSeparator(y[j])) j++;
if (i == x.Length || j == y.Length) return i == x.Length && j == y.Length;
if (char.ToUpperInvariant(x[i]) != char.ToUpperInvariant(y[j])) return false;
i++;
j++;
}
}
/// <inheritdoc />
public int GetHashCode(string obj)
{
// Must hash exactly the characters Equals compares, or two equal spellings land in
// different buckets and the set silently misses.
var hash = new HashCode();
foreach (var c in obj)
{
if (IsSeparator(c)) continue;
hash.Add(char.ToUpperInvariant(c));
}
return hash.ToHashCode();
}
private static bool IsSeparator(char c) => c is '_' or '-' || char.IsWhiteSpace(c);
}
}
@@ -0,0 +1,134 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// MTConnect Agent driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. The driver polls a remote MTConnect Agent's REST endpoints
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>) rooted at <see cref="AgentUri"/> and maps
/// each returned DataItem into an OPC UA variable per <see cref="MTConnectTagDefinition"/>.
/// </summary>
public sealed class MTConnectDriverOptions
{
/// <summary>
/// Gets the MTConnect Agent's base URI (e.g. <c>http://agent:5000</c>). Required — the
/// driver appends the standard Agent request paths (<c>/probe</c>, <c>/current</c>,
/// <c>/sample</c>) to this base.
/// </summary>
public required string AgentUri { get; init; }
/// <summary>
/// Optional device-name scope. When set, requests are narrowed to
/// <c>{AgentUri}/{DeviceName}/...</c> so a multi-device Agent only serves one device's
/// DataItems through this driver instance. Default <c>null</c> = agent-wide (all devices).
/// </summary>
public string? DeviceName { get; init; }
/// <summary>
/// Per-call HTTP deadline, in milliseconds, applied to every Agent request
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). Default <c>5000</c> — long enough for
/// a large device probe response over a LAN, short enough that a hung Agent surfaces as a
/// failed poll rather than wedging the driver.
/// </summary>
public int RequestTimeoutMs { get; init; } = 5000;
/// <summary>
/// The <c>interval</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
/// streaming request — the minimum time the Agent waits between successive chunks of the
/// response. Default <c>1000</c>; must be non-zero so the sample pump cannot spin the
/// connection into a busy-loop.
/// </summary>
public int SampleIntervalMs { get; init; } = 1000;
/// <summary>
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
/// MTConnect Agent's own conventional default.
/// </summary>
public int SampleCount { get; init; } = 1000;
/// <summary>
/// The <c>heartbeat</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
/// streaming request — the Agent sends an empty chunk after this much idle time so the
/// client can detect a silently-stalled connection. Default <c>10000</c>, matching the
/// MTConnect Agent's own conventional default; must be non-zero or a quiet connection can
/// never be distinguished from a dead one.
/// </summary>
public int HeartbeatMs { get; init; } = 10000;
/// <summary>
/// The authored tags this driver instance serves — one <see cref="MTConnectTagDefinition"/>
/// per tag, keyed by the DataItem <c>id</c> it binds
/// (<see cref="MTConnectTagDefinition.FullName"/>). The factory config DTO carries these and
/// builds them into the options; the driver indexes them to know each tag's target
/// <see cref="MTConnectTagDefinition.DriverDataType"/> when coercing an Agent observation
/// (whose wire form is always text) into a published value.
/// <para>
/// Defaults to <b>empty, never <c>null</c></b>: a driver instance may legitimately be
/// authored with no tags yet, and a null collection here would surface as a
/// NullReferenceException at deploy time from operator-authored config.
/// </para>
/// </summary>
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
/// <summary>
/// <b>The v3 data-plane binding: the authored raw tags the deploy artifact delivers.</b> Each
/// <see cref="RawTagEntry"/> pairs the tag's <b>RawPath</b> — the driver's wire reference for
/// read / subscribe / publish, and the key <c>DriverHostActor</c> routes published values by —
/// with the driver-specific <c>TagConfig</c> blob naming the Agent DataItem <c>id</c> it binds.
/// Injected into every driver's merged config by <c>DriverDeviceConfigMerger</c>, so this is the
/// collection a deployed instance actually serves from.
/// <para>
/// <b>Relationship to <see cref="Tags"/>.</b> The driver builds ONE observation index and
/// ONE <c>RawPath → dataItemId</c> table from both collections:
/// <list type="bullet">
/// <item>A tag in <c>RawTags</c> is reachable by its RawPath (production). Its coercion
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
/// its coercion type to the index.</item>
/// </list>
/// Both default to empty, so neither collection is required and a driver instance
/// authored with no tags yet starts cleanly.
/// </para>
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
/// <c>OnHostStatusChanged</c> on Running ↔ Stopped transitions.
/// </summary>
public MTConnectProbeOptions Probe { get; init; } = new();
/// <summary>
/// Reconnect backoff settings used after a failed Agent request or a dropped
/// <c>/sample</c> streaming connection.
/// </summary>
public MTConnectReconnectOptions Reconnect { get; init; } = new();
}
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
public sealed class MTConnectProbeOptions
{
/// <summary>Gets a value indicating whether probing is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the interval between probe requests.</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Gets the probe request timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>Geometric-backoff settings for the post-failure reconnect loop, mirroring <c>ModbusReconnectOptions</c>.</summary>
public sealed class MTConnectReconnectOptions
{
/// <summary>Delay before the first reconnect attempt, in milliseconds. Default <c>0</c> = immediate.</summary>
public int MinBackoffMs { get; init; } = 0;
/// <summary>Upper bound on the geometric backoff sequence, in milliseconds.</summary>
public int MaxBackoffMs { get; init; } = 30000;
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
public double BackoffMultiplier { get; init; } = 2.0;
}
@@ -0,0 +1,49 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// One MTConnect-backed OPC UA variable. Each authored tag binds a single Agent DataItem by
/// its <c>id</c> attribute (<see cref="FullName"/>); the driver looks up the observation's
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
/// </summary>
/// <param name="FullName">
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
/// there is no corresponding write path.
/// </param>
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
/// <param name="IsArray">
/// When <c>true</c>, the tag is exposed as an OPC UA array (e.g. an MTConnect
/// <c>PathPosition</c> / vector-valued sample). Default <c>false</c> = scalar.
/// </param>
/// <param name="ArrayDim">
/// Element count when <see cref="IsArray"/> is <c>true</c>; ignored for scalar tags. Default
/// <c>0</c>.
/// </param>
/// <param name="MtCategory">
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
/// the value-mapping path. Default <c>null</c>.
/// </param>
/// <param name="MtType">
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="MtSubType">
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="Units">
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
/// </param>
public sealed record MTConnectTagDefinition(
string FullName,
DriverDataType DriverDataType,
bool IsArray = false,
int ArrayDim = 0,
string? MtCategory = null,
string? MtType = null,
string? MtSubType = null,
string? Units = null);
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- Pure records + the MTConnect data-type inference table, shared by the driver, browse-commit,
and the AdminUI typed tag editor. No backend NuGet — only the zero-dependency Core.Abstractions
leaf (DriverDataType etc.), mirroring Driver.Modbus.Contracts / Driver.AbCip.Contracts. -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,135 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
/// design — every method returns/yields the plain DTOs in <c>MTConnectDtos.cs</c>, never a
/// TrakHound type, an <c>XElement</c>, or any other wire/library artifact. That is what lets
/// every driver behaviour above this seam (Tasks 613) be unit-tested against a fake serving
/// canned probe/current/sample XML, with no sockets involved.
/// </summary>
/// <remarks>
/// <para>
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
/// <c>MTConnectStreamsParser</c>) plus a <c>multipart/x-mixed-replace</c> frame reader for the
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
/// </para>
/// <para>
/// <b>Why the seam is <see cref="IDisposable"/> rather than
/// <see cref="IAsyncDisposable"/>.</b> The driver obtains its client through a
/// <c>Func&lt;MTConnectDriverOptions, IMTConnectAgentClient&gt;</c> and must release it on
/// <c>ShutdownAsync</c> and on every endpoint-changing <c>ReinitializeAsync</c>. Declaring
/// disposal here — instead of the driver doing <c>as IDisposable</c> + a null-conditional
/// call — is deliberate: that idiom silently no-ops against any future non-disposable
/// implementation, and the thing it would leak is an <see cref="HttpClient"/> and its socket
/// handler, once per re-deploy, for the life of the process.
/// </para>
/// <para>
/// Synchronous disposal is the honest shape because <i>every</i> resource behind this seam is
/// synchronously disposable — two <see cref="HttpClient"/>s and a
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
/// into an ordinary <see cref="OperationCanceledException"/>.
/// </para>
/// </remarks>
public interface IMTConnectAgentClient : IDisposable
{
/// <summary>
/// Issues an Agent <c>/probe</c> request and returns the parsed device hierarchy. Called
/// once at driver initialization; the result is cached and later walked to build the OPC UA
/// browse tree, where each <see cref="MTConnectDataItem.Id"/> becomes a tag's FullName.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
/// <summary>
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
/// data item. Used both to prime the observation index at startup and to re-baseline after
/// a detected sequence gap in <see cref="SampleAsync"/>.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
/// <summary>
/// Opens an Agent <c>/sample</c> long-poll stream starting at sequence <paramref name="from"/>
/// and yields one <see cref="MTConnectStreamsResult"/> per multipart chunk the Agent sends.
/// </summary>
/// <remarks>
/// <para>
/// <b>The only way this enumeration ends without throwing is <paramref name="ct"/> being
/// cancelled.</b> Every other way a stream can stop — the connection dropping, the Agent
/// writing its closing boundary, or the endpoint turning out not to stream at all —
/// throws an <see cref="MTConnectStreamException"/> naming which. A consumer therefore
/// never has to treat "the enumeration finished" as an ambiguous signal, and cannot
/// silently lose its subscription to a data path that quietly stopped (#485). See
/// <see cref="MTConnectStreamEndedException"/> (transient — reconnect) and
/// <see cref="MTConnectStreamNotSupportedException"/> (configuration — reconnecting will
/// reproduce it forever).
/// </para>
/// <para>
/// <b>Advancing the cursor.</b> After each chunk, the caller's next expected sequence is
/// that chunk's <see cref="MTConnectStreamsResult.NextSequence"/> — not the
/// <paramref name="from"/> this call was opened with. Feed that running value to
/// <see cref="IsSequenceGap"/> and, on reconnect, to a fresh <c>SampleAsync</c>.
/// </para>
/// </remarks>
/// <param name="from">The sequence number to resume streaming from.</param>
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
/// <paramref name="expectedFrom"/>, meaning observations between the two were evicted before
/// the caller could read them and an incremental update would silently skip values.
/// </summary>
/// <remarks>
/// <para>
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
/// against the original <c>from</c> instead reports a gap on every chunk once the
/// Agent's buffer has rolled past it — an endless <c>/current</c> re-baseline storm
/// against a perfectly healthy stream.
/// </para>
/// <para>
/// Equality is not a gap: <c>firstSequence == expectedFrom</c> is the ordinary
/// contiguous case where the Agent simply had nothing older to send.
/// </para>
/// <para>
/// <b>This check alone does NOT cover every ring-buffer overflow.</b> It can only judge
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
/// document served under <b>HTTP 200</b>, which this client surfaces as an
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
/// resume from that snapshot's <c>NextSequence</c>) just like a detected gap.
/// </para>
/// </remarks>
/// <param name="expectedFrom">The sequence the caller expected this chunk to start at.</param>
/// <param name="chunk">The chunk the Agent answered with.</param>
/// <returns>
/// <c>true</c> when observations were lost and the caller must re-baseline via
/// <see cref="CurrentAsync"/> rather than trusting an incremental update.
/// </returns>
static bool IsSequenceGap(long expectedFrom, MTConnectStreamsResult chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
return chunk.FirstSequence > expectedFrom;
}
}
@@ -0,0 +1,726 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
/// returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
/// so a throwaway instance is safe to construct — the universal discovery browser's
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
/// nothing until it is enumerated.
/// </para>
/// <para>
/// <b>Two <see cref="HttpClient"/>s, and the split is deliberate.</b>
/// <see cref="HttpClient.Timeout"/> is a per-instance, whole-response deadline, so a single
/// client cannot serve both legs: the unary <c>/probe</c> and <c>/current</c> calls must be
/// bounded by <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, while <c>/sample</c> is
/// a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
/// R2-01 says must never happen — so the streaming leg gets its own client with
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below). In
/// production each client owns its own handler and connection pool; in tests both are built
/// over one injected handler, which the caller owns.
/// </para>
/// <para>
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
/// run under a <see cref="CancellationTokenSource"/> linked to the caller's token and
/// cancelled after <c>RequestTimeoutMs</c>. The streaming leg bounds its <i>header</i> phase
/// the same way (a peer that accepts the connection and never answers must not wedge the
/// pump), then hands the body to a <b>heartbeat watchdog</b>: MTConnect Agents emit a
/// keep-alive chunk every <see cref="MTConnectDriverOptions.HeartbeatMs"/> precisely so a
/// client can tell "quiet but alive" from "dead", so every body read is bounded by a
/// two-missed-heartbeat window and a silent Agent surfaces as a
/// <see cref="TimeoutException"/>. This is the exact shape of this repo's S7 frozen-peer
/// production defect (arch-review R2-01) — an async read that ignores the socket deadline —
/// and the watchdog is what closes it here.
/// </para>
/// <para>
/// <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
/// A <c>0</c> must never come to mean "wait forever" (arch-review 01/S-6), and the three
/// <c>/sample</c> query knobs additionally reach the wire: an Agent answers
/// <c>heartbeat=0</c> with an <c>MTConnectError</c> under HTTP 200, which would otherwise
/// surface as a parse failure that never names the offending config key.
/// </para>
/// <para>
/// <b>Disposal is safe against an in-flight enumeration.</b> Task 9 re-initializes the
/// driver by disposing and rebuilding this client, which can happen while a pump is still
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
/// caller is written to expect, every request runs under a token linked to an internal
/// dispose token, so disposal surfaces as an ordinary
/// <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
/// status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
/// <see cref="MTConnectStreamException"/> for why the last of those is an exception rather
/// than a normal end of enumeration. <c>InitializeAsync</c> (Task 9) is what catches these
/// and moves the driver to Faulted.
/// </para>
/// </remarks>
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly HttpClient _http;
private readonly HttpClient _streamHttp;
private readonly CancellationTokenSource _disposeCts = new();
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
private readonly TimeSpan _streamIdleTimeout;
private bool _disposed;
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
/// <param name="logger">
/// Optional logger. Used only for conditions an operator needs to see but which are not
/// failures — today, the degraded <c>/sample</c> framing mode.
/// </param>
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
/// <see cref="MTConnectDriverOptions.HeartbeatMs"/>,
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/> or
/// <see cref="MTConnectDriverOptions.SampleCount"/> is not positive.
/// </exception>
public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
: this(options, handler: null, logger)
{
}
/// <summary>
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
/// sends through the supplied handler so the request/response round trip can be exercised
/// with no socket. The caller retains ownership of <paramref name="handler"/>.
/// </summary>
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
RequirePositive(
options.RequestTimeoutMs,
nameof(MTConnectDriverOptions.RequestTimeoutMs),
"a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
RequirePositive(
options.HeartbeatMs,
nameof(MTConnectDriverOptions.HeartbeatMs),
"it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
RequirePositive(
options.SampleIntervalMs,
nameof(MTConnectDriverOptions.SampleIntervalMs),
"it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
RequirePositive(
options.SampleCount,
nameof(MTConnectDriverOptions.SampleCount),
"it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");
_logger = logger;
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
// HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
_sampleQuerySuffix = string.Create(
CultureInfo.InvariantCulture,
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = _requestTimeout;
_streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
// See the class remarks: a long poll must outlive the unary deadline by design, and the
// heartbeat watchdog — not this property — is what keeps it bounded.
_streamHttp.Timeout = Timeout.InfiniteTimeSpan;
}
/// <inheritdoc/>
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
// call, so the parse below reads from memory — no network read can outlive the deadline.
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return MTConnectProbeParser.Parse(body);
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return MTConnectStreamsParser.Parse(body);
}
/// <inheritdoc/>
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from,
[EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var uri = BuildSampleUri(from);
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
var live = lifetime.Token;
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);
var boundary = ResolveMultipartBoundary(response, uri);
if (boundary is null)
{
// Not a multipart stream: the Agent answered with a single document, which means it
// ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
// exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
// more useful than ours — then report the misconfiguration. The parsed snapshot is
// deliberately NOT yielded: one document followed by a healthy-looking finished stream
// is how a dead data path disguises itself as a working one (#485).
var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
_ = MTConnectStreamsParser.Parse(single);
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
}
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
var delivered = 0L;
while (true)
{
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
if (part is null)
{
// The caller cancelling is the ONE contracted way out; anything else is reported.
live.ThrowIfCancellationRequested();
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
}
// A frame carrying nothing but whitespace is transport filler, not a document. The
// Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
// caller's `from` behind the Agent's buffer.
if (string.IsNullOrWhiteSpace(part))
{
continue;
}
delivered++;
yield return MTConnectStreamsParser.Parse(part);
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
// than a disposed handler.
_disposeCts.Cancel();
_http.Dispose();
_streamHttp.Dispose();
_disposeCts.Dispose();
}
private static void RequirePositive(int value, string optionName, string because)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MTConnectDriverOptions),
value,
$"{optionName} must be positive; {because}.");
}
}
private Uri BuildSampleUri(long from) =>
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
UriKind.Absolute);
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
{
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
// Neither the caller nor disposal cancelled, so this is our own deadline (or
// HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
// canceled" tells an operator nothing about which Agent stopped answering.
throw TimedOut(uri);
}
response.EnsureSuccessStatusCode();
return response;
}
/// <summary>
/// Issues the <c>/sample</c> request and returns as soon as the response headers land. Only
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
/// </summary>
private async Task<HttpResponseMessage> SendStreamRequestAsync(
HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _streamHttp
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
response.EnsureSuccessStatusCode();
return response;
}
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var reader = new StreamReader(body, Encoding.UTF8);
try
{
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
}
private TimeoutException TimedOut(Uri uri) =>
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
/// <summary>
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
/// Agent answered with a single (non-multipart) document.
/// </summary>
/// <exception cref="MTConnectStreamNotSupportedException">
/// The response claims to be <c>multipart/*</c> but carries no <c>boundary</c> parameter, so
/// there is nothing to frame it by. Failed fast and named, because the alternative — falling
/// through to whole-body reading of a body that never ends — surfaces much later as a bare
/// watchdog <see cref="TimeoutException"/> that points an operator at the network instead of
/// at the malformed header.
/// </exception>
private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
{
var contentType = response.Content.Headers.ContentType;
if (contentType?.MediaType is null ||
!contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var boundary = contentType.Parameters
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
.Trim('"');
if (string.IsNullOrEmpty(boundary))
{
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
}
return boundary;
}
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
}
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
}
/// <summary>
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
/// incrementally and never buffering more than the part currently in flight.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
/// dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
/// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
/// existing response stream nor be exercised without a listener. See the Task 0
/// correction in the plan.
/// </para>
/// <para>
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
/// a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
/// by length and the chunk is emitted the instant it is complete. Without it there is no
/// way to know a part has ended except by seeing the <i>next</i> boundary, so that path
/// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
/// can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
/// correctness fallback, not the expected path, and it logs a one-shot warning when it
/// engages so the degradation is visible rather than merely slow. (It cannot strand the
/// final part indefinitely — a clean end of stream returns the buffered tail — though a
/// watchdog timeout on a stalled Agent does discard it.)
/// </para>
/// <para>
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
/// parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
/// malfunctioning Agent cannot grow it without limit.
/// </para>
/// </remarks>
private sealed class MultipartStreamReader(
Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
{
private const int MaxBufferBytes = 32 * 1024 * 1024;
private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
private static readonly byte[] LfLf = "\n\n"u8.ToArray();
private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
private byte[] _buffer = new byte[16 * 1024];
private int _length;
private bool _finished;
private bool _warnedNoContentLength;
/// <summary>How the stream ended. Only meaningful once a read has returned <c>null</c>.</summary>
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
/// <summary>
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
/// </summary>
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
{
if (_finished)
{
return null;
}
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
!await EnsureAsync(2, ct).ConfigureAwait(false))
{
return Finish(MTConnectStreamEndReason.ConnectionClosed);
}
// "--boundary--" closes the stream.
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
{
return Finish(MTConnectStreamEndReason.ClosingBoundary);
}
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
if (headerEnd < 0)
{
return Finish(MTConnectStreamEndReason.ConnectionClosed);
}
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
Consume(headerEnd);
byte[]? body;
if (ParseContentLength(headers) is { } declared)
{
body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
}
else
{
WarnOnceAboutMissingContentLength();
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
}
return body is null ? null : Encoding.UTF8.GetString(body);
}
private void WarnOnceAboutMissingContentLength()
{
if (_warnedNoContentLength)
{
return;
}
_warnedNoContentLength = true;
logger?.LogWarning(
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
uri);
}
private string? Finish(MTConnectStreamEndReason reason)
{
_finished = true;
EndReason = reason;
return null;
}
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
{
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
{
Finish(MTConnectStreamEndReason.ConnectionClosed);
return null;
}
var body = _buffer[..declared];
Consume(declared);
return body;
}
private async Task<byte[]?> ReadToNextDelimiterAsync(CancellationToken ct)
{
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
if (next < 0)
{
// EOF with no closing delimiter: whatever is buffered is the last part. Returned
// rather than dropped, so a clean end of stream cannot strand a chunk.
var tail = _buffer[.._length];
_length = 0;
Finish(MTConnectStreamEndReason.ConnectionClosed);
return tail;
}
var body = _buffer[..TrimTrailingLineBreak(next)];
// Leave the delimiter itself in place; the next call's skip consumes it.
Consume(next);
return body;
}
/// <summary>Strips the single line break that terminates a part body before its delimiter.</summary>
private int TrimTrailingLineBreak(int end)
{
if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
{
return end - 2;
}
return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
}
/// <summary>Advances past the next boundary delimiter; <c>false</c> at end of stream.</summary>
private async Task<bool> SkipThroughDelimiterAsync(CancellationToken ct)
{
var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
if (index < 0)
{
return false;
}
Consume(index + _delimiter.Length);
return true;
}
private async Task<int> FindDelimiterAsync(CancellationToken ct)
{
var searchedTo = 0;
while (true)
{
// Restart the scan far enough back that a delimiter split across two reads is still
// found — without the overlap, a boundary straddling a packet boundary is invisible.
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
if (index >= 0)
{
return index;
}
searchedTo = _length;
if (!await FillAsync(ct).ConfigureAwait(false))
{
return -1;
}
}
}
/// <summary>
/// Finds the end of the part's header block — the first blank line after the delimiter.
/// A part with no headers at all is legal, in which case the blank line sits at offset 0.
/// </summary>
private async Task<int> FindHeaderEndAsync(CancellationToken ct)
{
var searchedTo = 0;
while (true)
{
var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));
if (crlf >= 0 && (lf < 0 || crlf <= lf))
{
return crlf + CrLfCrLf.Length;
}
if (lf >= 0)
{
return lf + LfLf.Length;
}
searchedTo = _length;
if (!await FillAsync(ct).ConfigureAwait(false))
{
return -1;
}
}
}
private static int? ParseContentLength(string headers)
{
foreach (var line in headers.Split('\n'))
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var raw = trimmed["content-length:".Length..].Trim();
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
value >= 0
? value
: null;
}
return null;
}
private async Task<bool> EnsureAsync(int count, CancellationToken ct)
{
while (_length < count)
{
if (!await FillAsync(ct).ConfigureAwait(false))
{
return false;
}
}
return true;
}
/// <summary>
/// Reads at least one more byte, bounded by the heartbeat watchdog. Returns <c>false</c>
/// at end of stream and throws <see cref="TimeoutException"/> when the Agent goes silent.
/// </summary>
private async Task<bool> FillAsync(CancellationToken ct)
{
if (_length == _buffer.Length)
{
if (_buffer.Length >= MaxBufferBytes)
{
throw new InvalidDataException(
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
}
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
}
using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
watchdog.CancelAfter(idleTimeout);
int read;
try
{
read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException(
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
}
if (read <= 0)
{
return false;
}
_length += read;
return true;
}
private int IndexOf(byte[] pattern, int start)
{
if (start >= _length)
{
return -1;
}
var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());
return index < 0 ? -1 : index + start;
}
private void Consume(int count)
{
Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
_length -= count;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Static factory registration helper for <see cref="MTConnectDriver"/>. The Host registers it
/// once at startup; the bootstrapper then materialises MTConnect <c>DriverInstance</c> rows from
/// the deployed configuration into live driver instances. Mirrors
/// <c>ModbusDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>There is exactly one parser for the config document.</b> This factory does not carry a
/// config DTO of its own — it delegates to <see cref="MTConnectDriver.ParseOptions"/>, which
/// the driver itself must own because the runtime delivers a config <i>change</i> to a live
/// instance (<c>DriverInstanceActor</c> assigns its driver once and calls
/// <c>ReinitializeAsync(newJson)</c> thereafter). A second DTO here would drift from that
/// one silently, and the drift would first show up at deploy time as an operator's edit
/// being applied differently by the two paths — or not at all.
/// </para>
/// <para>
/// <b>Construction opens nothing.</b> The Wave-0 universal discovery browser builds a
/// throwaway instance through this factory purely to read
/// <c>ITagDiscovery.SupportsOnlineDiscovery</c>, and never initializes it — so a factory
/// that dialled the Agent (or that pre-built an <see cref="IMTConnectAgentClient"/>) would
/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
/// agent-client factory is therefore left null: <see cref="MTConnectDriver"/> builds the
/// production client inside <c>InitializeAsync</c>.
/// </para>
/// <para>
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IRediscoverable"/>) by pattern-matching the object
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
/// narrowed return is how a capability ends up implemented but never dispatched. There is
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
/// </para>
/// </remarks>
public static class MTConnectDriverFactoryExtensions
{
/// <summary>
/// The <c>DriverInstance.DriverType</c> value this factory answers to.
/// </summary>
/// <remarks>
/// <b>Aliased to <see cref="DriverTypeNames.MTConnect"/>, not a literal.</b> The registry key,
/// the instance's self-reported <see cref="MTConnectDriver.DriverType"/>, the probe's
/// <c>DriverType</c>, and the constant every dispatch map keys off are therefore the same
/// symbol — the repo-wide fix for the historical <c>TwinCat</c>/<c>Focas</c> drift, where a
/// driver's own spelling silently diverged from the constant and dispatch maps missed it.
/// Retained as a named const (rather than deleted in favour of the constant) so the existing
/// factory callers and the sibling <c>*DriverFactoryExtensions.DriverTypeName</c> convention
/// keep working; it is now an alias with no independent value.
/// </remarks>
public const string DriverTypeName = DriverTypeNames.MTConnect;
/// <summary>
/// Register the MTConnect factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct
/// an <see cref="ILogger{TCategoryName}"/> per driver instance — without it the driver runs
/// with the null logger (tests and standalone callers stay unchanged).
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is null or blank.
/// </exception>
/// <exception cref="InvalidOperationException">
/// The config document is unparseable, deserialises to null, omits the required
/// <c>agentUri</c>, or carries an unauthorable enum/tag value. Throwing is correct at this
/// seam: a deployment carrying such a row must fail rather than register a driver pointed at
/// nothing, and the discovery browser's <c>CanBrowse</c> catches factory exceptions so a
/// half-authored config merely disables the address picker.
/// </exception>
public static MTConnectDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
// The single config authority — see the class remarks for why this is not a second DTO.
// Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
// there means "no config supplied, keep the constructor options", but this factory HAS no
// constructor options to keep, so an empty document is simply a config with no agentUri and
// must fault.
var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
// Named arguments deliberately: the ctor's trailing parameters are all optional, so a
// future insertion could silently re-bind a positional argument to the wrong one.
return new MTConnectDriver(
options,
driverInstanceId,
agentClientFactory: null,
logger: loggerFactory?.CreateLogger<MTConnectDriver>());
}
}
@@ -0,0 +1,237 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
/// single <c>GET {AgentUri}/probe</c> under the supplied deadline and reports success/failure
/// with latency — it does not construct or initialize an <see cref="MTConnectDriver"/> instance.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
/// method builds the full driver options document, including every authored
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
/// </para>
/// <para>
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject.
/// This is the direct consequence of the independent-parsing decision above, judged
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
/// config deploy".
/// </para>
/// <para>
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
/// call would have to re-derive the exact behaviour the client already provides: per-call
/// <see cref="HttpClient.Timeout"/> bounding, a linked-CTS deadline, and — most importantly —
/// correct handling of an Agent's <c>MTConnectError</c> document served under HTTP 200 (so
/// <c>EnsureSuccessStatusCode</c> never fires). <see cref="MTConnectProbeParser"/> already
/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
/// here would either duplicate that logic or under-validate (accepting any 200 response as
/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
/// risk.
/// </para>
/// <para>
/// <b>Never throws</b> (the <see cref="IDriverProbe"/> contract): every failure — unparseable
/// JSON, missing/malformed <c>agentUri</c>, DNS/TCP failure, a non-2xx response, a 200 body
/// that is not a valid <c>MTConnectDevices</c> document, and timeout — becomes
/// <c>Ok = false</c> with a message. Genuine caller cancellation is likewise converted rather
/// than left to propagate, matching <c>ModbusDriverProbe</c>'s posture (its linked-CTS catch
/// does not distinguish the caller's token from its own deadline).
/// </para>
/// <para>
/// <b>Non-positive <c>timeout</c> (arch-review 01/S-6).</b> A <c>0</c> or negative
/// timeout does NOT mean "wait forever" — it is replaced with <see cref="FallbackTimeout"/>
/// (5s, matching <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>'s own default) so an
/// operator-authorable field can never brick the probe UI.
/// </para>
/// <para>
/// <b>Secrets discipline.</b> <c>AgentUri</c> may carry userinfo credentials
/// (<c>http://user:pass@host/</c>). Every message this type constructs from the URI uses a
/// redacted display form (<see cref="RedactedDisplay"/>) built ONLY from
/// <see cref="Uri.Scheme"/>/<see cref="Uri.Host"/>/<see cref="Uri.Port"/>/<see cref="Uri.AbsolutePath"/>
/// — never <see cref="Uri.UserInfo"/> and never the raw config string — so a password can
/// never reach the AdminUI via the returned <see cref="DriverProbeResult.Message"/>.
/// </para>
/// </remarks>
public sealed class MTConnectDriverProbe : IDriverProbe
{
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Caps an excessive caller <c>timeout</c>. <c>CancellationTokenSource.CancelAfter(TimeSpan)</c>'s
/// legal range tops out near ~49.7 days (<see cref="uint.MaxValue"/> ms minus one); an
/// uncapped pathological value (e.g. a caller passing <c>TimeSpan.FromDays(1000)</c>) throws
/// <see cref="ArgumentOutOfRangeException"/> straight out of
/// <c>CancelAfter</c>, which would violate the "Never throws" contract just as surely as a
/// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
/// defect class, the high end rather than the low end). 10 minutes is generous for a
/// reachability probe and leaves an enormous margin under the legal ceiling.
/// </summary>
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Test seam: routes the reused <see cref="MTConnectAgentClient"/> through a stub handler so response-shape cases need no socket.</summary>
private readonly HttpMessageHandler? _handler;
/// <summary>Creates the probe used in production (real sockets).</summary>
public MTConnectDriverProbe()
: this(handler: null)
{
}
/// <summary>Test seam constructor — see <see cref="_handler"/>.</summary>
internal MTConnectDriverProbe(HttpMessageHandler? handler)
{
_handler = handler;
}
/// <inheritdoc />
/// <remarks>
/// Sourced from the <see cref="DriverTypeNames"/> constant, not a literal — the probe is
/// looked up by <c>AdminOperationsActor</c> under this key, so a drift from the constant
/// silently disables the Test Connect button rather than failing anything at build time.
/// </remarks>
public string DriverType => DriverTypeNames.MTConnect;
/// <summary>Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.</summary>
private sealed class MTConnectProbeConfigDto
{
public string? AgentUri { get; set; }
}
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
MTConnectProbeConfigDto? dto;
try
{
dto = JsonSerializer.Deserialize<MTConnectProbeConfigDto>(configJson, JsonOptions);
}
catch (Exception ex)
{
return new(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
{
return new(false, "Config JSON deserialized to null.", null);
}
var agentUri = dto.AgentUri?.Trim();
if (string.IsNullOrWhiteSpace(agentUri))
{
return new(false, "Config has no agentUri to probe.", null);
}
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
}
var safeUri = RedactedDisplay(parsed);
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a
// pathologically large one must never overrun CancelAfter's legal range (high end) — see
// MaxTimeout.
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
if (effectiveTimeout > MaxTimeout)
{
effectiveTimeout = MaxTimeout;
}
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(effectiveTimeout);
var options = new MTConnectDriverOptions
{
AgentUri = agentUri,
RequestTimeoutMs = requestTimeoutMs,
};
try
{
using var client = new MTConnectAgentClient(options, _handler);
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
sw.Stop();
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
}
catch (OperationCanceledException)
{
// Covers both the caller's token and our own CancelAfter deadline firing — see the type
// remarks on why cancellation is never allowed to propagate.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (TimeoutException)
{
// MTConnectAgentClient's own request-deadline signal.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (HttpRequestException ex)
{
// Connection failure or non-2xx status. HttpRequestException messages describe the
// socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
}
catch (InvalidDataException ex)
{
// MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
// an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
// a document with no devices. Never includes the request URI.
return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
}
catch (ArgumentException)
{
// Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
// method's own Uri.TryCreate above passed but the client's stricter check disagreed.
// Never forward the exception's message verbatim — it can echo the raw (possibly
// credentialed) config string.
return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
}
catch (Exception ex)
{
// Last-resort catch for an exception type none of the above enumerate. Deliberately does
// NOT forward ex.Message: unlike the typed catches above (each individually audited for
// whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
// such audit, so ex.Message is an open door for a credential leak. safeUri + the
// exception's TYPE name is enough for an operator to act on without that risk.
return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
}
}
/// <summary>
/// Builds a display form of <paramref name="uri"/> for use in returned messages, composed
/// ONLY from scheme/host/port/path — never <see cref="Uri.UserInfo"/> — so a credentialed
/// <c>AgentUri</c> can never leak a password into the AdminUI.
/// </summary>
private static string RedactedDisplay(Uri uri)
{
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
}
}
@@ -0,0 +1,193 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Result of an Agent <c>/probe</c> request: the full device hierarchy the Agent exposes.
/// Produced by <see cref="IMTConnectAgentClient.ProbeAsync"/>. This is the neutral shape both
/// the TrakHound-backed implementation and a hand-rolled XML fallback must produce — nothing
/// downstream of <see cref="IMTConnectAgentClient"/> ever sees a TrakHound type or an
/// <c>XElement</c>.
/// </summary>
/// <param name="Devices">Every device the Agent's probe response declares.</param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices);
/// <summary>
/// Common shape shared by <see cref="MTConnectDevice"/> and <see cref="MTConnectComponent"/>:
/// each may directly own data items and/or nest further components, to arbitrary depth. Backs
/// the <see cref="MTConnectComponentTreeExtensions.AllDataItems"/> recursive walk.
/// </summary>
public interface IMTConnectComponentContainer
{
/// <summary>Components nested directly under this device/component (may be empty).</summary>
IReadOnlyList<MTConnectComponent> Components { get; }
/// <summary>Data items declared directly on this device/component (may be empty).</summary>
IReadOnlyList<MTConnectDataItem> DataItems { get; }
}
/// <summary>
/// One MTConnect device from the Agent's probe response — the root of a nested
/// component/data-item tree.
/// </summary>
/// <param name="Id">The device's <c>id</c> attribute.</param>
/// <param name="Name">The device's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Components">Child components declared directly under this device.</param>
/// <param name="DataItems">Data items declared directly on this device (outside any component).</param>
public sealed record MTConnectDevice(
string Id,
string? Name,
IReadOnlyList<MTConnectComponent> Components,
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
/// <summary>
/// One MTConnect component (e.g. <c>Axes</c>, <c>Controller</c>, <c>Path</c>) from the Agent's
/// probe response. Components nest to arbitrary depth — a component may itself contain further
/// child components as well as data items.
/// </summary>
/// <param name="Id">The component's <c>id</c> attribute.</param>
/// <param name="Name">The component's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Components">Child components nested directly under this component.</param>
/// <param name="DataItems">Data items declared directly on this component.</param>
public sealed record MTConnectComponent(
string Id,
string? Name,
IReadOnlyList<MTConnectComponent> Components,
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
/// <summary>
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
/// it carries no observed value; values arrive separately via
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
/// and are correlated back to a data item by <see cref="Id"/>.
/// </summary>
/// <param name="Id">
/// The DataItem's <c>id</c> attribute — globally unique within the Agent's probe response and
/// the correlation key used against <see cref="MTConnectObservation.DataItemId"/>.
/// </param>
/// <param name="Name">The DataItem's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Category">The DataItem's <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
/// <param name="Type">The DataItem's <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).</param>
/// <param name="SubType">The DataItem's <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>), when present.</param>
/// <param name="Units">The DataItem's <c>units</c> attribute (e.g. <c>MILLIMETER</c>, <c>REVOLUTION/MINUTE</c>), when present.</param>
/// <param name="Representation">
/// The DataItem's <c>representation</c> attribute (e.g. <c>TIME_SERIES</c>, <c>DISCRETE</c>);
/// absent means the MTConnect default (<c>VALUE</c>).
/// </param>
/// <param name="SampleCount">
/// The DataItem's <c>sampleCount</c> attribute — element count for a <c>TIME_SERIES</c>
/// representation. Present only on data items that carry one.
/// </param>
public sealed record MTConnectDataItem(
string Id,
string? Name,
string Category,
string Type,
string? SubType,
string? Units,
string? Representation,
int? SampleCount);
/// <summary>
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
/// </summary>
/// <param name="InstanceId">
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
/// whenever the Agent restarts or its underlying device model changes; the driver watches this
/// for change to raise rediscovery rather than trusting a stale observation snapshot.
/// </param>
/// <param name="NextSequence">
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
/// </param>
/// <param name="FirstSequence">
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
/// circular buffer. Load-bearing for sequence-gap detection: a caller that requested
/// <c>from</c> a sequence older than this chunk's <see cref="FirstSequence"/> has fallen out of
/// the Agent's buffer and must re-baseline via <see cref="IMTConnectAgentClient.CurrentAsync"/>.
/// </param>
/// <param name="Observations">
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
/// empty when the chunk carries no new observations (e.g. a heartbeat).
/// </param>
public sealed record MTConnectStreamsResult(
long InstanceId,
long NextSequence,
long FirstSequence,
IReadOnlyList<MTConnectObservation> Observations);
/// <summary>
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
/// <c>/sample</c> stream chunk. Deliberately dumb: <see cref="Value"/> is carried as the raw
/// string the Agent reported (including the literal <c>UNAVAILABLE</c>) with no interpretation
/// applied here — coercing <c>UNAVAILABLE</c> to a bad-quality status and converting the string
/// to the tag's <c>DriverDataType</c> are the observation index's job, not this layer's.
/// </summary>
/// <param name="DataItemId">
/// The reporting DataItem's <c>id</c> — correlates back to <see cref="MTConnectDataItem.Id"/>
/// from the probe model.
/// </param>
/// <param name="Value">
/// The observed value exactly as the Agent reported it, or the literal string
/// <c>"UNAVAILABLE"</c> when the Agent has no current value for the data item. Never
/// pre-parsed into a nullable or an enum at this layer.
/// </param>
/// <param name="TimestampUtc">
/// The observation's Agent-reported timestamp, normalized to UTC (<see cref="DateTime.Kind"/>
/// is always <see cref="DateTimeKind.Utc"/>). Becomes the OPC UA variable's SourceTimestamp.
/// </param>
/// <param name="IsStructured">
/// <c>true</c> when the Agent carried this observation's real content in <b>child elements</b>
/// rather than as text — an MTConnect 2.0 <c>DATA_SET</c> / <c>TABLE</c> observation, whose
/// content is a list of <c>&lt;Entry key="…"&gt;</c> elements.
/// <para>
/// <b>Why the flag exists, and why only the parser can set it.</b> <see cref="Value"/> is
/// the element's concatenated descendant text, so a two-entry data set reads as the single
/// token <c>"12"</c> — keys discarded, values run together. The observation index cannot
/// detect that after the fact: neither this record nor the tag definition carries the
/// DataItem's <c>representation</c>, and no value-shape heuristic can work, because
/// concatenated entries are indistinguishable from a legitimate space-bearing EVENT such as
/// a <c>Message</c> reading <c>"Coolant level low"</c>. The one reliable discriminator —
/// that the observation element had element children — exists only while the XML is still
/// XML.
/// </para>
/// <para>
/// Deliberately a <b>neutral fact about the wire shape</b>, not a status: mapping it to a
/// quality code is the observation index's job (it codes these
/// <c>BadNotSupported</c> rather than publishing concatenated noise as Good). Defaults to
/// <c>false</c>, the shape of every ordinary scalar observation.
/// </para>
/// </param>
public sealed record MTConnectObservation(
string DataItemId,
string Value,
DateTime TimestampUtc,
bool IsStructured = false);
/// <summary>
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
/// </summary>
public static class MTConnectComponentTreeExtensions
{
/// <summary>
/// Enumerates every data item owned by this device or component, plus every data item owned
/// by any component nested beneath it, to arbitrary depth. Order is depth-first: a node's
/// own data items first, then each child component's data items in turn.
/// </summary>
/// <param name="container">The device or component to walk.</param>
public static IEnumerable<MTConnectDataItem> AllDataItems(this IMTConnectComponentContainer container)
{
foreach (var dataItem in container.DataItems)
{
yield return dataItem;
}
foreach (var child in container.Components)
{
foreach (var dataItem in child.AllDataItems())
{
yield return dataItem;
}
}
}
}
@@ -0,0 +1,415 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c>&lt;Unavailable/&gt;</c>
/// element name — as a CONDITION's value on that same exact spelling. It maps to
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
/// EVENT whose value happens to read "Unavailable".
/// </para>
/// <para>
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
/// that boundary on account of observation content. Every unusable observation degrades to a
/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
/// always see <i>when</i> the driver last heard about the tag even when it cannot show
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
/// argument — a programming error, not data.
/// </para>
/// <para>
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
/// </para>
/// <para>
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
/// explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
/// offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
/// would serialize the pump against every read for no semantic gain.
/// </para>
/// <para>
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
/// real content in <c>&lt;Entry key=…&gt;</c> child elements, which reach a text-valued
/// observation concatenated with the keys discarded — a two-entry set reads as the single
/// token <c>"12"</c> — and because the type inference correctly demotes those
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
/// itself (no value heuristic can separate concatenated entries from a legitimate
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
/// <see cref="MTConnectObservation.IsStructured"/>, which
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
/// </para>
/// </remarks>
internal sealed class MTConnectObservationIndex
{
/// <summary>
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
/// the parser already normalizes a CONDITION's <c>&lt;Unavailable/&gt;</c> element name onto
/// this exact spelling, so every category lands on one comparison.
/// </summary>
private const string UnavailableSentinel = "UNAVAILABLE";
private const uint Good = 0x00000000u;
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
private const uint BadNoCommunication = 0x80310000u;
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
private const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
private const uint BadOutOfRange = 0x803C0000u;
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
private const uint BadNotSupported = 0x803D0000u;
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
/// several states reported for one dataItemId <i>within a single document</i> — see
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
/// fault is information and an absent value is not. Case-insensitive so a vendor's casing
/// of a state element still ranks; the parser itself emits the canonical spellings.
/// </summary>
private static readonly FrozenDictionary<string, int> ConditionSeverity =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[UnavailableSentinel] = 0,
["Normal"] = 1,
["Warning"] = 2,
["Fault"] = 3,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Builds an index over the driver instance's authored tags.
/// </summary>
/// <param name="tags">
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
/// legal and means "no authored types": every observation is then indexed as its raw text
/// under <see cref="DriverDataType.String"/>.
/// <para>
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
/// operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
/// construct would turn an authoring slip into a driver that will not start, whereas
/// last-wins is deterministic and costs only that one tag's coercion type.
/// </para>
/// </param>
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
public MTConnectObservationIndex(
IEnumerable<MTConnectTagDefinition>? tags = null,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
foreach (var tag in tags ?? [])
{
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
map[tag.FullName] = tag;
}
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
public int Count => _snapshots.Count;
/// <summary>
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
/// with no authored tag are indexed too — the Agent streams the whole device, so they are
/// normal, and dropping them would make a streaming-but-not-yet-authored data item
/// indistinguishable from one that does not exist.
/// </summary>
/// <param name="result">The parsed streams document. An observation-free result is a legal
/// keep-alive and leaves the index untouched.</param>
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
public void Apply(MTConnectStreamsResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (result.Observations is not { Count: > 0 }) return;
// Duplicates are reconciled on the RAW observations first, so each dataItemId is written
// exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
// — 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<string, MTConnectObservation>(StringComparer.Ordinal);
foreach (var observation in result.Observations)
{
if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
resolved[observation.DataItemId] =
resolved.TryGetValue(observation.DataItemId, out var incumbent)
? Reconcile(incumbent, observation)
: observation;
}
foreach (var (dataItemId, observation) in resolved)
{
_snapshots[dataItemId] = BuildSnapshot(observation);
}
}
/// <summary>
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
/// </summary>
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
/// <returns>
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
/// would tell an operator their correctly-authored tag does not exist.
/// </returns>
public DataValueSnapshot Get(string dataItemId)
{
if (!string.IsNullOrWhiteSpace(dataItemId) &&
_snapshots.TryGetValue(dataItemId, out var snapshot))
{
return snapshot;
}
var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
? BadWaitingForInitialData
: BadNodeIdUnknown;
return new DataValueSnapshot(null, status, null, Now);
}
/// <summary>
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
/// longer exists, and serving it would be worse than serving nothing.
/// </summary>
public void Clear() => _snapshots.Clear();
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
/// <summary>
/// Picks the winner when one dataItemId appears more than once in a single document. A
/// <c>&lt;Condition&gt;</c> container legitimately carries several <b>simultaneously active</b>
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
/// the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
/// Warning purely because of element order. Anything else is last-wins, matching the Agent's
/// ascending-sequence ordering.
/// <para>
/// Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
/// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
/// spanned Applies would latch every fault forever.
/// </para>
/// </summary>
private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
{
if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
{
return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
}
return challenger;
}
private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
{
var serverTimestamp = Now;
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
// No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
// the only way to report absence, so an empty element is not an empty string value.
if (string.IsNullOrWhiteSpace(observation.Value) ||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
{
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
}
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
// Publishing that as a Good string would be noise an operator would trust. Checked before
// the tag lookup because it is a fact about the wire shape, true whether or not the data
// item is authored — and after the sentinel, so an unavailable data set still reports the
// truthful no-comms.
if (observation.IsStructured)
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
}
_tags.TryGetValue(observation.DataItemId, out var tag);
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
// type mismatch, and emphatically not the first element parsed out and served as Good.
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
if (tag is { IsArray: true })
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
}
// An unauthored observation has no declared target type; String is the only coercion that
// cannot fail, and it carries the Agent's text with no interpretation applied.
var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
return status == Good
? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
}
/// <summary>
/// Converts the Agent's text to the tag's authored type. Every parse is
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
/// </summary>
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
private static uint Coerce(string raw, DriverDataType type, out object? value)
{
value = null;
var text = raw.Trim();
switch (type)
{
// Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
// never a sensible authoring for MTConnect, but degrading it to text is harmless where
// rejecting it would break a tag for no protection.
case DriverDataType.String:
case DriverDataType.Reference:
value = raw;
return Good;
case DriverDataType.Boolean:
if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
if (text is "1") { value = true; return Good; }
if (text is "0") { value = false; return Good; }
return BadTypeMismatch;
case DriverDataType.DateTime:
if (DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var moment))
{
value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
return Good;
}
return BadTypeMismatch;
case DriverDataType.Float32:
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
{
value = single;
return Good;
}
return BadTypeMismatch;
case DriverDataType.Float64:
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
{
value = real;
return Good;
}
return BadTypeMismatch;
case DriverDataType.Int16:
if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
{
value = i16;
return Good;
}
break;
case DriverDataType.Int32:
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
{
value = i32;
return Good;
}
break;
case DriverDataType.Int64:
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
{
value = i64;
return Good;
}
break;
case DriverDataType.UInt16:
if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
{
value = u16;
return Good;
}
break;
case DriverDataType.UInt32:
if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
{
value = u32;
return Good;
}
break;
case DriverDataType.UInt64:
if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
{
value = u64;
return Good;
}
break;
default:
// A DriverDataType this driver has no rule for. Text always round-trips, so serving
// it beats failing a tag over an enum member added later.
value = raw;
return Good;
}
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
// operator whether to retype the tag or widen it.
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
: BadTypeMismatch; // not a number at all
}
}
@@ -0,0 +1,189 @@
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
/// so the whole parse is unit-testable against canned fixtures.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled rather than TrakHound.</b> The TrakHound packages Task 0 pinned
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2 — both dropped in Task 7) contain <b>no</b>
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
/// <c>Success = false</c> / "Document Formatter Not found for &quot;xml&quot;" (verified by
/// reflection + live invocation against this repo's own fixture, 2026-07-24). TrakHound also
/// offers no socket-free public parse entry point, which the plan requires.
/// </para>
/// <para>
/// <b>Three shapes of the wire format this parser is built around.</b>
/// (1) A device may declare data items <i>directly</i> on <c>&lt;Device&gt;&lt;DataItems&gt;</c>,
/// outside any component — a walker that only recurses <c>&lt;Components&gt;</c> silently
/// drops them.
/// (2) The children of <c>&lt;Components&gt;</c> are named for the component <i>type</i>
/// (<c>&lt;Controller&gt;</c>, <c>&lt;Path&gt;</c>, <c>&lt;Axes&gt;</c>, …) — there is no
/// literal <c>&lt;Component&gt;</c> element — so <i>every</i> element child of
/// <c>&lt;Components&gt;</c> is a component.
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
/// (match on local name, read attributes unqualified) are shared verbatim with
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
/// </para>
/// <para>
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
/// the Agent's document begins. A <c>/sample</c> chunk, by contrast, is lifted out of a
/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
/// the XML declaration, which <c>XmlReader</c> rejects — hence the trim there and not here.
/// This asymmetry is intentional, not an oversight.
/// </para>
/// <para>
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
/// <see cref="InvalidDataException"/>. It never degrades to an empty-but-successful model:
/// an empty device model that looks successful is precisely the defect class that has bitten
/// this codebase before (#485), and here it would tear the driver's browse tree down to
/// nothing while reporting healthy.
/// </para>
/// </remarks>
internal static class MTConnectProbeParser
{
/// <summary>
/// Bound on component nesting depth. Real device models nest a handful of levels; this only
/// exists so a pathological or hostile document cannot recurse the parser into a
/// process-fatal (uncatchable) stack overflow.
/// </summary>
private const int MaxComponentDepth = 64;
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
private const string Subject = "/probe response";
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
/// <exception cref="InvalidDataException">
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
/// under HTTP 200), or declares no usable device.
/// </exception>
public static MTConnectProbeModel Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
throw new InvalidDataException("MTConnect /probe response was empty; expected an MTConnectDevices XML document.");
}
XDocument document;
try
{
document = XDocument.Parse(xml);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectProbeModel Parse(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
XDocument document;
try
{
document = XDocument.Load(stream);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
private static MTConnectProbeModel Build(XDocument document)
{
var root = document.Root
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
}
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
?? throw new InvalidDataException(
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
if (devices.Count == 0)
{
throw new InvalidDataException(
"MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
}
return new MTConnectProbeModel(devices);
}
private static MTConnectDevice ReadDevice(XElement element) =>
new(
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
MTConnectXml.OptionalAttribute(element, "name"),
ReadComponents(element, depth: 1),
ReadDataItems(element));
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
new(
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
MTConnectXml.OptionalAttribute(element, "name"),
ReadComponents(element, depth + 1),
ReadDataItems(element));
/// <summary>
/// Every element child of this container's <c>&lt;Components&gt;</c> element, whatever it is
/// named — the element name is the component type, not the literal string "Component".
/// </summary>
private static IReadOnlyList<MTConnectComponent> ReadComponents(XElement container, int depth)
{
if (depth > MaxComponentDepth)
{
throw new InvalidDataException(
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
}
return MTConnectXml.ChildrenNamed(container, "Components")
.SelectMany(components => components.Elements())
.Select(element => ReadComponent(element, depth))
.ToList();
}
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
MTConnectXml.ChildrenNamed(container, "DataItems")
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
.Select(ReadDataItem)
.ToList();
private static MTConnectDataItem ReadDataItem(XElement element)
{
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
return new MTConnectDataItem(
id,
MTConnectXml.OptionalAttribute(element, "name"),
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
MTConnectXml.OptionalAttribute(element, "subType"),
MTConnectXml.OptionalAttribute(element, "units"),
MTConnectXml.OptionalAttribute(element, "representation"),
MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
}
}
@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Driver-internal identity of one <c>/sample</c> subscription, matching the sibling drivers'
/// shape (Galaxy's <c>GalaxySubscriptionHandle</c>, the shared <c>PollGroupEngine</c>'s
/// <c>PollSubscriptionHandle</c>): a monotonic per-driver id plus a diagnostic string that
/// carries it into logs.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every handle shares ONE Agent stream.</b> Unlike a polled driver, where each
/// subscription owns a loop, MTConnect's <c>/sample</c> long poll is per-Agent: the driver
/// opens exactly one and fans each chunk out to whichever handles subscribe the reporting
/// DataItem. The handle is therefore purely an identity — it owns no connection, no task,
/// and no cursor — and dropping one only stops the stream when it was the last.
/// </para>
/// <para>
/// A <see langword="record"/> for value equality on the id, so a handle that has round-tripped
/// through the caller still resolves. The id is never reused within a driver instance.
/// </para>
/// </remarks>
/// <param name="SubscriptionId">The monotonic per-driver subscription id.</param>
internal sealed record MTConnectSampleHandle(long SubscriptionId) : ISubscriptionHandle
{
/// <inheritdoc/>
public string DiagnosticId => $"mtconnect-sub-{SubscriptionId}";
}
@@ -0,0 +1,161 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Base type for every way an Agent <c>/sample</c> long poll can stop delivering chunks for a
/// reason that is <b>not</b> the caller cancelling it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
/// an enumerator that ends immediately. Returning normally would make "the data path
/// stopped" indistinguishable from "the data path is idle", which is the
/// quiet-successful-looking-termination shape that produced this repo's #485 defect. Every
/// non-cancellation end is therefore loud, typed, and carries the Agent URI.
/// </para>
/// <para>
/// Catch this base type to handle "the stream is over" uniformly; catch the two derived
/// types to distinguish a <i>transient</i> end (the Agent closed a connection — reconnect)
/// from a <i>configuration</i> error (the Agent will never stream to this request — retrying
/// is pointless until an operator changes something).
/// </para>
/// </remarks>
public abstract class MTConnectStreamException : Exception
{
/// <summary>Creates the exception with no message.</summary>
protected MTConnectStreamException()
{
}
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
protected MTConnectStreamException(string message)
: base(message)
{
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
protected MTConnectStreamException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// <summary>How an Agent <c>/sample</c> stream ended.</summary>
public enum MTConnectStreamEndReason
{
/// <summary>
/// The transport closed mid-stream — the connection dropped, the Agent restarted, or a proxy
/// timed the connection out. Ordinary and transient: reconnect from the last
/// <see cref="MTConnectStreamsResult.NextSequence"/>.
/// </summary>
ConnectionClosed = 0,
/// <summary>
/// The Agent wrote the closing <c>--boundary--</c> delimiter, ending the multipart response
/// deliberately. Usually means the request was bounded (an Agent honouring a <c>count</c>
/// that exhausted, or an administrative stop) rather than a fault. Also reconnectable, but
/// worth distinguishing in logs: a stream that keeps closing cleanly points at the request's
/// parameters, not at the network.
/// </summary>
ClosingBoundary = 1
}
/// <summary>
/// The Agent's <c>/sample</c> stream ended without the caller cancelling it. See
/// <see cref="MTConnectStreamException"/> for why this is not a normal end of enumeration.
/// </summary>
public sealed class MTConnectStreamEndedException : MTConnectStreamException
{
/// <summary>Creates the exception for a stream that ended for <paramref name="reason"/>.</summary>
/// <param name="reason">How the stream ended.</param>
/// <param name="agentUri">The <c>/sample</c> request URI whose stream ended.</param>
/// <param name="chunksDelivered">How many chunks were yielded before the end.</param>
public MTConnectStreamEndedException(MTConnectStreamEndReason reason, string agentUri, long chunksDelivered)
: base($"MTConnect /sample stream from '{agentUri}' ended after {chunksDelivered} chunk(s) without the caller cancelling it ({Describe(reason)}). The stream is contracted to run until cancelled, so this is reported rather than returned; resume from the last chunk's nextSequence.")
{
Reason = reason;
AgentUri = agentUri;
ChunksDelivered = chunksDelivered;
}
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
public MTConnectStreamEndedException(string message)
: base(message)
{
AgentUri = string.Empty;
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
public MTConnectStreamEndedException(string message, Exception innerException)
: base(message, innerException)
{
AgentUri = string.Empty;
}
/// <summary>Creates the exception with no message.</summary>
public MTConnectStreamEndedException()
{
AgentUri = string.Empty;
}
/// <summary>How the stream ended.</summary>
public MTConnectStreamEndReason Reason { get; }
/// <summary>The <c>/sample</c> request URI whose stream ended.</summary>
public string AgentUri { get; }
/// <summary>How many chunks were yielded before the stream ended.</summary>
public long ChunksDelivered { get; }
private static string Describe(MTConnectStreamEndReason reason) =>
reason switch
{
MTConnectStreamEndReason.ClosingBoundary => "the Agent wrote the closing multipart boundary",
_ => "the connection closed mid-stream"
};
}
/// <summary>
/// The Agent answered a <c>/sample</c> request with something that cannot be consumed as a
/// stream at all — a single non-multipart document, or a <c>multipart/*</c> response with no
/// <c>boundary</c> parameter to frame it by.
/// </summary>
/// <remarks>
/// This is a <b>configuration</b> error, not a transient one, and is deliberately typed apart
/// from <see cref="MTConnectStreamEndedException"/>: reconnecting will produce the identical
/// response forever. The usual cause is an endpoint that is not an MTConnect Agent (a reverse
/// proxy, a load balancer's health page, or an Agent fronted by something that buffers and
/// collapses <c>multipart/x-mixed-replace</c>). Reported instead of yielding the one document
/// the Agent did return, because a single snapshot followed by a healthy-looking finished
/// stream is exactly how a dead data path disguises itself as a working one.
/// </remarks>
public sealed class MTConnectStreamNotSupportedException : MTConnectStreamException
{
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
public MTConnectStreamNotSupportedException(string message)
: base(message)
{
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
public MTConnectStreamNotSupportedException(string message, Exception innerException)
: base(message, innerException)
{
}
/// <summary>Creates the exception with no message.</summary>
public MTConnectStreamNotSupportedException()
{
}
}
@@ -0,0 +1,240 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Turns an MTConnect Agent <c>MTConnectStreams</c> document — a <c>/current</c> snapshot or one
/// chunk of a <c>/sample</c> multipart stream — into the neutral
/// <see cref="MTConnectStreamsResult"/>. Deliberately a pure, socket-free static so the whole
/// parse is unit-testable against canned fixtures; chunk framing lives in
/// <see cref="MTConnectAgentClient"/> and hands this parser one chunk's XML at a time.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled rather than TrakHound.</b> Same finding as
/// <see cref="MTConnectProbeParser"/>: the packages pinned by Task 0 ship no XML document
/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
/// package references were dropped in Task 7; the driver depends only on the BCL.
/// </para>
/// <para>
/// <b>The Streams document is shaped nothing like the Devices document</b>, and three
/// differences drive this parser's design.
/// </para>
/// <list type="number">
/// <item>
/// <description>
/// <b>The observation element's name is the data item's type in PascalCase</b>
/// (<c>Position</c>, <c>PartCount</c>, <c>Execution</c>) — not the probe's
/// UPPER_SNAKE <c>POSITION</c> / <c>PART_COUNT</c>. The standard defines hundreds of
/// types and vendors add more, so matching a fixed set of element names would drop
/// observations silently. <b>Every</b> element child of <c>&lt;Samples&gt;</c>,
/// <c>&lt;Events&gt;</c> and <c>&lt;Condition&gt;</c> is therefore an observation,
/// keyed by its <c>dataItemId</c> attribute — the only identity the probe model and
/// the streams document actually share.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>A CONDITION observation's value is its ELEMENT NAME, not its text.</b>
/// <c>&lt;Normal/&gt;</c> / <c>&lt;Warning/&gt;</c> / <c>&lt;Fault/&gt;</c> /
/// <c>&lt;Unavailable/&gt;</c> carry the state in the name and are typically empty;
/// where text is present it is the operator-facing message, not the state. Reading
/// the text would report every condition as an empty string.
/// <c>&lt;Unavailable/&gt;</c> is normalized to the literal
/// <see cref="UnavailableSentinel"/> so a no-comms condition lands on exactly the
/// same sentinel as a Sample/Event whose text is <c>UNAVAILABLE</c> — the
/// observation index maps that one token to bad quality.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>Timestamps are normalized to UTC explicitly.</b> MTConnect timestamps are
/// ISO-8601 Zulu, but a bare <c>DateTime.Parse</c> yields <c>Local</c> or
/// <c>Unspecified</c> depending on machine settings — and this value becomes the
/// OPC UA SourceTimestamp, so a wrong <see cref="DateTimeKind"/> shifts every
/// timestamp by the host's UTC offset with nothing to show for it.
/// </description>
/// </item>
/// </list>
/// <para>
/// <b>Failure posture.</b> A malformed document, a non-<c>MTConnectStreams</c> root (an Agent
/// <c>MTConnectError</c> served under HTTP 200 included), an unusable header, or an
/// observation missing its <c>dataItemId</c>/<c>timestamp</c> all throw
/// <see cref="InvalidDataException"/>. A document with a valid header and <i>zero</i>
/// observations, by contrast, is entirely legal — <c>/sample</c> chunks are deltas and an
/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
/// observation list rather than throwing.
/// </para>
/// </remarks>
internal static class MTConnectStreamsParser
{
/// <summary>
/// The single token that means "the Agent has no value for this data item". Sample/Event
/// observations carry it as text; a CONDITION carries it as the element name
/// <c>&lt;Unavailable/&gt;</c> and is normalized onto this spelling here.
/// </summary>
private const string UnavailableSentinel = "UNAVAILABLE";
private const string ConditionContainer = "Condition";
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
private const string Subject = "streams response";
/// <summary>How those messages name the header element.</summary>
private const string HeaderOwner = "the <Header>";
/// <summary>
/// The three elements whose element children are observations. A ComponentStream may carry
/// any subset of them, including none.
/// </summary>
private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];
/// <summary>Parses a <c>/current</c> response, or one <c>/sample</c> chunk, held as a string.</summary>
/// <param name="xml">The raw <c>MTConnectStreams</c> XML document.</param>
/// <exception cref="InvalidDataException">
/// The payload is empty, not well-formed XML, not an <c>MTConnectStreams</c> document (an
/// Agent <c>MTConnectError</c> included — Agents answer a bad request with one under HTTP
/// 200), carries no usable <c>&lt;Header&gt;</c>, or carries a malformed observation.
/// </exception>
public static MTConnectStreamsResult Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
XDocument document;
try
{
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
// trailing line break; XmlReader rejects either ahead of the XML declaration.
document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectStreamsResult Parse(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
XDocument document;
try
{
document = XDocument.Load(stream);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
private static MTConnectStreamsResult Build(XDocument document)
{
var root = document.Root
?? throw new InvalidDataException("MTConnect streams response has no root element.");
if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
}
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
?? throw new InvalidDataException(
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
return new MTConnectStreamsResult(
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
ReadObservations(root));
}
/// <summary>
/// Collects every observation in the document, in Agent-supplied order. Containers are found
/// by descending the whole <c>&lt;Streams&gt;</c> subtree rather than by walking a fixed
/// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
/// hide a device's observations.
/// </summary>
private static IReadOnlyList<MTConnectObservation> ReadObservations(XElement root)
{
var observations = new List<MTConnectObservation>();
foreach (var container in root.Descendants().Where(IsObservationContainer))
{
var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
foreach (var element in container.Elements())
{
observations.Add(ReadObservation(element, isCondition));
}
}
return observations;
}
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
{
var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
return new MTConnectObservation(
dataItemId,
isCondition ? ConditionState(element) : element.Value.Trim(),
ReadTimestampUtc(element, dataItemId),
// A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
// because this is the only layer that can still see the distinction — see
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
// the element NAME, so child content cannot corrupt it, and flagging one would throw
// away a perfectly well-determined state.
IsStructured: !isCondition && element.HasElements);
}
/// <summary>
/// A condition's state is its element name. <c>Unavailable</c> is normalized to the
/// <see cref="UnavailableSentinel"/> spelling so downstream quality mapping has exactly one
/// token to recognize; every other state is reported as-named (<c>Normal</c>, <c>Warning</c>,
/// <c>Fault</c>, and any vendor state).
/// </summary>
private static string ConditionState(XElement element)
{
var state = element.Name.LocalName;
return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
}
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
{
var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
if (!DateTime.TryParse(
raw,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var parsed))
{
throw new InvalidDataException(
$"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
}
// AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
// quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
private static bool IsObservationContainer(XElement element) =>
ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
}
@@ -0,0 +1,146 @@
using System.Globalization;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The element/attribute reading rules shared by <see cref="MTConnectProbeParser"/> and
/// <see cref="MTConnectStreamsParser"/>. Both documents are the same dialect of XML and must be
/// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the
/// two rules below, either of which fails silently rather than loudly.
/// </summary>
/// <remarks>
/// <para>
/// <b>Rule 1: elements are matched on <see cref="XName.LocalName"/>, ignoring the
/// namespace.</b> The namespace URI carries the MTConnect version
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>), so a fully-qualified match
/// would hard-code a version. It also silently drops vendor-extension elements declared in
/// their own namespace, whose standard children still inherit the default MTConnect
/// namespace — the browse tree simply comes back short, with no error anywhere.
/// </para>
/// <para>
/// <b>Rule 2: attributes are read <i>unqualified</i>.</b> A prefixed attribute
/// (<c>xsi:type</c> on a document root, a vendor's <c>x:dataItemId</c>) must never be
/// mistaken for the real <c>type</c> / <c>dataItemId</c>, which would invent a data item the
/// probe never declared.
/// </para>
/// </remarks>
internal static class MTConnectXml
{
/// <summary>Does <paramref name="element"/> have the given local name, whatever its namespace?</summary>
/// <param name="element">The element to test.</param>
/// <param name="localName">The unqualified name to match.</param>
public static bool IsNamed(XElement element, string localName) =>
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
/// <summary>Direct element children of <paramref name="parent"/> with the given local name.</summary>
/// <param name="parent">The element whose children to filter.</param>
/// <param name="localName">The unqualified name to match.</param>
public static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
parent.Elements().Where(child => IsNamed(child, localName));
/// <summary>
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
/// See the type-level remarks for why only unqualified attributes are considered.
/// </summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
public static string? OptionalAttribute(XElement element, string name)
{
var value = element.Attribute(name)?.Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>Reads a required unqualified attribute, or throws naming the owner and the attribute.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message (e.g. "/probe response").</param>
/// <exception cref="InvalidDataException">The attribute is absent or blank.</exception>
public static string RequiredAttribute(XElement element, string name, string owner, string subject)
{
var value = OptionalAttribute(element, name);
return value ?? throw new InvalidDataException(
$"MTConnect {subject} is malformed: {owner} has no '{name}' attribute.");
}
/// <summary>Reads an optional unqualified attribute as an <see cref="int"/>.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is present but not an integer.</exception>
public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject)
{
var raw = OptionalAttribute(element, name);
if (raw is null)
{
return null;
}
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
throw new InvalidDataException(
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
}
return value;
}
/// <summary>Reads a required unqualified attribute as a <see cref="long"/>.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is absent, blank, or not an integer.</exception>
public static long RequiredLongAttribute(XElement element, string name, string owner, string subject)
{
var raw = RequiredAttribute(element, name, owner, subject);
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
throw new InvalidDataException(
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
}
return value;
}
/// <summary>
/// Builds the error message for a document whose root is not what was expected, lifting the
/// Agent's own error text when the response is an <c>MTConnectError</c> document — which
/// Agents return under <b>HTTP 200</b>, so <c>EnsureSuccessStatusCode</c> never fires and
/// this is the only place the operator can learn what the Agent objected to.
/// </summary>
/// <param name="root">The document's actual root element.</param>
/// <param name="expectedRootName">The root element name the caller required.</param>
/// <param name="subject">How to describe the document in the error message.</param>
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var message =
$"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>.";
if (!IsNamed(root, "MTConnectError"))
{
return message;
}
var errors = root.Descendants()
.Where(e => IsNamed(e, "Error"))
.Select(e =>
{
var code = OptionalAttribute(e, "errorCode");
var text = e.Value.Trim();
return code is null ? text : $"{code}: {text}";
})
.Where(text => text.Length > 0)
.ToList();
return errors.Count == 0
? message
: $"{message} The Agent reported: {string.Join("; ", errors)}";
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
</ItemGroup>
<!-- No backend NuGet: the Agent surface is plain HTTP + XML, served entirely by the BCL
(HttpClient + System.Xml.Linq). The TrakHound MTConnect.NET-Common/-HTTP references Task 0
added were removed in Task 7 — they can neither parse an MTConnect document (no XML
formatter ships in either package) nor frame the /sample stream socket-free (their client
type dials its own URL). See the Task 0 CORRECTION block in
docs/plans/2026-07-24-mtconnect-driver.md. -->
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests"/>
</ItemGroup>
</Project>
@@ -63,6 +63,9 @@
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.MTConnect:
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -0,0 +1,406 @@
@* Embeddable MTConnect Agent driver config form body. Hosted by DriverConfigModal (/raw). The Agent's
base URI is a DRIVER-level setting, not a per-device endpoint: the driver appends the standard Agent
paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
there is no host/port to split onto the device.
MTConnect is read-only — there are no write knobs to author. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@* Agent *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">MTConnect Agent</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Agent base URI</label>
<InputText @bind-Value="_form.AgentUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="http://agent.internal:5000" />
<div class="form-text">Required. The driver appends <span class="mono">/probe</span>, <span class="mono">/current</span> and <span class="mono">/sample</span> to this base.</div>
</div>
<div class="col-md-6">
<label class="form-label">Device name (blank = all devices)</label>
<InputText @bind-Value="_form.DeviceName" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="VMC-1" />
<div class="form-text">Narrows requests to <span class="mono">{base}/{device}/…</span> on a multi-device Agent.</div>
</div>
</div>
</div>
</section>
@* Polling *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Polling</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Request timeout (ms)</label>
<InputNumber @bind-Value="_form.RequestTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 5000. Must be &gt; 0.</div>
</div>
<div class="col-md-3">
<label class="form-label">Sample interval (ms)</label>
<InputNumber @bind-Value="_form.SampleIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 1000. Must be &gt; 0 — a 0 busy-loops the stream.</div>
</div>
<div class="col-md-3">
<label class="form-label">Sample count</label>
<InputNumber @bind-Value="_form.SampleCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 1000 observations per chunk. Must be &gt; 0.</div>
</div>
<div class="col-md-3">
<label class="form-label">Heartbeat (ms)</label>
<InputNumber @bind-Value="_form.HeartbeatMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 10000. Must be &gt; 0 — a 0 makes a quiet stream indistinguishable from a dead one.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="mtcProbeEnabled" />
<label class="form-check-label" for="mtcProbeEnabled">Probe enabled</label>
</div>
<div class="form-text mt-0">Periodic <span class="mono">/probe</span> driving the Running/Stopped host status.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe interval (ms)</label>
<InputNumber @bind-Value="_form.ProbeIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" disabled="@(!_form.ProbeEnabled)" />
<div class="form-text">Default 5000. Must be &gt; 0 while probing is on.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe timeout (ms)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" disabled="@(!_form.ProbeEnabled)" />
<div class="form-text">Default 2000. Must be &gt; 0 while probing is on.</div>
</div>
</div>
</div>
</section>
@* Reconnect *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Min backoff (ms)</label>
<InputNumber @bind-Value="_form.ReconnectMinBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="0" />
<div class="form-text">Default 0 (retry immediately).</div>
</div>
<div class="col-md-3">
<label class="form-label">Max backoff (ms)</label>
<InputNumber @bind-Value="_form.ReconnectMaxBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 30000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Backoff multiplier</label>
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2.0 (doubles each retry).</div>
</div>
</div>
</div>
</section>
@if (_form.Validate() is { } error)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
}
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver's DriverConfig JSON (supports <c>@bind-DriverConfigJson</c>).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The driver's resilience-policy JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Two-way resilience callback.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// Case-insensitive read so a PascalCase seeded/hand-authored config loads its real values instead of
// silently showing defaults; camelCase write to match the driver's own DTO spelling. The string-enum
// converter is mandatory across the whole *DriverForm fleet (DriverPageJsonConverterTests) — the
// MTConnect config carries no enum today, and the guard is precisely what stops the first one added
// later from serialising as a number the string-typed factory cannot parse.
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private MTConnectFormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_form = MTConnectFormModel.FromJson(DriverConfigJson);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serialises the current form state over the config document it was loaded from.</summary>
/// <returns>The driver's DriverConfig JSON.</returns>
public string GetConfigJson() => BuildConfigJson(_form, DriverConfigJson);
/// <summary>
/// Projects <paramref name="form"/> onto <paramref name="existingJson"/>, returning the driver's
/// <c>DriverConfig</c> JSON. Pure and static so the whole decision surface is unit-testable —
/// this project has no bUnit, so anything left in markup is verified only by the live gate.
/// </summary>
/// <remarks>
/// <para>
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
/// silently delete). The <c>probe</c> and <c>reconnect</c> sub-objects are replaced wholesale
/// — the form owns every field in both.
/// </para>
/// <para>
/// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
/// than written: <c>ParseOptions</c> then substitutes the driver's own positive default
/// instead of the driver faulting at Initialize on <c>RequirePositive</c> (arch-review
/// 01/S-6). Removing rather than merely skipping matters — a stale positive value already in
/// the document would otherwise survive and disagree with what the form shows.
/// </para>
/// </remarks>
/// <param name="form">The form state to serialise.</param>
/// <param name="existingJson">The config document being edited, or null when creating.</param>
/// <returns>The driver's DriverConfig JSON.</returns>
public static string BuildConfigJson(MTConnectFormModel form, string? existingJson)
{
ArgumentNullException.ThrowIfNull(form);
var bag = ParseObject(existingJson) ?? new JsonObject();
var emitted = JsonNode.Parse(JsonSerializer.Serialize(form.ToDto(), _jsonOpts))!.AsObject();
foreach (var key in emitted.Select(p => p.Key).ToList())
{
var value = emitted[key];
emitted.Remove(key); // detach before re-parenting into the bag
if (value is null)
{
bag.Remove(key);
}
else
{
// A nulled member of a sub-object must be an ABSENT key, not "key": null. The driver's
// nullable DTO binds both to "use the default", but an explicit null in a stored config
// reads as a deliberately-cleared setting to anyone inspecting it.
if (value is JsonObject nested) { StripNulls(nested); }
bag[key] = value;
}
}
return bag.ToJsonString();
}
private static void StripNulls(JsonObject o)
{
foreach (var key in o.Where(p => p.Value is null).Select(p => p.Key).ToList())
{
o.Remove(key);
}
}
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static JsonObject? ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return null; }
try { return JsonNode.Parse(json) as JsonObject; }
catch (JsonException) { return null; }
}
/// <summary>
/// Mutable mirror of the driver's <c>DriverConfig</c> document. Deliberately NOT
/// <c>MTConnectDriverOptions</c>: that type models the parsed options (TimeSpan probe knobs,
/// materialised tag lists), and serialising it would emit <c>"probe":{"interval":"00:00:05"}</c>
/// — which the driver's integer <c>intervalMs</c> DTO cannot bind.
/// </summary>
public sealed class MTConnectFormModel
{
/// <summary>The Agent's base URI. Required by the driver.</summary>
public string AgentUri { get; set; } = "";
/// <summary>Optional single-device scope; blank = agent-wide.</summary>
public string? DeviceName { get; set; }
/// <summary>Per-call HTTP deadline in ms. Must be &gt; 0.</summary>
public int RequestTimeoutMs { get; set; } = 5_000;
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms. Must be &gt; 0.</summary>
public int SampleIntervalMs { get; set; } = 1_000;
/// <summary>The <c>/sample</c> <c>count</c> query parameter. Must be &gt; 0.</summary>
public int SampleCount { get; set; } = 1_000;
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms. Must be &gt; 0.</summary>
public int HeartbeatMs { get; set; } = 10_000;
/// <summary>Whether the background connectivity probe runs.</summary>
public bool ProbeEnabled { get; set; } = true;
/// <summary>Probe cadence in ms. Must be &gt; 0 while <see cref="ProbeEnabled"/>.</summary>
public int ProbeIntervalMs { get; set; } = 5_000;
/// <summary>Probe request deadline in ms. Must be &gt; 0 while <see cref="ProbeEnabled"/>.</summary>
public int ProbeTimeoutMs { get; set; } = 2_000;
/// <summary>Delay before the first reconnect attempt in ms; 0 = immediate, and legal.</summary>
public int ReconnectMinBackoffMs { get; set; }
/// <summary>Upper bound on the geometric reconnect backoff, in ms.</summary>
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
/// own defaults for anything absent or unusable.</summary>
/// <param name="json">The DriverConfig JSON, or null/blank/malformed for a fresh form.</param>
/// <returns>The populated form model.</returns>
public static MTConnectFormModel FromJson(string? json)
{
var dto = TryDeserialize(json);
var defaults = new MTConnectFormModel();
if (dto is null) { return defaults; }
return new MTConnectFormModel
{
AgentUri = dto.AgentUri ?? "",
DeviceName = dto.DeviceName,
RequestTimeoutMs = dto.RequestTimeoutMs ?? defaults.RequestTimeoutMs,
SampleIntervalMs = dto.SampleIntervalMs ?? defaults.SampleIntervalMs,
SampleCount = dto.SampleCount ?? defaults.SampleCount,
HeartbeatMs = dto.HeartbeatMs ?? defaults.HeartbeatMs,
ProbeEnabled = dto.Probe?.Enabled ?? defaults.ProbeEnabled,
ProbeIntervalMs = dto.Probe?.IntervalMs ?? defaults.ProbeIntervalMs,
ProbeTimeoutMs = dto.Probe?.TimeoutMs ?? defaults.ProbeTimeoutMs,
ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
};
}
/// <summary>Projects the form onto the driver's wire DTO, nulling any value the driver would
/// reject so the key is omitted and the driver's own default applies.</summary>
/// <returns>The wire DTO.</returns>
public MTConnectConfigDto ToDto() => new()
{
AgentUri = Trimmed(AgentUri),
DeviceName = Trimmed(DeviceName),
RequestTimeoutMs = Positive(RequestTimeoutMs),
SampleIntervalMs = Positive(SampleIntervalMs),
SampleCount = Positive(SampleCount),
HeartbeatMs = Positive(HeartbeatMs),
Probe = new MTConnectProbeConfigDto
{
Enabled = ProbeEnabled,
IntervalMs = Positive(ProbeIntervalMs),
TimeoutMs = Positive(ProbeTimeoutMs),
},
Reconnect = new MTConnectReconnectConfigDto
{
MinBackoffMs = ReconnectMinBackoffMs >= 0 ? ReconnectMinBackoffMs : null,
MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
},
};
/// <summary>Returns an operator-facing message describing why this config would not start, or
/// null when it is usable. Advisory: <see cref="ToDto"/> already prevents an unusable value
/// from reaching the driver, so this exists to explain the snap-back rather than to gate save.</summary>
/// <returns>The error message, or null when valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(AgentUri))
{
return "An Agent base URI is required — the driver cannot start without it.";
}
var bad = new List<string>();
if (RequestTimeoutMs <= 0) { bad.Add("Request timeout"); }
if (SampleIntervalMs <= 0) { bad.Add("Sample interval"); }
if (SampleCount <= 0) { bad.Add("Sample count"); }
if (HeartbeatMs <= 0) { bad.Add("Heartbeat"); }
if (ProbeEnabled && ProbeIntervalMs <= 0) { bad.Add("Probe interval"); }
if (ProbeEnabled && ProbeTimeoutMs <= 0) { bad.Add("Probe timeout"); }
if (ReconnectMaxBackoffMs <= 0) { bad.Add("Max backoff"); }
return bad.Count == 0
? null
: $"Must be greater than zero: {string.Join(", ", bad)}. A non-positive value is dropped on save and the driver's default applies.";
}
private static int? Positive(int value) => value > 0 ? value : null;
private static string? Trimmed(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static MTConnectConfigDto? TryDeserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return null; }
try { return JsonSerializer.Deserialize<MTConnectConfigDto>(json, _jsonOpts); }
catch (JsonException) { return null; }
}
}
/// <summary>Mirror of the driver's private <c>MTConnectDriverConfigDto</c> — the exact shape
/// <c>MTConnectDriver.ParseOptions</c> binds. Every member is nullable so an omitted key means "use
/// the driver's default" rather than "reset to zero".</summary>
public sealed class MTConnectConfigDto
{
/// <summary>The Agent's base URI.</summary>
public string? AgentUri { get; init; }
/// <summary>Optional single-device scope.</summary>
public string? DeviceName { get; init; }
/// <summary>Per-call HTTP deadline in ms.</summary>
public int? RequestTimeoutMs { get; init; }
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms.</summary>
public int? SampleIntervalMs { get; init; }
/// <summary>The <c>/sample</c> <c>count</c> query parameter.</summary>
public int? SampleCount { get; init; }
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms.</summary>
public int? HeartbeatMs { get; init; }
/// <summary>Background connectivity-probe knobs.</summary>
public MTConnectProbeConfigDto? Probe { get; init; }
/// <summary>Reconnect backoff knobs.</summary>
public MTConnectReconnectConfigDto? Reconnect { get; init; }
}
/// <summary>Wire mirror of the driver's probe config block.</summary>
public sealed class MTConnectProbeConfigDto
{
/// <summary>Whether probing runs.</summary>
public bool? Enabled { get; init; }
/// <summary>Probe cadence in ms.</summary>
public int? IntervalMs { get; init; }
/// <summary>Probe request deadline in ms.</summary>
public int? TimeoutMs { get; init; }
}
/// <summary>Wire mirror of the driver's reconnect config block.</summary>
public sealed class MTConnectReconnectConfigDto
{
/// <summary>Delay before the first reconnect attempt, in ms.</summary>
public int? MinBackoffMs { get; init; }
/// <summary>Upper bound on the geometric backoff, in ms.</summary>
public int? MaxBackoffMs { get; init; }
/// <summary>Multiplier applied each retry.</summary>
public double? BackoffMultiplier { get; init; }
}
}
@@ -70,7 +70,8 @@
("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
("Calculation", DriverTypeNames.Calculation),
("MTConnect", DriverTypeNames.MTConnect),
];
private string _type = DriverTypeNames.Modbus;
@@ -0,0 +1,118 @@
@* Typed tag-config editor for an MTConnect Agent tag. A thin shell over MTConnectTagConfigModel — every
rule (defaults, unknown-key preservation, the numeric-dataType trap) lives in that model, not here.
MTConnect is READ-ONLY (the driver is deliberately not IWritable), so unlike Modbus there is no
"Writable" toggle to author.
The mtCategory/mtType/mtSubType/units row is /probe-sourced device-model metadata: rendered disabled +
readonly with NO binding of any kind, so nothing an operator can do in this UI writes to it. The model
still round-trips the values so a browse-committed stamp survives an edit of the data type.
NOTE: no inferred-type hint is shown. MTConnectDataTypeInference.Infer() also keys on the DataItem's
`representation` (DATA_SET/TABLE demote to String even on a SAMPLE; TIME_SERIES is honoured only on a
SAMPLE), and the tag-config model does not carry `representation`. Calling Infer() from here would
therefore agree with the browse picker for ordinary items and silently DISAGREE for exactly the
representation-driven ones — the failure that never shows up in the obvious test. The picker already
stamped the inferred type into `dataType` on commit; this editor is the override surface for it. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
<div class="row g-2">
<div class="col-md-8">
<label class="form-label" for="mtc-fullname">DataItem id</label>
<input id="mtc-fullname" type="text" class="form-control form-control-sm mono"
value="@_m.FullName" @onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? ""))"
placeholder="Xact" />
<div class="form-text">The Agent DataItem's <span class="mono">id</span> attribute — the driver's read/subscribe key.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mtc-datatype">Data type</label>
<select id="mtc-datatype" class="form-select form-select-sm" value="@_m.DataType"
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
<div class="form-text">Overrides the type the browse picker inferred. MTConnect is text on the wire — a wrong numeric type reads as Bad quality.</div>
</div>
</div>
@* Probe-sourced device-model metadata — display only. *@
<div class="row g-2 mt-1">
<div class="col-md-3">
<label class="form-label" for="mtc-category">Category</label>
<input id="mtc-category" type="text" class="form-control form-control-sm mono"
value="@Display(_m.MtCategory)" disabled readonly />
</div>
<div class="col-md-3">
<label class="form-label" for="mtc-type">Type</label>
<input id="mtc-type" type="text" class="form-control form-control-sm mono"
value="@Display(_m.MtType)" disabled readonly />
</div>
<div class="col-md-3">
<label class="form-label" for="mtc-subtype">Sub-type</label>
<input id="mtc-subtype" type="text" class="form-control form-control-sm mono"
value="@Display(_m.MtSubType)" disabled readonly />
</div>
<div class="col-md-3">
<label class="form-label" for="mtc-units">Units</label>
<input id="mtc-units" type="text" class="form-control form-control-sm mono"
value="@Display(_m.Units)" disabled readonly />
</div>
<div class="col-12">
<div class="form-text">From the Agent's <span class="mono">/probe</span> device model — not editable here. Re-pick the tag in the browser to refresh it.</div>
</div>
</div>
@* Author's own notes — not consumed by the driver or the runtime. *@
<div class="row g-2 mt-1">
<div class="col-md-6">
<label class="form-label" for="mtc-device">Device (note)</label>
<input id="mtc-device" type="text" class="form-control form-control-sm"
value="@_m.MtDevice" @onchange="@(e => Update(() => _m.MtDevice = e.Value?.ToString()))" />
</div>
<div class="col-md-6">
<label class="form-label" for="mtc-component">Component (note)</label>
<input id="mtc-component" type="text" class="form-control form-control-sm"
value="@_m.MtComponent" @onchange="@(e => Update(() => _m.MtComponent = e.Value?.ToString()))" />
</div>
</div>
@if (_m.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
@code {
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — supplied by the hosting modal for browse-capable pickers.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private MTConnectTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = MTConnectTagConfigModel.FromJson(ConfigJson);
}
// Placeholder for absent probe metadata: an empty read-only box reads as "the field is broken".
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value;
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -132,6 +132,11 @@ public static class RawBrowseCommitMapper
return new FocasTagConfigModel { Address = address }.ToJson();
if (Is(driverType, DriverTypeNames.S7))
return new S7TagConfigModel { Address = address }.ToJson();
// MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
// editor's canonical spelling is fullName — committing under the generic key below would open
// in that editor with an EMPTY id field and blank it on save.
if (Is(driverType, DriverTypeNames.MTConnect))
return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
@@ -0,0 +1,163 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — MTConnect is read-only, so
/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
/// load→save.</summary>
/// <remarks>
/// <para>
/// <see cref="MtCategory"/>/<see cref="MtType"/>/<see cref="MtSubType"/>/<see cref="Units"/> come
/// from the Agent's <c>/probe</c> response (see <c>MTConnectTagDefinition</c> in the driver's
/// Contracts project) and are shown read-only by Task 18's editor — this model still round-trips
/// them so a browse-commit that stamped them survives a manual edit of <see cref="DataType"/>.
/// <see cref="MtDevice"/>/<see cref="MtComponent"/> are pure author context (which physical
/// device/component this DataItem belongs to); nothing downstream consumes them.
/// </para>
/// <para>
/// <see cref="DataType"/> is an override of the driver's own probe-based inference
/// (<c>MTConnectDataTypeInference.Infer</c>, which the browse-commit path already applied when it
/// wrote <see cref="FullName"/> and this field). It is deliberately NOT recomputed here — this
/// editor only re-serialises whatever the operator picks or the browse commit stamped, never a
/// locally-reinvented inference that could disagree with the one true rule.
/// </para>
/// </remarks>
public sealed class MTConnectTagConfigModel
{
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.
/// Loaded from <c>fullName</c>, or from the driver's alternate spellings <c>dataItemId</c> /
/// <c>address</c> (see <see cref="IdKeys"/>); always saved as <c>fullName</c>.</summary>
public string FullName { get; set; } = "";
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
/// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type
/// that can fail to parse (mirrors <c>MTConnectDataTypeInference</c>'s own String-leaning default).</summary>
public DriverDataType DataType { get; set; } = DriverDataType.String;
/// <summary>The DataItem's probe-sourced <c>category</c> (<c>SAMPLE</c>/<c>EVENT</c>/<c>CONDITION</c>). Read-only display metadata.</summary>
public string? MtCategory { get; set; }
/// <summary>The DataItem's probe-sourced <c>type</c> (e.g. <c>POSITION</c>, <c>EXECUTION</c>). Read-only display metadata.</summary>
public string? MtType { get; set; }
/// <summary>The DataItem's probe-sourced <c>subType</c> (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>). Read-only display metadata.</summary>
public string? MtSubType { get; set; }
/// <summary>The DataItem's probe-sourced <c>units</c> (e.g. <c>MILLIMETER</c>). Read-only display metadata.</summary>
public string? Units { get; set; }
/// <summary>Author-entered note identifying the owning MTConnect device. Pure authoring context; not
/// consumed by the driver or the runtime.</summary>
public string? MtDevice { get; set; }
/// <summary>Author-entered note identifying the owning MTConnect component. Pure authoring context; not
/// consumed by the driver or the runtime.</summary>
public string? MtComponent { get; set; }
// The as-loaded raw "dataType" JSON value, kept only to detect the ParseEnum numeric-text trap in
// Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted
// number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or
// migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no
// signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever
// be non-null on the FIRST load of an already-poisoned config, never after a save through this model.
private string? _rawDataType;
private JsonObject _bag = new();
/// <summary>
/// The accepted spellings of the DataItem id, in precedence order, mirroring the driver's own
/// <c>MTConnectRawTagConfigDto</c>: <c>fullName</c> (this model's canonical key),
/// <c>dataItemId</c> (what an operator hand-authoring the blob reaches for), and <c>address</c>
/// (what <c>RawBrowseCommitMapper</c> wrote for MTConnect before it grew a typed-editor branch).
/// Reading only <c>fullName</c> made every already-committed tag open with an EMPTY id field and
/// get blanked on save, while the data plane kept working — the symptom looked cosmetic.
/// <see cref="ToJson"/> normalises onto <c>fullName</c> and drops the aliases, so the two
/// spellings can never drift apart on a later edit.
/// </summary>
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="MTConnectTagConfigModel"/>.</returns>
public static MTConnectTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new MTConnectTagConfigModel
{
FullName = ReadId(o),
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
MtType = TagConfigJson.GetString(o, "mtType"),
MtSubType = TagConfigJson.GetString(o, "mtSubType"),
Units = TagConfigJson.GetString(o, "units"),
MtDevice = TagConfigJson.GetString(o, "mtDevice"),
MtComponent = TagConfigJson.GetString(o, "mtComponent"),
_rawDataType = TagConfigJson.GetString(o, "dataType"),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>dataType</c> is always written as its enum NAME (never a bare number — see the
/// enum-serialization trap in the class remarks); the optional metadata keys are omitted when
/// blank rather than persisted as empty strings.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
// two spellings drift on the next edit and the driver silently reads the stale one.
TagConfigJson.Set(_bag, "dataItemId", null);
TagConfigJson.Set(_bag, "address", null);
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
TagConfigJson.Set(_bag, "mtType", Blank(MtType));
TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType));
TagConfigJson.Set(_bag, "units", Blank(Units));
TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice));
TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent));
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(FullName))
{
return "A DataItem id (fullName) is required.";
}
// See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never
// something this editor itself would have written, so surface it instead of silently accepting it.
if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit))
{
return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type.";
}
return null;
}
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
// persisting an empty string.
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
// First non-blank id spelling wins (see IdKeys). A present-but-blank "fullName" must not shadow a
// populated alias — that is exactly the state a save from the broken editor left behind.
private static string ReadId(JsonObject o)
{
foreach (var key in IdKeys)
{
if (Blank(TagConfigJson.GetString(o, key)) is { } id)
{
return id;
}
}
return "";
}
}
@@ -27,6 +27,7 @@ public static class TagConfigEditorMap
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -27,6 +27,7 @@ public static class TagConfigValidator
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -37,6 +37,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
@@ -20,6 +20,7 @@ using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
using SqlProbe = Driver.Sql.SqlDriverProbe;
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -126,6 +127,7 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
return services;
}
@@ -149,6 +151,13 @@ public static class DriverFactoryBootstrap
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);
// Tier A (the Register default): fully managed — HttpClient + System.Xml.Linq, no native SDK,
// no COM. Tier C is the only tier that arms process-level recycle (MemoryRecycle hard-breach /
// ScheduledRecycleScheduler), which would be wrong here: the driver is in-process and killing
// it kills every OPC UA session. The long-lived /sample stream does not argue for a slower
// tier either — SubscribeAsync returns synchronously after starting the pump on a background
// task, so the Tier A 5s Subscribe budget never covers the stream's lifetime.
Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
@@ -75,6 +75,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
@@ -38,6 +38,7 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
puts it in StatusCodeParityTests' scope. -->
@@ -0,0 +1,110 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that records the tree <c>DiscoverAsync</c> streams into
/// it, so discovery against a live Agent can be asserted on shape (which leaf landed under which
/// folder, with which driver-side metadata) rather than on a flat list.
/// </summary>
/// <remarks>
/// A near-twin of the MTConnect unit suite's <c>CapturingBuilder</c>, restated here rather than
/// shared: that type is <c>internal</c> to a different test assembly, and the two production
/// capturing builders (<c>Commons.Browsing</c> / <c>Runtime.Drivers</c>) would drag the server
/// stack into a driver integration project that deliberately references only the driver.
/// </remarks>
internal sealed class DiscoveryCapture : IAddressSpaceBuilder
{
private readonly State _state;
private readonly string _path;
/// <summary>Creates the root scope a driver's <c>DiscoverAsync</c> is handed.</summary>
public DiscoveryCapture()
{
_state = new State();
_path = string.Empty;
}
private DiscoveryCapture(State state, string path)
{
_state = state;
_path = path;
}
/// <summary>Every folder streamed, with the slash-joined path it landed at.</summary>
public IReadOnlyList<CapturedFolder> Folders => _state.Folders;
/// <summary>Every variable streamed, with the folder path it landed under.</summary>
public IReadOnlyList<CapturedVariable> Variables => _state.Variables;
/// <inheritdoc/>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
_state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
return new DiscoveryCapture(_state, path);
}
/// <inheritdoc/>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
_state.Variables.Add(captured);
return new Handle(captured);
}
/// <inheritdoc/>
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
// Recorded nowhere: the MTConnect driver streams no node properties, and a capture that
// threw here would fail a driver that legitimately started to.
}
private sealed class State
{
public List<CapturedFolder> Folders { get; } = [];
public List<CapturedVariable> Variables { get; } = [];
}
private sealed class Handle(CapturedVariable variable) : IVariableHandle
{
public string FullReference => variable.Attr.FullName;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
variable.AlarmConditions.Add(info);
return new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
// No test here drives an alarm transition through discovery.
}
}
}
}
/// <summary>One folder captured from a discovery stream.</summary>
/// <param name="Path">Slash-joined path of the folder itself.</param>
/// <param name="ParentPath">Slash-joined path of the scope it was added to (empty at the root).</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
/// <summary>One variable captured from a discovery stream.</summary>
/// <param name="ParentPath">Slash-joined path of the folder it landed under.</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
/// <param name="Attr">The driver-side attribute metadata the driver stamped on it.</param>
internal sealed record CapturedVariable(
string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
{
/// <summary>Alarm conditions the driver marked this variable with, if any.</summary>
public List<AlarmConditionInfo> AlarmConditions { get; } = [];
}
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
MTConnect integration-test device model — the canned Devices.xml the fixture Agent
(mtconnect/agent) serves from /probe.
This model is authored to exercise every branch of MTConnectDataTypeInference.Infer,
and to cover the two things the driver's canned unit-test fixtures structurally CANNOT:
1. UPPER_SNAKE type spellings. A probe document writes DataItem@type in UPPER_SNAKE
(PART_COUNT, ROTARY_VELOCITY); a streams document names the same concept in
PascalCase (PartCount, RotaryVelocity). The inference is separator-insensitive to
bridge the two, and a real Agent is the only thing that proves it — an earlier
revision typed PART_COUNT as String and passed every unit test.
2. name != id on every named DataItem. The hand-authored unit fixtures use ids like
"dev1_pos" that double as browse names; on a real machine tool the two always
differ, and DriverAttributeInfo.FullName must be the *id* (the observation
correlation key) while the browse name is the *name*. Every DataItem below that
carries a name deliberately gives it a value unequal to its id.
Inference coverage map (category / type / representation -> expected DriverDataType):
SAMPLE, units -> Float64 fixture_x_pos, fixture_x_load, fixture_c_speed
SAMPLE, TIME_SERIES, sampleCount -> Float64[] array fixture_c_temp_series
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
EVENT, free text -> String fixture_program, fixture_block (never fed)
EVENT, DATA_SET representation -> String fixture_varset
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
-->
<MTConnectDevices
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectDevices:2.0 http://schemas.mtconnect.org/schemas/MTConnectDevices_2.0.xsd">
<Header creationTime="2026-07-24T00:00:00Z" sender="otopcua-fixture" instanceId="0" version="2.0" assetBufferSize="128" assetCount="0" bufferSize="16384"/>
<Devices>
<!--
The Adapters block in agent.cfg is keyed by this Device's NAME (OtFixtureCnc). Rename
one and you must rename the other, or the Agent starts with no adapter attached and
every observation stays UNAVAILABLE.
-->
<Device id="otfixture" name="OtFixtureCnc" uuid="otopcua-mtconnect-fixture">
<Description manufacturer="OtOpcUa" model="IntegrationFixture" serialNumber="fixture-001">MTConnect integration-test fixture</Description>
<DataItems>
<!-- Device-level data items, declared OUTSIDE any component: the walker must not drop these. -->
<DataItem category="EVENT" id="fixture_avail" name="Favail" type="AVAILABILITY"/>
<DataItem category="EVENT" id="fixture_asset_changed" type="ASSET_CHANGED"/>
<DataItem category="EVENT" id="fixture_asset_removed" type="ASSET_REMOVED"/>
</DataItems>
<Components>
<Axes id="fixture_axes" name="Axes">
<Components>
<Linear id="fixture_x" name="X">
<DataItems>
<!-- SAMPLE + units -> Float64. Driven as a sinusoid, so its value CHANGES between reads. -->
<DataItem category="SAMPLE" id="fixture_x_pos" name="Xact" type="POSITION" subType="ACTUAL" units="MILLIMETER" nativeUnits="MILLIMETER"/>
<DataItem category="SAMPLE" id="fixture_x_load" name="Xload" type="LOAD" units="PERCENT" nativeUnits="PERCENT"/>
<!-- CONDITION -> String + IsAlarm, whatever its type says. -->
<DataItem category="CONDITION" id="fixture_x_travel" name="Xtravel" type="POSITION"/>
</DataItems>
</Linear>
<Rotary id="fixture_c" name="C">
<DataItems>
<DataItem category="SAMPLE" id="fixture_c_speed" name="Cspeed" type="ROTARY_VELOCITY" subType="ACTUAL" units="REVOLUTION/MINUTE" nativeUnits="REVOLUTION/MINUTE"/>
<!--
TIME_SERIES -> Float64 ARRAY, with ArrayDim NULL (variable length).
There is deliberately no sampleCount attribute here, and there cannot be:
`sampleCount` is an attribute of a TIME_SERIES *observation*, not of a
DataItem declaration. A real Agent rejects it outright: it logs
"The following keys were present and not expected: sampleCount" followed by
"DataItems: Invalid element 'DataItem'" and DROPS THE ENTIRE DATA ITEM from
the device model, so the tag simply vanishes from /probe (verified live
against mtconnect/agent 2.7.0.12). MTConnectDataTypeInference therefore only
ever sees sampleCount = null from a real Agent, and stamps ArrayDim = null.
`sampleRate` IS a valid DataItem attribute and is kept.
-->
<DataItem category="SAMPLE" id="fixture_c_temp_series" name="Ctemps" type="TEMPERATURE" units="CELSIUS" nativeUnits="CELSIUS" representation="TIME_SERIES" sampleRate="100"/>
</DataItems>
</Rotary>
</Components>
</Axes>
<Controller id="fixture_controller" name="Controller">
<DataItems>
<!-- Controlled vocabulary EVENT -> String. -->
<DataItem category="EVENT" id="fixture_mode" name="Cmode" type="CONTROLLER_MODE"/>
</DataItems>
<Components>
<Path id="fixture_path" name="Path">
<DataItems>
<DataItem category="EVENT" id="fixture_execution" name="Pexec" type="EXECUTION"/>
<!-- THE regression case: UPPER_SNAKE PART_COUNT must infer Int64, not String. -->
<DataItem category="EVENT" id="fixture_partcount" name="Pcount" type="PART_COUNT"/>
<DataItem category="EVENT" id="fixture_linenumber" name="Pline" type="LINE_NUMBER"/>
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
</DataItems>
</Path>
</Components>
</Controller>
</Components>
</Device>
</Devices>
</MTConnectDevices>
@@ -0,0 +1,105 @@
# MTConnect integration-test fixture — the official C++ Agent + an SHDR data source
The MTConnect C++ Agent (`mtconnect/agent`, pinned) serving a canned device model, fed live
data by a standard-library SHDR adapter. No image build step: both services run stock images
with this folder's files bind-mounted.
> **The published image is `mtconnect/agent`, not `mtconnect/cppagent`.** `cppagent` is the
> name of the source project on GitHub; there is no Docker Hub repository under that name.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Two services: `agent` (published on :5000) and `adapter` (internal, :7878) |
| [`agent.cfg`](agent.cfg) | Agent configuration, bind-mounted at `/mtconnect/config/agent.cfg` (the image's CMD path) |
| [`Devices.xml`](Devices.xml) | The canned device model the Agent serves from `/probe` |
| [`adapter.py`](adapter.py) | SHDR feeder — the data source. Pure stdlib, runs on a stock `python:*-alpine` |
## Why there is an adapter service
The `mtconnect/agent` image ships **only** the agent binary plus its schemas and styles — there
is no bundled simulator. An Agent with no adapter answers `/probe` correctly and then reports
**every observation `UNAVAILABLE` forever**, which cannot prove that reads return real values or
that the `/sample` long poll delivers anything. `adapter.py` is what makes the fixture live.
The Agent dials **out** to the adapter (see the `Adapters` block in `agent.cfg`); the adapter is
not published to the host.
## Run
From the shared Docker host (stack dir `/opt/otopcua-mtconnect`):
```bash
docker compose up -d --wait
docker compose logs -f agent
docker compose down
```
From a dev box via the helper (see CLAUDE.md "Docker Workflow"):
```powershell
lmxopcua-fix sync mtconnect # push this folder to /opt/otopcua-mtconnect/
lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
lmxopcua-fix logs mtconnect
lmxopcua-fix down mtconnect
```
### Running it on a Mac
macOS **squats port 5000** — AirPlay Receiver (ControlCenter) binds `*:5000` and wins the race,
so `docker port` reports a healthy publish while every request answers `403 Forbidden` with
`Server: AirTunes`. Use the host-port override:
```bash
MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests
```
## Endpoint
- Default: `http://10.100.0.35:5000` (the shared Docker host; 5000 is the Agent's own default port).
- Override with `MTCONNECT_AGENT_ENDPOINT` to point at a real Agent on a machine tool.
- `MTCONNECT_AGENT_HOST_PORT` changes only the **published host port** of the fixture container.
`MTConnectAgentFixture` issues one `GET {endpoint}/probe` at collection init and records a
`SkipReason` when it fails, so the suite skips cleanly on a box with no fixture running.
## The seeded device model
Every named DataItem deliberately has `name != id` — the inverse of the driver's hand-authored
unit fixtures, and the only arrangement under which confusing the browse name with the
observation correlation key is visible.
| DataItem `id` | `name` | Category | Type | Repr. | Inferred type |
|---|---|---|---|---|---|
| `fixture_avail` | `Favail` | EVENT | AVAILABILITY | | String |
| `fixture_x_pos` | `Xact` | SAMPLE | POSITION (ACTUAL, MILLIMETER) | | Float64 — **moves** |
| `fixture_x_load` | `Xload` | SAMPLE | LOAD (PERCENT) | | Float64 — **moves** |
| `fixture_x_travel` | `Xtravel` | CONDITION | POSITION | | String + IsAlarm |
| `fixture_c_speed` | `Cspeed` | SAMPLE | ROTARY_VELOCITY (REVOLUTION/MINUTE) | | Float64 — **moves** |
| `fixture_c_temp_series` | `Ctemps` | SAMPLE | TEMPERATURE (CELSIUS) | TIME_SERIES | Float64 **array**, ArrayDim `null` |
| `fixture_mode` | `Cmode` | EVENT | CONTROLLER_MODE | | String |
| `fixture_execution` | `Pexec` | EVENT | EXECUTION | | String |
| `fixture_partcount` | `Pcount` | EVENT | **PART_COUNT** | | **Int64** — the regression case |
| `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_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm |
| `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String |
The Agent additionally injects **its own `<Agent>` self-model device** (connection status,
observation update rate, adapter URI). That is normal for every MTConnect 2.x Agent and the test
suite excludes it from value-plane assertions — its update-rate samples tick whether or not any
adapter is attached, so including them would let "the stream delivered a changed value" pass
against an Agent with no data source at all.
## Two things a real Agent taught us (both cost a fixture restart to find)
1. **`agent.cfg` must be pure ASCII.** A single non-ASCII byte — even inside a comment — makes
the config parser reject the whole file with a bare `Failed / Stopped at line: N` and exit.
2. **`sampleCount` is not a DataItem attribute.** It belongs to a TIME_SERIES *observation*. An
Agent that meets it on a declaration logs
`The following keys were present and not expected: sampleCount` followed by
`DataItems: Invalid element 'DataItem'` and **drops the entire data item** from the device
model. `MTConnectDataTypeInference` therefore only ever sees `sampleCount = null` from a real
Agent, so a live TIME_SERIES tag is always a variable-length array.
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""SHDR adapter feeding the OtOpcUa MTConnect integration-test fixture live data.
The mtconnect/agent image ships *only* the agent binary plus its schemas and styles --
there is no bundled simulator. Without an adapter the Agent answers /probe correctly but
reports every observation UNAVAILABLE forever, which cannot prove the two things this
fixture exists for: that ReadAsync returns real coerced values, and that the driver's
/sample long poll delivers OnDataChange from a genuinely moving stream.
So this is the data source. It speaks SHDR (the Agent's plain-text adapter protocol) over
TCP: the Agent dials IN as the client (see agent.cfg's Adapters block), and every line we
write is `<timestamp>|<key>|<value>[|<key>|<value>...]`, where <key> matches a DataItem's
`name` attribute in Devices.xml (falling back to its `id` for the unnamed ones).
Deliberate behaviours -- each one is asserted on by the integration suite:
* Xact / Xload / Cspeed move on EVERY tick. "The value changed between two reads" is the
only assertion that distinguishes a live stream from a cached /current snapshot.
* Pcount (PART_COUNT) increments on a slower cadence, so it is both a *changing* value
and an integer -- the live half of the UPPER_SNAKE PART_COUNT -> Int64 inference.
* Pblock is NEVER written. The Agent therefore reports it UNAVAILABLE for the life of
the fixture, which is what exercises UNAVAILABLE -> BadNoCommunication.
* Conditions are re-asserted periodically rather than once at connect, so a driver that
attaches mid-run still sees them.
Pure standard library on purpose: the container is a stock `python:*-alpine` with this
file bind-mounted, so the fixture needs no image build step at all (unlike the Modbus /
S7 fixtures, whose simulators do).
"""
from __future__ import annotations
import argparse
import datetime
import math
import selectors
import socket
import sys
import threading
import time
# How often the periodic feed writes a batch of observations. Comfortably faster than the
# driver's default SampleIntervalMs (1000) so a subscription sees several distinct chunks
# inside a short test timeout.
TICK_SECONDS = 0.25
# The Agent's PING/PONG watchdog: it sends `* PING` and expects `* PONG <ms>` back. The
# number is how long the Agent should wait before declaring us dead.
PONG_TIMEOUT_MS = 10000
EXECUTION_STATES = ("ACTIVE", "READY", "INTERRUPTED", "ACTIVE", "STOPPED")
CONTROLLER_MODES = ("AUTOMATIC", "MANUAL", "AUTOMATIC", "SEMI_AUTOMATIC")
def timestamp() -> str:
"""UTC in the ISO-8601 form the Agent expects (milliseconds, trailing Z)."""
now = datetime.datetime.now(datetime.timezone.utc)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
class Feed:
"""One connected Agent. Owns the socket for the life of that connection."""
def __init__(self, conn: socket.socket, peer: str) -> None:
self._conn = conn
self._peer = peer
self._started = time.monotonic()
self._ticks = 0
# ---- wire ----
def send(self, line: str) -> None:
self._conn.sendall((line + "\n").encode("utf-8"))
def send_observations(self, pairs: list[tuple[str, str]]) -> None:
"""One SHDR line carrying several key/value pairs, all sharing one timestamp."""
if not pairs:
return
body = "|".join(f"{key}|{value}" for key, value in pairs)
self.send(f"{timestamp()}|{body}")
# ---- content ----
def send_initial_state(self) -> None:
"""Everything that is not periodic, sent once as soon as the Agent attaches."""
self.send_observations(
[
("Favail", "AVAILABLE"),
("Pprogram", "OTOPCUA-FIXTURE.NC"),
# DATA_SET representation: the Agent renders these as <Entry key=...> children,
# which is precisely the wire shape the driver demotes to String / BadNotSupported.
("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"),
]
)
self.send_conditions()
def send_conditions(self) -> None:
"""SHDR condition form: <key>|<level>|<nativeCode>|<nativeSeverity>|<qualifier>|<text>."""
stamp = timestamp()
self.send(f"{stamp}|Xtravel|normal||||")
self.send(f"{stamp}|Plogic|normal||||")
def tick(self) -> None:
"""One periodic batch. Called every TICK_SECONDS."""
self._ticks += 1
elapsed = time.monotonic() - self._started
# Continuously-moving SAMPLEs. Rounded to 4 dp so the value is a clean double on
# the wire and still differs between any two consecutive ticks.
position = round(120.0 + 40.0 * math.sin(elapsed / 2.0), 4)
load = round(45.0 + 15.0 * math.sin(elapsed / 3.7), 4)
speed = round(1500.0 + 250.0 * math.sin(elapsed / 5.0), 4)
pairs: list[tuple[str, str]] = [
("Xact", f"{position}"),
("Xload", f"{load}"),
("Cspeed", f"{speed}"),
]
# PART_COUNT: an integer EVENT that also moves. Slower than the samples so it reads
# like a real counter rather than a signal.
pairs.append(("Pcount", str(100 + int(elapsed // 5))))
pairs.append(("Pline", str(10 + (self._ticks % 90))))
self.send_observations(pairs)
# TIME_SERIES form: <key>|<sampleCount>|<sampleRate>|<v1> <v2> ...
series = " ".join(
f"{round(21.5 + 0.5 * math.sin(elapsed + i / 4.0), 3)}" for i in range(8)
)
self.send(f"{timestamp()}|Ctemps|8|100|{series}")
# Controlled-vocabulary EVENTs, on their own slow cadences.
if self._ticks % 28 == 1:
self.send_observations(
[("Pexec", EXECUTION_STATES[(self._ticks // 28) % len(EXECUTION_STATES)])]
)
if self._ticks % 44 == 1:
self.send_observations(
[("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])]
)
# Re-assert conditions occasionally so a late-attaching driver still sees them.
if self._ticks % 60 == 0:
self.send_conditions()
# NOTE: Pblock is never written, on purpose. See the module docstring.
def handle(conn: socket.socket, peer: str) -> None:
feed = Feed(conn, peer)
print(f"[adapter] agent connected from {peer}", flush=True)
selector = selectors.DefaultSelector()
selector.register(conn, selectors.EVENT_READ)
pending = b""
next_tick = time.monotonic()
try:
feed.send_initial_state()
while True:
now = time.monotonic()
if now >= next_tick:
feed.tick()
# Absolute schedule, not `now + TICK`: drift-free, and a slow tick cannot
# compound into an ever-widening gap the Agent reads as a stall.
next_tick += TICK_SECONDS
if next_tick < now:
next_tick = now + TICK_SECONDS
for _ in selector.select(timeout=max(0.0, next_tick - time.monotonic())):
chunk = conn.recv(4096)
if not chunk:
print(f"[adapter] agent {peer} closed the connection", flush=True)
return
pending += chunk
while b"\n" in pending:
line, pending = pending.split(b"\n", 1)
text = line.decode("utf-8", errors="replace").strip()
# The Agent's heartbeat. Answering is mandatory: an Agent that gets no
# PONG tears the connection down and every observation goes UNAVAILABLE.
if text.startswith("* PING"):
feed.send(f"* PONG {PONG_TIMEOUT_MS}")
except (BrokenPipeError, ConnectionResetError, OSError) as ex:
print(f"[adapter] connection to {peer} ended: {ex}", flush=True)
finally:
selector.close()
try:
conn.close()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser(description="SHDR adapter for the OtOpcUa MTConnect fixture.")
parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: all interfaces).")
parser.add_argument("--port", type=int, default=7878, help="SHDR listen port (default: 7878).")
args = parser.parse_args()
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((args.host, args.port))
listener.listen(8)
print(f"[adapter] listening on {args.host}:{args.port}", flush=True)
try:
while True:
conn, addr = listener.accept()
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# One thread per Agent connection. The Agent reconnects after any restart, and
# a serialized accept loop would leave the new connection unserved while the
# dead one's socket was still being reaped.
threading.Thread(
target=handle, args=(conn, f"{addr[0]}:{addr[1]}"), daemon=True
).start()
except KeyboardInterrupt:
return 0
finally:
listener.close()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,50 @@
# MTConnect C++ Agent configuration for the OtOpcUa integration-test fixture.
#
# ASCII ONLY. The Agent's config parser rejects the whole file on a non-ASCII byte --
# even inside a comment -- with a bare "Failed / Stopped at line: N" and exits. An em
# dash in a comment is enough to make the fixture never start.
#
# The image's CMD is `/usr/bin/mtcagent run /mtconnect/config/agent.cfg`, so this file is
# bind-mounted at exactly that path (see docker-compose.yml). Paths below are ABSOLUTE on
# purpose: a relative `Devices` is resolved against the Agent's working directory
# (/home/agent), not against this file, and would silently start an Agent with no device
# model.
Devices = /mtconnect/config/Devices.xml
Port = 5000
ServiceName = OtOpcUaMTConnectFixture
# 2^14 = 16384 observations retained. Generous on purpose: the driver's /sample pump
# re-baselines through /current when its cursor falls out of the Agent's ring buffer, and
# a small buffer would make the fixture exercise that recovery path on every run instead
# of the steady-state streaming the suite is here to prove.
BufferSize = 14
CheckpointFrequency = 1000
MaxAssets = 128
# MTConnect is a read-only source for this driver; the Agent must not accept writes.
AllowPut = false
SchemaVersion = 2.0
MonitorConfigFiles = false
Pretty = true
# The block name must equal the Device name attribute in Devices.xml (OtFixtureCnc) --
# that is how the Agent binds an adapter connection to a device. "adapter" is the compose
# service name of the SHDR feeder; the Agent dials OUT to it.
Adapters {
OtFixtureCnc {
Host = adapter
Port = 7878
ReconnectInterval = 2000
}
}
# Log to stdout rather than a file: /mtconnect/log is an image-declared VOLUME that is
# created root-owned while the Agent runs as uid 1000, so file logging fails to open.
# stdout also puts adapter-connect / device-model errors straight into `docker logs`,
# which is how you diagnose a fixture that comes up but reports everything UNAVAILABLE.
logger_config {
logging_level = info
output = cout
}
@@ -0,0 +1,79 @@
# MTConnect integration-test fixture — the official MTConnect C++ Agent, fed live data by
# a standard-library SHDR adapter.
#
# TWO services, and both are required:
#
# agent mtconnect/agent (the cppagent image). Serves /probe, /current and /sample on
# :5000. NOTE the repository name — the source project is `mtconnect/cppagent`
# but the published image is `mtconnect/agent`; `mtconnect/cppagent` does not
# exist on Docker Hub.
# adapter The data source. The agent image ships only the agent binary plus schemas and
# styles — there is NO bundled simulator — so without this every observation is
# UNAVAILABLE forever and the suite could prove nothing about reads or streaming.
# The Agent dials OUT to it (agent.cfg's Adapters block); it is not published to
# the host.
#
# Why pinned: the `latest` tag moves and a fixture that silently changes device-model
# behaviour turns a driver regression into an unexplained red. Bump deliberately.
#
# Usage (from the docker host, stack dir /opt/otopcua-mtconnect):
# docker compose up -d --wait
# docker compose down
#
# Or from a dev box, via the helper (see CLAUDE.md "Docker Workflow"):
# lmxopcua-fix sync mtconnect
# lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
services:
agent:
image: mtconnect/agent:2.7.0.12
container_name: otopcua-mtconnect-agent
restart: "no"
labels:
project: lmxopcua
depends_on:
adapter:
condition: service_started
ports:
# Host port is overridable because macOS squats :5000 — AirPlay Receiver
# (ControlCenter) binds *:5000 and WINS the race, so `docker port` reports a healthy
# publish while every request answers "403 Forbidden / Server: AirTunes". On the
# Linux docker host the default is correct and needs no override. To run the fixture
# on a Mac:
# MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
# MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test ...
- "${MTCONNECT_AGENT_HOST_PORT:-5000}:5000"
volumes:
# Individual FILE binds, not a directory bind: /mtconnect/config is a VOLUME declared
# by the image, and mounting this whole folder over it would also drop docker-compose.yml
# and adapter.py into the Agent's config directory.
- ./agent.cfg:/mtconnect/config/agent.cfg:ro
- ./Devices.xml:/mtconnect/config/Devices.xml:ro
healthcheck:
# The image is alpine-based, so busybox wget is present (there is no curl). A 200 from
# /probe is the real readiness signal — the port accepts connections before the device
# model is parsed.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/probe || exit 1"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
adapter:
image: python:3.13-alpine
container_name: otopcua-mtconnect-adapter
restart: "no"
labels:
project: lmxopcua
volumes:
- ./adapter.py:/fixtures/adapter.py:ro
# Stock image + a bind-mounted script: this fixture needs no `build:` step at all, unlike
# the Modbus / S7 / AB fixtures whose simulators are built from a Dockerfile.
command: ["python", "-u", "/fixtures/adapter.py", "--host", "0.0.0.0", "--port", "7878"]
expose:
- "7878"
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket; socket.create_connection(('127.0.0.1', 7878), timeout=2).close()\" || exit 1"]
interval: 5s
timeout: 3s
retries: 6
start_period: 3s
@@ -0,0 +1,190 @@
using System.Net.Http;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// Reachability probe for a live MTConnect Agent (the <c>mtconnect/agent</c> container in
/// <c>Docker/docker-compose.yml</c>, or a real Agent on a machine tool). Reads
/// <c>MTCONNECT_AGENT_ENDPOINT</c> (default <c>http://10.100.0.35:5000</c> — the shared Docker
/// host) and issues ONE <c>GET {endpoint}/probe</c> at fixture construction. Each test checks
/// <see cref="SkipReason"/> and calls <c>Assert.Skip</c> when the Agent was unreachable, so a
/// dev box with no fixture running still passes <c>dotnet test</c> cleanly — the same pattern
/// as <c>ModbusSimulatorFixture</c> / <c>S7SimulatorFixture</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The probe is an HTTP round-trip, not a TCP connect</b> — unlike the Modbus and S7
/// fixtures, whose protocols have nothing cheaper. An Agent's port accepts connections
/// before its device model is parsed, and a mis-authored <c>Devices.xml</c> produces an
/// Agent that answers TCP and then fails every request. A TCP-only probe would let the
/// whole suite run and fail confusingly rather than skip with an actionable message.
/// </para>
/// <para>
/// <b>The probe response is kept</b> (<see cref="ProbeDocument"/>) and is the source of
/// truth every assertion in this suite is written against. Nothing here may hard-code a
/// DataItem id: the seeded <c>Devices.xml</c> is expected to change, and a real Agent on a
/// real machine is a completely different model. Tests therefore say "for every DataItem
/// the Agent declares with category=EVENT and type PART_COUNT, the discovered variable must
/// be Int64" — a statement that survives any device model, including one with zero such
/// items (which self-skips rather than passing vacuously).
/// </para>
/// <para>
/// <b>A collection fixture, so the probe runs once per session</b> rather than once per
/// test: against a firewalled endpoint each attempt costs the full timeout.
/// </para>
/// </remarks>
public sealed class MTConnectAgentFixture : IAsyncDisposable
{
/// <summary>The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.</summary>
private const string DefaultEndpoint = "http://10.100.0.35:5000";
private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";
/// <summary>
/// Bound on the one-shot reachability probe. Deliberately generous relative to a TCP probe:
/// a cold Agent parses its device model on the first request, and a large real-world model
/// can take a second or two to serialize.
/// </summary>
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);
private static readonly string HowToStart =
$"Start the fixture (docker compose -f Docker/docker-compose.yml up -d --wait) or point " +
$"{EndpointEnvVar} at a live Agent, then re-run.";
/// <summary>Gets the Agent's base URI, exactly as an authored <c>agentUri</c> would carry it.</summary>
public string AgentUri { get; }
/// <summary>Gets the skip reason when the Agent is unreachable or unusable; otherwise <c>null</c>.</summary>
public string? SkipReason { get; }
/// <summary>
/// Gets the Agent's own <c>/probe</c> response, parsed as XML — <c>null</c> exactly when
/// <see cref="SkipReason"/> is set. Tests cross-reference discovery output against this so
/// their assertions describe the Agent's declared model rather than a hard-coded fixture.
/// </summary>
public XDocument? ProbeDocument { get; }
/// <summary>Initializes a new instance of the <see cref="MTConnectAgentFixture"/> class.</summary>
public MTConnectAgentFixture()
{
AgentUri = (Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint).Trim().TrimEnd('/');
if (!Uri.TryCreate(AgentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
SkipReason = $"{EndpointEnvVar} ('{AgentUri}') is not an absolute http(s) URI. {HowToStart}";
return;
}
try
{
using var http = new HttpClient { Timeout = ProbeTimeout };
using var response = http.GetAsync($"{AgentUri}/probe").GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
SkipReason =
$"MTConnect Agent at {AgentUri} answered /probe with HTTP {(int)response.StatusCode} " +
$"({response.ReasonPhrase}). {HowToStart}";
return;
}
var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var document = XDocument.Parse(body);
// An MTConnectError document (or anything else) served under a 2xx must skip, not run.
// The suite's whole value is asserting against a real device model; running it against
// a document that is not one produces confusing red rather than an honest skip.
if (document.Root is null || document.Root.Name.LocalName != "MTConnectDevices")
{
SkipReason =
$"MTConnect Agent at {AgentUri} answered /probe with a <{document.Root?.Name.LocalName ?? "?"}> " +
$"document, not <MTConnectDevices>. {HowToStart}";
return;
}
ProbeDocument = document;
}
catch (Exception ex)
{
SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
}
}
/// <summary>
/// Gets every <c>DataItem</c> the Agent declares, flattened out of
/// <see cref="ProbeDocument"/> with the attributes the type inference consumes.
/// </summary>
/// <remarks>
/// Read straight off the XML with LINQ-to-XML rather than through the driver's own
/// <c>MTConnectProbeParser</c>: that parser is what several of these tests are checking, and
/// an assertion that ran the code under test to build its own expectation could only ever
/// prove the code agrees with itself. Matching is on local names because an MTConnect
/// document is namespaced and the namespace URI carries the schema version.
/// </remarks>
public IReadOnlyList<DeclaredDataItem> DeclaredDataItems =>
ProbeDocument is null
? []
: [.. ProbeDocument.Descendants()
.Where(e => e.Name.LocalName == "DataItem")
.Select(e => new DeclaredDataItem(
Id: (string?)e.Attribute("id") ?? string.Empty,
Name: (string?)e.Attribute("name"),
Category: (string?)e.Attribute("category") ?? string.Empty,
Type: (string?)e.Attribute("type") ?? string.Empty,
Units: (string?)e.Attribute("units"),
Representation: (string?)e.Attribute("representation"),
InComposition: e.Ancestors().Any(a => a.Name.LocalName == "Compositions"),
InAgentSelfModel: e.Ancestors().Any(a => a.Name.LocalName == "Agent")))
.Where(d => d.Id.Length > 0)];
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// One <c>DataItem</c> as the live Agent declares it in its <c>/probe</c> response — the
/// expectation side of every discovery assertion in this suite.
/// </summary>
/// <param name="Id">The <c>id</c> attribute: the observation correlation key, and what discovery must stamp as <c>FullName</c>.</param>
/// <param name="Name">The <c>name</c> attribute when present: cosmetic, and what discovery must use as the browse name.</param>
/// <param name="Category">The <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
/// <param name="Type">The <c>type</c> attribute in the probe document's UPPER_SNAKE spelling (<c>PART_COUNT</c>, not <c>PartCount</c>).</param>
/// <param name="Units">The <c>units</c> attribute when present.</param>
/// <param name="Representation">The <c>representation</c> attribute when present (<c>TIME_SERIES</c>, <c>DATA_SET</c>, …).</param>
/// <param name="InComposition">
/// <c>true</c> when this DataItem is declared under a <c>&lt;Compositions&gt;</c> element rather
/// than under a component's own <c>&lt;DataItems&gt;</c>. The driver's discovery walk recurses
/// <c>&lt;Components&gt;</c> only, so a composition-owned item is legitimately absent from the
/// browse tree; assertions about full coverage of the device model must exclude these or they
/// become a false red on any Agent whose model uses compositions.
/// </param>
/// <param name="InAgentSelfModel">
/// <c>true</c> when this DataItem belongs to the Agent's own <c>&lt;Agent&gt;</c> self-model
/// (connection status, observation update rate, adapter URI, …) rather than to a
/// <c>&lt;Device&gt;</c>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
/// move continuously <b>whether or not any adapter is attached</b> — so a "the stream delivered a
/// changed value" assertion that did not exclude these would pass against an Agent with no data
/// source at all, which is exactly the vacuous green this suite must not have.
/// </param>
public sealed record DeclaredDataItem(
string Id,
string? Name,
string Category,
string Type,
string? Units,
string? Representation,
bool InComposition,
bool InAgentSelfModel);
/// <summary>Collection definition so the reachability probe runs once per test session.</summary>
[Xunit.CollectionDefinition(Name)]
public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture<MTConnectAgentFixture>
{
/// <summary>The collection name every test class in this suite joins.</summary>
public const string Name = "MTConnectAgent";
}
@@ -0,0 +1,697 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using System.Xml.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// The MTConnect driver against a <b>real</b> MTConnect Agent (the <c>mtconnect/agent</c>
/// fixture in <c>Docker/</c>, or a live Agent on a machine tool via
/// <c>MTCONNECT_AGENT_ENDPOINT</c>). Everything else in the driver's test surface runs on canned
/// XML; this suite is the only thing that touches a socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>What only a live Agent can prove</b>, and therefore what every test here is aimed at:
/// </para>
/// <list type="number">
/// <item>
/// <description>
/// <b>UPPER_SNAKE type spellings.</b> A <c>/probe</c> document writes
/// <c>DataItem@type</c> as <c>PART_COUNT</c>; a streams document names the same
/// concept <c>PartCount</c>. An earlier revision of the inference matched only the
/// PascalCase spelling, typed every real Agent's part counter as
/// <see cref="DriverDataType.String"/>, and passed every unit test — because the
/// hand-authored fixtures used the streams spelling in the probe document too.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b><c>name</c> differs from <c>id</c>.</b> On a real Agent most DataItems carry
/// both, and they differ. The canned fixtures use ids that double as names, which is
/// the one arrangement under which confusing the two is invisible.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>A real <c>multipart/x-mixed-replace</c> stream.</b> The canned subscribe tests
/// drive a fake pump that hands the driver pre-parsed chunks; real boundaries, real
/// chunked transfer-encoding, and real heartbeats are a different thing entirely.
/// </description>
/// </item>
/// </list>
/// <para>
/// <b>Assertions are structural, never by literal id.</b> Every expectation is computed from
/// the Agent's OWN <c>/probe</c> response (<see cref="MTConnectAgentFixture.ProbeDocument"/>),
/// so the suite survives an edit to the seeded <c>Devices.xml</c> and can be pointed at a
/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
/// </para>
/// <para>
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
/// shape belongs to older/third-party Agents and stays canned-only.
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
/// </para>
/// </remarks>
[Collection(MTConnectAgentCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Driver", "MTConnect")]
public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
{
/// <summary>
/// Hard per-test ceiling, in milliseconds. Every test also bounds its own awaits, but a
/// live-integration suite must never be able to wedge a build against a half-up fixture —
/// an Agent that completes its TCP handshake and then never answers would otherwise hang
/// inside the driver's own retry loop.
/// </summary>
private const int TestTimeoutMs = 90_000;
/// <summary>Canonical <c>Opc.Ua.StatusCodes</c> numerics, restated so the assertion names the wire value a client sees.</summary>
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---- reachability ----
/// <summary>
/// The AdminUI Test Connect path against a live Agent: <see cref="MTConnectDriverProbe"/>
/// must go green and report a latency.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Probe_reports_the_live_agent_reachable()
{
SkipIfDown();
var result = await new MTConnectDriverProbe()
.ProbeAsync(ProbeConfig(agent.AgentUri), TimeSpan.FromSeconds(15), Ct);
result.Ok.ShouldBeTrue($"Test Connect must go green against the live Agent. Message: {result.Message}");
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("/probe");
result.Latency.ShouldNotBeNull();
}
/// <summary>
/// Falsifiability control for <see cref="Probe_reports_the_live_agent_reachable"/>: the same
/// probe against a port nothing is listening on must go RED. Without this, a probe that
/// returned <c>Ok = true</c> unconditionally would satisfy the green test forever.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Probe_reports_a_dead_endpoint_as_unreachable()
{
SkipIfDown();
var result = await new MTConnectDriverProbe()
.ProbeAsync(ProbeConfig($"http://127.0.0.1:{ClosedLoopbackPort()}"), TimeSpan.FromSeconds(5), Ct);
result.Ok.ShouldBeFalse("a probe against a closed port must fail, or the green probe proves nothing");
result.Latency.ShouldBeNull();
}
// ---- discovery: the /probe device model ----
/// <summary>
/// <c>DiscoverAsync</c> streams a non-empty, genuinely nested tree, and every leaf in it is
/// a DataItem the Agent actually declares — no invented references, and nothing dropped
/// except the composition-owned items the walk is documented not to visit.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_streams_the_agents_declared_device_model()
{
SkipIfDown();
var capture = await DiscoverAsync();
capture.Variables.ShouldNotBeEmpty("a live Agent declares data items; an empty browse tree is the #485 shape");
capture.Folders.ShouldNotBeEmpty();
capture.Folders.ShouldContain(
f => f.ParentPath.Length > 0,
"the walk must recurse <Components>, not just emit one folder per device");
var declared = agent.DeclaredDataItems;
var declaredIds = declared.Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
var discoveredIds = capture.Variables.Select(v => v.Attr.FullName).ToHashSet(StringComparer.Ordinal);
discoveredIds.Except(declaredIds).ShouldBeEmpty("discovery must not invent references the Agent never declared");
var expected = declared.Where(d => !d.InComposition).Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
expected.Except(discoveredIds).ShouldBeEmpty("every component-owned DataItem the Agent declares must be browsable");
}
/// <summary>
/// <b>The regression this whole fixture exists for.</b> An <c>EVENT</c> whose probe-document
/// type is the UPPER_SNAKE <c>PART_COUNT</c> / <c>LINE_NUMBER</c> must infer
/// <see cref="DriverDataType.Int64"/>. Matching only the streams document's PascalCase
/// <c>PartCount</c> types it <see cref="DriverDataType.String"/> — green in every unit test,
/// wrong on every real Agent.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_an_upper_snake_numeric_event_as_Int64()
{
SkipIfDown();
var numericEvents = agent.DeclaredDataItems
.Where(d => Is(d.Category, "EVENT"))
.Where(d => Is(d.Type, "PART_COUNT") || Is(d.Type, "LINE_NUMBER") || Is(d.Type, "LINE"))
.ToList();
if (numericEvents.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} declares no PART_COUNT / LINE_NUMBER EVENT, so the " +
"UPPER_SNAKE numeric-event inference has nothing to assert against here.");
}
var capture = await DiscoverAsync();
foreach (var item in numericEvents)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(
DriverDataType.Int64,
$"DataItem '{item.Id}' (category={item.Category}, type={item.Type}) is an integer EVENT; " +
"typing it String is the UPPER_SNAKE-vs-PascalCase defect this suite exists to catch");
variable.Attr.IsArray.ShouldBeFalse();
}
}
/// <summary>
/// A <c>SAMPLE</c> is a measured quantity by definition of the category, so it must infer
/// <see cref="DriverDataType.Float64"/> — scalar for an ordinary sample, an array for a
/// <c>TIME_SERIES</c>.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_samples_as_Float64_and_time_series_as_a_float_array()
{
SkipIfDown();
var samples = agent.DeclaredDataItems
.Where(d => Is(d.Category, "SAMPLE") && !d.InComposition)
.Where(d => !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
.ToList();
if (samples.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} declares no scalar/time-series SAMPLE data items.");
}
var capture = await DiscoverAsync();
foreach (var item in samples)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(
DriverDataType.Float64,
$"DataItem '{item.Id}' is category=SAMPLE (type={item.Type}, units={item.Units ?? "<none>"})");
if (Is(item.Representation, "TIME_SERIES"))
{
variable.Attr.IsArray.ShouldBeTrue($"'{item.Id}' declares representation=TIME_SERIES");
// Not a bug and not an oversight: `sampleCount` is an attribute of a TIME_SERIES
// *observation*, not of a DataItem declaration. A real Agent REJECTS the whole
// DataItem if the declaration carries one ("The following keys were present and not
// expected: sampleCount" -> "Invalid element 'DataItem'"), so the inference can only
// ever see null here and a live TIME_SERIES tag is always variable-length. Asserted
// rather than ignored so a future change that starts fabricating a dimension is loud.
variable.Attr.ArrayDim.ShouldBeNull(
$"'{item.Id}': a real Agent never carries sampleCount on a DataItem declaration");
}
else
{
variable.Attr.IsArray.ShouldBeFalse($"'{item.Id}' declares no TIME_SERIES representation");
}
}
}
/// <summary>
/// A <c>CONDITION</c> is a state word, never a number, whatever its <c>type</c> says — and it
/// is the one category discovery flags as an alarm.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_a_condition_as_a_string_alarm()
{
SkipIfDown();
var conditions = agent.DeclaredDataItems
.Where(d => Is(d.Category, "CONDITION") && !d.InComposition)
.ToList();
if (conditions.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} declares no CONDITION data items.");
}
var capture = await DiscoverAsync();
foreach (var item in conditions)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(DriverDataType.String, $"CONDITION '{item.Id}' reports a state word");
variable.Attr.IsAlarm.ShouldBeTrue($"CONDITION '{item.Id}' is an alarm-bearing data item");
variable.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
}
/// <summary>
/// <b>The <c>FullName</c>-vs-browse-name split, on the shape that makes it visible.</b> The
/// driver-side reference must be the DataItem's <c>id</c> (the observation correlation key)
/// while the browse name is its <c>name</c>. Committing the name instead produces a picker
/// that looks perfect and authors tags that can never report a value — and it is invisible on
/// the canned fixtures, whose ids double as names.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs()
{
SkipIfDown();
var renamed = agent.DeclaredDataItems
.Where(d => !d.InComposition)
.Where(d => !string.IsNullOrEmpty(d.Name) && !string.Equals(d.Name, d.Id, StringComparison.Ordinal))
.ToList();
if (renamed.Count == 0)
{
Assert.Skip(
$"Every DataItem the Agent at {agent.AgentUri} declares has name == id (or no name), so " +
"the id-vs-name split cannot be observed against this device model.");
}
var capture = await DiscoverAsync();
foreach (var item in renamed)
{
var variable = Leaf(capture, item.Id);
variable.Attr.FullName.ShouldBe(item.Id, "the driver-side reference must be the DataItem id");
variable.BrowseName.ShouldBe(item.Name, "the browse name must prefer the DataItem name");
}
}
/// <summary>
/// A device scope that matches nothing must fail loudly. It must never degrade to an
/// empty-but-successful browse — indistinguishable from a working connection to a machine
/// that declares nothing (#485).
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_scoped_to_an_unknown_device_fails_rather_than_browsing_empty()
{
SkipIfDown();
var driver = new MTConnectDriver(
LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}"), "mtconnect-live-unknown-device");
var capture = new DiscoveryCapture();
try
{
// Either leg may be the one that fails — the Agent answers /probe and /current for an
// unknown device with 404 + an MTConnectError body — so the assertion is that ONE of
// them did, and that nothing was streamed.
var failed = false;
try
{
await driver.InitializeAsync(ConfigJson(LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}")), Ct);
await driver.DiscoverAsync(capture, Ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failed = true;
}
failed.ShouldBeTrue("an unknown device scope must surface as a failure, never as an empty browse");
capture.Variables.ShouldBeEmpty();
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
// ---- reads: the /current snapshot ----
/// <summary>
/// <c>ReadAsync</c> returns live, coerced values off the Agent's <c>/current</c> snapshot for
/// the numeric samples it is currently reporting.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_returns_live_values_for_the_agents_numeric_samples()
{
SkipIfDown();
var ids = await NumericSampleIdsAsync();
if (ids.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value " +
"(every sample is UNAVAILABLE). Is the fixture's adapter service running?");
}
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
var results = await live.Driver.ReadAsync([.. ids], Ct);
results.Count.ShouldBe(ids.Count);
var good = results.Where(r => r.StatusCode == Good).ToList();
good.ShouldNotBeEmpty("at least one numeric SAMPLE the Agent reports a value for must read Good");
good.ShouldAllBe(r => r.Value is double);
good.ShouldAllBe(r => r.SourceTimestampUtc != null && r.SourceTimestampUtc.Value.Kind == DateTimeKind.Utc);
}
/// <summary>
/// The Agent's literal <c>UNAVAILABLE</c> must map to <c>BadNoCommunication</c> — not to a
/// Good empty string, and not to the "no value yet" code, which means something different to
/// an operator.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_maps_an_unavailable_observation_to_BadNoCommunication()
{
SkipIfDown();
var unavailable = (await CurrentAsync())
.Descendants()
.Where(e => (string?)e.Attribute("dataItemId") is { Length: > 0 })
.Where(e => !e.HasElements && string.Equals(e.Value.Trim(), "UNAVAILABLE", StringComparison.Ordinal))
.Select(e => (string)e.Attribute("dataItemId")!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (unavailable.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no UNAVAILABLE observation, so the " +
"UNAVAILABLE -> BadNoCommunication mapping has nothing live to assert against.");
}
await using var live = await LiveDriverAsync(unavailable.Select(id => Tag(id, DriverDataType.String)));
var results = await live.Driver.ReadAsync([.. unavailable], Ct);
results.Count.ShouldBe(unavailable.Count);
results.ShouldAllBe(r => r.StatusCode == BadNoCommunication);
results.ShouldAllBe(r => r.Value == null);
}
// ---- subscriptions: the /sample long poll ----
/// <summary>
/// <b>The live streaming proof.</b> A real <c>multipart/x-mixed-replace</c> body, with real
/// boundaries and real heartbeats, must drive <c>OnDataChange</c> — and must drive it with a
/// value that has actually MOVED since the primed <c>/current</c> baseline. Asserting merely
/// that a callback arrived would be satisfied by the priming callback alone, which is
/// answered out of the snapshot and proves nothing about the stream.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Subscribe_delivers_a_changed_value_from_the_live_sample_stream()
{
SkipIfDown();
var ids = await NumericSampleIdsAsync();
if (ids.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value, so no " +
"observation can be expected to move. Is the fixture's adapter service running?");
}
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
var seen = new ConcurrentQueue<DataChangeEventArgs>();
var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var baseline = new ConcurrentDictionary<string, double>(StringComparer.Ordinal);
live.Driver.OnDataChange += (_, e) =>
{
seen.Enqueue(e);
if (e.Snapshot.StatusCode != Good || e.Snapshot.Value is not double value)
{
return;
}
// First Good value per reference is the baseline (the priming callback); a later one
// that differs is the stream doing its job.
if (!baseline.TryAdd(e.FullReference, value) &&
baseline.TryGetValue(e.FullReference, out var first) &&
Math.Abs(first - value) > 1e-9)
{
moved.TrySetResult();
}
};
var handle = await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
try
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
deadline.CancelAfter(StreamWait);
var finished = await Task.WhenAny(moved.Task, Task.Delay(Timeout.Infinite, deadline.Token));
(finished == moved.Task).ShouldBeTrue(
$"the live /sample stream must deliver a CHANGED value within {StreamWait.TotalSeconds:F0}s. " +
$"Callbacks seen: {seen.Count}; references with a Good baseline: {baseline.Count}.");
seen.Count.ShouldBeGreaterThan(ids.Count, "more callbacks than the one priming callback per reference");
}
finally
{
await live.Driver.UnsubscribeAsync(handle, CancellationToken.None);
}
}
// ---- the v3 production shape: RawPath identity ----
/// <summary>
/// The shape a deployed driver actually serves: the artifact injects <c>RawTags</c>
/// (RawPath + a <c>TagConfig</c> blob naming the DataItem id), and the driver must read and
/// publish <b>by RawPath</b>. A driver that answered under its own DataItem id would be
/// dropped silently by <c>DriverHostActor</c>'s RawPath-keyed routing table, with every unit
/// test still green.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags()
{
SkipIfDown();
var ids = (await NumericSampleIdsAsync()).Take(4).ToList();
if (ids.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
}
var rawPaths = ids.ToDictionary(id => id, id => $"Plant/MTConnect/live/{id}", StringComparer.Ordinal);
// The real merge DeploymentArtifact.TryReadSpec runs, not a hand-written JSON literal: this
// proves the driver binds the shape the deploy artifact actually produces.
var merged = DriverDeviceConfigMerger.Merge(
ConfigJson(LiveOptions()),
[new DriverDeviceConfigMerger.DeviceRow("live", "{}")],
[.. ids.Select(id => new RawTagEntry(
rawPaths[id],
$$"""{"fullName":"{{id}}","driverDataType":"Float64"}""",
WriteIdempotent: false,
DeviceName: "live"))]);
var driver = new MTConnectDriver(LiveOptions(), "mtconnect-live-rawtags");
try
{
await driver.InitializeAsync(merged, Ct);
var byRawPath = await driver.ReadAsync([.. rawPaths.Values], Ct);
byRawPath.Count.ShouldBe(ids.Count);
byRawPath.ShouldContain(r => r.StatusCode == Good && r.Value is double, "reads must resolve by RawPath");
var seen = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => seen.Enqueue(e);
var handle = await driver.SubscribeAsync([.. rawPaths.Values], TimeSpan.FromMilliseconds(200), Ct);
try
{
seen.ShouldNotBeEmpty("subscribe primes every reference from the /current snapshot");
seen.Select(e => e.FullReference)
.ShouldAllBe(reference => rawPaths.Values.Contains(reference));
}
finally
{
await driver.UnsubscribeAsync(handle, CancellationToken.None);
}
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
// ---- helpers ----
private void SkipIfDown()
{
if (agent.SkipReason is not null)
{
Assert.Skip(agent.SkipReason);
}
}
private static bool Is(string? value, string expected) =>
string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase);
/// <summary>The minimal Test Connect config: the probe reads nothing but <c>agentUri</c>.</summary>
private static string ProbeConfig(string agentUri) =>
JsonSerializer.Serialize(new { agentUri });
/// <summary>
/// A loopback port with nothing listening on it: bound to :0 to let the OS pick a free one,
/// then released. Racy in principle, effectively certain in practice, and far more reliable
/// than guessing an unused constant.
/// </summary>
private static int ClosedLoopbackPort()
{
var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
new()
{
AgentUri = agent.AgentUri,
DeviceName = deviceName,
RequestTimeoutMs = 15_000,
// Faster than the 1000 ms default so a subscription sees several distinct chunks inside
// StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
SampleIntervalMs = 200,
SampleCount = 500,
HeartbeatMs = 5_000,
// The background connectivity probe is off: it dials the Agent on its own cadence and
// would interleave request logs with the ones a failing test needs to be read from.
Probe = new MTConnectProbeOptions { Enabled = false },
};
private static string ConfigJson(MTConnectDriverOptions options, IEnumerable<MTConnectTagDefinition>? tags = null) =>
JsonSerializer.Serialize(new
{
agentUri = options.AgentUri,
deviceName = options.DeviceName,
requestTimeoutMs = options.RequestTimeoutMs,
sampleIntervalMs = options.SampleIntervalMs,
sampleCount = options.SampleCount,
heartbeatMs = options.HeartbeatMs,
probe = new { enabled = false },
tags = (tags ?? []).Select(t => new
{
fullName = t.FullName,
driverDataType = t.DriverDataType.ToString(),
}),
});
private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
{
var options = LiveOptions();
var driver = new MTConnectDriver(options, "mtconnect-live");
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
return new LiveDriver(driver);
}
/// <summary>Initializes a driver and captures one full <c>DiscoverAsync</c> pass.</summary>
private async Task<DiscoveryCapture> DiscoverAsync()
{
await using var live = await LiveDriverAsync([]);
var capture = new DiscoveryCapture();
await live.Driver.DiscoverAsync(capture, Ct);
return capture;
}
/// <summary>The single captured variable for a DataItem id, asserting it was streamed exactly once.</summary>
private static CapturedVariable Leaf(DiscoveryCapture capture, string dataItemId)
{
var matches = capture.Variables.Where(v => v.Attr.FullName == dataItemId).ToList();
matches.Count.ShouldBe(1, $"DataItem '{dataItemId}' must be streamed exactly once by discovery");
return matches[0];
}
/// <summary>The Agent's <c>/current</c> snapshot, fetched straight over HTTP.</summary>
/// <remarks>
/// Read outside the driver on purpose: several assertions need to know what the Agent is
/// reporting <i>right now</i> in order to build their expectation, and computing that with
/// the parser under test would only prove the code agrees with itself.
/// </remarks>
private async Task<XDocument> CurrentAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);
return XDocument.Parse(body);
}
/// <summary>
/// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
/// a read or subscription can make a non-vacuous statement about.
/// </summary>
/// <remarks>
/// <b>The Agent's own self-model is excluded, and that exclusion is load-bearing.</b> Every
/// MTConnect 2.x Agent publishes an <c>&lt;Agent&gt;</c> device whose
/// <c>OBSERVATION_UPDATE_RATE</c> / <c>ASSET_UPDATE_RATE</c> SAMPLEs tick continuously
/// regardless of whether any adapter is attached. Including them made
/// <see cref="Subscribe_delivers_a_changed_value_from_the_live_sample_stream"/> pass with the
/// fixture's data source deliberately stopped (verified) — it was proving the Agent's own
/// heartbeat, not the device stream. Restricted to <c>&lt;Device&gt;</c>-owned samples, an
/// Agent with no live source yields an empty set and the test skips with an actionable
/// message instead.
/// </remarks>
private async Task<IReadOnlyList<string>> NumericSampleIdsAsync()
{
var declaredSamples = agent.DeclaredDataItems
.Where(d => Is(d.Category, "SAMPLE") && !d.InAgentSelfModel && !d.InComposition)
.Where(d => !Is(d.Representation, "TIME_SERIES") && !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
.Select(d => d.Id)
.ToHashSet(StringComparer.Ordinal);
return [.. (await CurrentAsync())
.Descendants()
.Where(e => !e.HasElements)
.Select(e => ((string?)e.Attribute("dataItemId"), e.Value.Trim()))
.Where(pair => pair.Item1 is { Length: > 0 } && declaredSamples.Contains(pair.Item1))
.Where(pair => double.TryParse(pair.Item2, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
.Select(pair => pair.Item1!)
.Distinct(StringComparer.Ordinal)];
}
/// <summary>
/// Scope guard: <see cref="MTConnectDriver"/> is not disposable (its teardown is
/// <c>ShutdownAsync</c>), and a test that failed mid-way would otherwise leave a live
/// <c>/sample</c> long poll running against the fixture for the rest of the session.
/// </summary>
private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
{
public MTConnectDriver Driver { get; } = driver;
public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise wire-level behavior against a live Agent — the analyzer's documented intentional
case. Mirrors the NoWarn the MTConnect unit suite carries for the same reason. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
</ItemGroup>
<!-- The fixture (compose + agent.cfg + Devices.xml + adapter.py) beside the test binaries, so
the skip message can point at a compose file that is actually next to the runner — the
same convention the Modbus / S7 / AB fixtures use. -->
<ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>
@@ -0,0 +1,449 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The shared <see cref="IMTConnectAgentClient"/> test double: an Agent that serves the canned
/// <c>Fixtures/probe.xml</c> + <c>Fixtures/current.xml</c> documents, counts every call, records
/// its own disposal, and lets a test drive the <c>/sample</c> chunk sequence by hand.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deterministic by construction — no timers, no sleeps, no polling.</b> The
/// <c>/sample</c> leg reads from an unbounded <see cref="Channel{T}"/> that only a test
/// fills, and <see cref="PumpAsync"/> does not return until the driver has actually consumed
/// the chunk it wrote (each chunk carries its own completion source, signalled after the
/// enumerator's <c>yield return</c> resumes). A subscription test can therefore say "one
/// chunk has now been fully processed" as a fact rather than as a timing hope.
/// </para>
/// <para>
/// <b>It honours the seam's stream-end contract</b> (see
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
/// enumeration ends without throwing. Closing the scripted stream via
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
/// production client does when an Agent drops the connection — so a pump written against the
/// fake cannot silently pass while mishandling the real one.
/// </para>
/// <para>
/// <b>Disposal is observable</b> (<see cref="DisposeCount"/>) because "did the driver
/// actually release the client?" is a behaviour, not an implementation detail: a
/// <c>ReinitializeAsync</c> that re-points the Agent but leaks the old client keeps a live
/// connection pool per re-deploy, and nothing else in the test surface would notice.
/// </para>
/// </remarks>
internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly CancellationTokenSource _disposeCts = new();
private readonly Lock _sampleWaitersLock = new();
private readonly List<(int Threshold, TaskCompletionSource Waiter)> _sampleWaiters = [];
/// <summary>Chunks a test has scripted but not yet pumped — see <see cref="ScriptChunks"/>.</summary>
private readonly ConcurrentQueue<MTConnectStreamsResult> _script = new();
/// <summary>
/// The <b>current</b> <c>/sample</c> stream generation. Replaced (not merely completed) by
/// <see cref="EndStream"/> so that a pump which reconnects after a dropped stream gets a
/// live channel to read from instead of one that is permanently closed — without that, a
/// reconnect test could only ever observe the reconnect failing.
/// </summary>
private Channel<ScriptedChunk> _chunks = Channel.CreateUnbounded<ScriptedChunk>();
private int _probeCallCount;
private int _currentCallCount;
private int _sampleCallCount;
private int _disposeCount;
private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
{
Probe = probe;
Current = current;
}
/// <summary>
/// Builds a client serving the repo's canned fixtures — <c>Fixtures/probe.xml</c> for
/// <c>/probe</c> and <c>Fixtures/current.xml</c> for <c>/current</c>.
/// </summary>
/// <param name="probeFixture">Probe-document fixture path, relative to the test binaries.</param>
/// <param name="currentFixture">Streams-document fixture path, relative to the test binaries.</param>
public static CannedAgentClient FromFixtures(
string probeFixture = "Fixtures/probe.xml",
string currentFixture = "Fixtures/current.xml") =>
new(
MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)),
MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture)));
/// <summary>Parses a streams fixture into a chunk a test can script onto the sample stream.</summary>
/// <param name="fixture">Streams-document fixture path, relative to the test binaries.</param>
public static MTConnectStreamsResult Chunk(string fixture) =>
MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
/// <summary>
/// An Agent scripted for the ring-buffer-overflow story: the driver primes from
/// <c>Fixtures/current.xml</c> (<c>nextSequence</c> 108), then the first scripted chunk is
/// <c>Fixtures/sample-gap.xml</c> — whose <c>firstSequence</c> (5000) is strictly newer than
/// the cursor, i.e. the buffer rolled past the driver — and the second is an ordinary chunk
/// that continues contiguously from the re-baseline.
/// </summary>
/// <remarks>
/// The <b>second</b> <c>/current</c> answer is what makes this a story rather than a single
/// event: it advertises the buffer the gap chunk revealed (<c>firstSequence</c> 5000 /
/// <c>nextSequence</c> 5005), so a driver that re-baselines and resumes from that answer sees
/// the follow-up chunk as contiguous. A driver that re-baselined but resumed from the stale
/// cursor would report a gap on every subsequent chunk instead.
/// </remarks>
public static CannedAgentClient WithGapThenResume()
{
var client = FromFixtures();
var gap = Chunk("Fixtures/sample-gap.xml");
// 1st /current = the InitializeAsync prime (the fixture). 2nd = the post-gap re-baseline.
client.CurrentAnswers.Enqueue(client.Current);
client.CurrentAnswers.Enqueue(
new MTConnectStreamsResult(gap.InstanceId, gap.NextSequence, gap.FirstSequence, gap.Observations));
client.ScriptChunks(
gap,
new MTConnectStreamsResult(
gap.InstanceId,
gap.NextSequence + 5,
gap.NextSequence,
[
new MTConnectObservation(
"dev1_pos", "201.5000", new DateTime(2026, 7, 24, 12, 6, 0, DateTimeKind.Utc)),
]));
return client;
}
/// <summary>The document <c>/probe</c> answers with. Settable so a test can re-shape the model.</summary>
public MTConnectProbeModel Probe { get; set; }
/// <summary>
/// The document <c>/current</c> answers with. Settable so a re-baseline (or an agent-restart
/// <c>instanceId</c> change) can be scripted between calls.
/// </summary>
public MTConnectStreamsResult Current { get; set; }
/// <summary>When set, <c>/probe</c> throws this instead of answering.</summary>
public Exception? ProbeFailure { get; set; }
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
public Exception? CurrentFailure { get; set; }
/// <summary>
/// Scripted <c>/current</c> answers, consumed in order: each call dequeues the next one and
/// <b>promotes it to <see cref="Current"/></b>, so once the script runs out the Agent keeps
/// answering with the last thing it said rather than travelling back in time. This is how a
/// test scripts "the prime, then the post-gap re-baseline" without racing the pump for a
/// property setter.
/// </summary>
public ConcurrentQueue<MTConnectStreamsResult> CurrentAnswers { get; } = new();
/// <summary>
/// Scripted one-shot <c>/sample</c> failures: each enumeration dequeues one and throws it.
/// Models a transient stream fault the Agent recovers from — e.g. the
/// <see cref="InvalidDataException"/> a real cppagent's <c>OUT_OF_RANGE</c> error document
/// (served under HTTP 200) surfaces as.
/// </summary>
public ConcurrentQueue<Exception> SampleFailures { get; } = new();
/// <summary>
/// A <b>sticky</b> <c>/sample</c> failure: every enumeration throws it, forever. Models a
/// configuration-level fault such as
/// <see cref="MTConnectStreamNotSupportedException"/>, where reconnecting reproduces the
/// identical answer — the shape a pump must NOT retry-loop against.
/// </summary>
public Exception? SampleFailure { get; set; }
/// <summary>
/// When set, the <b>next</b> <c>/probe</c> parks here until the test completes it, then the
/// gate clears itself so later calls answer immediately.
/// </summary>
/// <remarks>
/// This is how a test holds a request "in flight" across a concurrent lifecycle change —
/// a shutdown or a re-initialize landing mid-fetch — with no timers and no races of its own.
/// The answer is captured when the request lands, not when it completes, so a test can
/// change <see cref="Probe"/> meanwhile and still tell the two documents apart.
/// </remarks>
public TaskCompletionSource? ProbeGate { get; set; }
/// <summary>
/// When set, <c>/current</c> does not answer until this source completes — an Agent that
/// accepted the request and then went quiet. The wait is cancellation-observing, so the
/// caller's own deadline is what ends it; the test never sleeps and never races a timer it
/// did not set. Leave <c>null</c> for the ordinary immediate answer.
/// </summary>
public TaskCompletionSource? CurrentGate { get; set; }
/// <summary>
/// Signalled the moment a <c>/current</c> request <b>lands</b> — before
/// <see cref="CurrentGate"/> parks it. This is the deterministic "the caller is now inside
/// the request" barrier a test needs before landing a concurrent lifecycle change on it;
/// without it the only alternative is polling <see cref="CurrentCallCount"/> on a timer.
/// </summary>
public TaskCompletionSource? CurrentEntered { get; set; }
/// <summary>Number of <c>/probe</c> requests issued against this client.</summary>
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
/// <summary>Number of <c>/current</c> requests issued against this client.</summary>
public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
/// <summary>Number of <c>/sample</c> enumerations started against this client.</summary>
public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
/// <summary>Number of <see cref="Dispose"/> calls (not clamped — a double-dispose is visible).</summary>
public int DisposeCount => Volatile.Read(ref _disposeCount);
/// <summary>Whether the client has been disposed at least once.</summary>
public bool IsDisposed => DisposeCount > 0;
/// <summary>The <c>from</c> sequence the most recent <c>/sample</c> enumeration was opened at.</summary>
public long? LastSampleFrom { get; private set; }
/// <summary>
/// A task completing once <see cref="SampleCallCount"/> has reached
/// <paramref name="count"/> — the deterministic "the pump has now opened its Nth stream"
/// barrier, for the reconnect paths where no chunk is ever delivered and
/// <see cref="PumpAsync"/> therefore cannot be the barrier.
/// </summary>
/// <param name="count">The enumeration count to wait for.</param>
public Task WaitForSampleCallsAsync(int count)
{
lock (_sampleWaitersLock)
{
if (Volatile.Read(ref _sampleCallCount) >= count)
{
return Task.CompletedTask;
}
var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_sampleWaiters.Add((count, waiter));
return waiter.Task;
}
}
/// <summary>Releases any <see cref="WaitForSampleCallsAsync"/> waiter the new call count satisfies.</summary>
private void ReleaseSampleWaiters(int reached)
{
lock (_sampleWaitersLock)
{
for (var i = _sampleWaiters.Count - 1; i >= 0; i--)
{
if (_sampleWaiters[i].Threshold > reached)
{
continue;
}
_sampleWaiters[i].Waiter.TrySetResult();
_sampleWaiters.RemoveAt(i);
}
}
}
/// <inheritdoc/>
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _probeCallCount);
if (ProbeFailure is not null)
{
throw ProbeFailure;
}
// Captured now: the Agent answers with the document it held when the request landed.
var answer = Probe;
if (ProbeGate is { } gate)
{
ProbeGate = null;
await gate.Task.WaitAsync(ct).ConfigureAwait(false);
// A real client whose handler was disposed mid-request fails the request; so does this.
ObjectDisposedException.ThrowIf(IsDisposed, this);
}
return answer;
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _currentCallCount);
CurrentEntered?.TrySetResult();
// The request was accepted; the answer is withheld until the test releases the gate or the
// caller's deadline cancels the token.
var gate = CurrentGate;
if (gate is not null)
{
await gate.Task.WaitAsync(ct).ConfigureAwait(false);
}
// A scripted answer supersedes — and then becomes — the standing one.
if (CurrentAnswers.TryDequeue(out var scripted))
{
Current = scripted;
}
return CurrentFailure is null ? Current : throw CurrentFailure;
}
/// <inheritdoc/>
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from, [EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ReleaseSampleWaiters(Interlocked.Increment(ref _sampleCallCount));
LastSampleFrom = from;
// Thrown before the first chunk, exactly like a real client that fails its /sample request:
// one-shot scripts first, then the sticky "this endpoint will never stream" failure.
if (SampleFailures.TryDequeue(out var oneShot))
{
throw oneShot;
}
if (SampleFailure is { } sticky)
{
throw sticky;
}
// Bound to THIS generation: EndStream swaps in a fresh channel, so a consumer that
// reconnects reads the new one rather than the closed one it just lost.
var chunks = Volatile.Read(ref _chunks);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
var delivered = 0L;
while (true)
{
ScriptedChunk scripted;
try
{
scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
}
catch (ChannelClosedException)
{
// Mirrors the production client: a stream that stops for any reason other than the
// caller cancelling is an exception, never a quiet end of enumeration.
throw new MTConnectStreamEndedException(
MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered);
}
delivered++;
try
{
yield return scripted.Result;
}
finally
{
// Signalled when the consumer is DONE with this chunk — whether it came back for the
// next one or abandoned the enumeration. The finally is load-bearing: a chunk that
// makes the pump break out (a sequence gap, an agent restart) is never resumed past,
// so signalling only after the resume would hang every re-baseline test.
scripted.Consumed.TrySetResult();
}
}
}
/// <summary>
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
/// ran".
/// </summary>
/// <param name="chunk">The chunk the Agent should send next.</param>
/// <returns>A task completing once the enumerating consumer has moved past <paramref name="chunk"/>.</returns>
public Task PumpAsync(MTConnectStreamsResult chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
var scripted = new ScriptedChunk(
chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
if (!Volatile.Read(ref _chunks).Writer.TryWrite(scripted))
{
throw new InvalidOperationException("The scripted /sample stream is already closed.");
}
return scripted.Consumed.Task;
}
/// <summary>
/// Queues chunks for <see cref="PumpOnce"/> to deliver one at a time. Nothing is sent until
/// a test asks for it — the script is a plan, not a schedule.
/// </summary>
/// <param name="chunks">The chunks the Agent should send, in order.</param>
public void ScriptChunks(params MTConnectStreamsResult[] chunks)
{
ArgumentNullException.ThrowIfNull(chunks);
foreach (var chunk in chunks)
{
_script.Enqueue(chunk);
}
}
/// <summary>
/// Pumps the next scripted chunk (see <see cref="ScriptChunks"/>) and waits until the
/// consumer has finished with it.
/// </summary>
/// <returns>A task completing once the consumer has processed — or abandoned the stream on — that chunk.</returns>
/// <exception cref="InvalidOperationException">The script is exhausted.</exception>
public Task PumpOnce() =>
_script.TryDequeue(out var next)
? PumpAsync(next)
: throw new InvalidOperationException(
"No scripted /sample chunk left to pump; call ScriptChunks first.");
/// <summary>
/// Closes the current scripted <c>/sample</c> stream, which surfaces to its consumer as an
/// <see cref="MTConnectStreamEndedException"/> — the transient, reconnectable end — and opens
/// a fresh one so a reconnecting consumer has somewhere to land.
/// </summary>
public void EndStream()
{
var closed = Interlocked.Exchange(ref _chunks, Channel.CreateUnbounded<ScriptedChunk>());
closed.Writer.TryComplete();
}
/// <inheritdoc/>
/// <remarks>
/// <para>
/// <b>The first-disposer test uses the value <see cref="Interlocked.Increment(ref int)"/>
/// returned</b>, not a re-read of the counter. Re-reading is a race: two concurrent
/// disposes can both increment and then both read 2, so BOTH take the early return and
/// the stream is never closed — a subscription test would then hang waiting for a
/// teardown that silently did nothing.
/// </para>
/// <para>
/// <b><see cref="_disposeCts"/> is cancelled but deliberately NOT disposed.</b> A
/// <c>/sample</c> enumeration that is starting concurrently reaches
/// <c>CreateLinkedTokenSource(ct, _disposeCts.Token)</c>, and reading <c>.Token</c> on a
/// disposed source throws <see cref="ObjectDisposedException"/> — a flake that would
/// surface as a random unrelated failure in whichever test happened to lose the race.
/// Leaking one cancelled source per fake is free; a cancelled source needs no disposal
/// to release anything a test cares about.
/// </para>
/// </remarks>
public void Dispose()
{
if (Interlocked.Increment(ref _disposeCount) > 1)
{
return;
}
_disposeCts.Cancel();
Volatile.Read(ref _chunks).Writer.TryComplete();
}
private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
}
@@ -0,0 +1,132 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that records the tree a driver streams into it, so
/// <c>DiscoverAsync</c> can be asserted on <b>shape</b> (which node landed under which folder)
/// and not merely on a flat list of leaves.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a reference to the shipped capturing builder.</b> Two
/// production capturing builders exist — <c>Commons.Browsing.CapturingAddressSpaceBuilder</c>
/// (universal browser) and <c>Runtime.Drivers.CapturingAddressSpaceBuilder</c> (deploy-time
/// discovery) — but this suite deliberately references only the driver and its
/// <c>.Contracts</c>, so neither is reachable without pulling the whole server stack into a
/// driver unit-test project. Both also flatten differently from what these tests must prove:
/// the browser's node id for a folder is a synthetic path while a leaf's id is its
/// <c>FullName</c>, which is exactly the conflation a "did the device-level data item land in
/// the device folder?" assertion needs to see through.
/// </para>
/// <para>
/// <b>Deliberately not thread-safe.</b> A driver streams discovery on one caller; recording
/// behind a lock would hide a driver that fanned out onto other threads.
/// </para>
/// </remarks>
internal sealed class CapturingBuilder : IAddressSpaceBuilder
{
private readonly State _state;
private readonly string _path;
/// <summary>Creates a root builder — the scope a driver's <c>DiscoverAsync</c> is handed.</summary>
public CapturingBuilder()
{
_state = new State();
_path = string.Empty;
}
private CapturingBuilder(State state, string path)
{
_state = state;
_path = path;
}
/// <summary>Every folder streamed, in call order, with the slash-joined path it landed at.</summary>
public IReadOnlyList<CapturedFolder> Folders => _state.Folders;
/// <summary>Every variable streamed, in call order, with the folder path it landed under.</summary>
public IReadOnlyList<CapturedVariable> Variables => _state.Variables;
/// <summary>Every property streamed, with the scope path it was added to.</summary>
public IReadOnlyList<CapturedProperty> Properties => _state.Properties;
/// <inheritdoc/>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
_state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
return new CapturingBuilder(_state, path);
}
/// <inheritdoc/>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
_state.Variables.Add(captured);
return new CapturedVariableHandle(captured);
}
/// <inheritdoc/>
public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
_state.Properties.Add(new CapturedProperty(_path, browseName, dataType, value));
private sealed class State
{
public List<CapturedFolder> Folders { get; } = [];
public List<CapturedVariable> Variables { get; } = [];
public List<CapturedProperty> Properties { get; } = [];
}
private sealed class CapturedVariableHandle(CapturedVariable variable) : IVariableHandle
{
public string FullReference => variable.Attr.FullName;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
variable.AlarmConditions.Add(info);
return new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
// Nothing to record: no test in this suite drives an alarm transition through
// discovery, and a sink that threw would fail a driver that legitimately never
// pushes one.
}
}
}
}
/// <summary>One folder captured from a discovery stream.</summary>
/// <param name="Path">Slash-joined path of the folder itself.</param>
/// <param name="ParentPath">Slash-joined path of the scope it was added to (empty at the root).</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
/// <summary>One variable captured from a discovery stream.</summary>
/// <param name="ParentPath">Slash-joined path of the folder it landed under (empty at the root).</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
/// <param name="Attr">The driver-side attribute metadata the driver stamped on it.</param>
internal sealed record CapturedVariable(
string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
{
/// <summary>Alarm conditions the driver marked this variable with, if any.</summary>
public List<AlarmConditionInfo> AlarmConditions { get; } = [];
}
/// <summary>One property captured from a discovery stream.</summary>
/// <param name="ScopePath">Slash-joined path of the node it was added to.</param>
/// <param name="BrowseName">The property browse name.</param>
/// <param name="DataType">The property data type.</param>
/// <param name="Value">The property value.</param>
internal sealed record CapturedProperty(string ScopePath, string BrowseName, DriverDataType DataType, object? Value);
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.3 http://www.mtconnect.org/schemas/MTConnectStreams_1.3.xsd">
<!-- Every observation UNAVAILABLE - Task 8's quality-mapping fixture. -->
<Header creationTime="2026-07-24T12:10:00.600Z"
sender="fixture-agent"
instanceId="1655000000"
version="1.3.0.12"
assetBufferSize="1024"
assetCount="0"
bufferSize="131072"
firstSequence="200"
lastSequence="206"
nextSequence="207"/>
<Streams>
<DeviceStream name="VMC-3Axis" uuid="dev1-uuid">
<ComponentStream component="Device" componentId="dev1" name="dev1">
<Events>
<Availability dataItemId="dev1_avail" timestamp="2026-07-24T12:10:00.000Z" sequence="200">UNAVAILABLE</Availability>
</Events>
</ComponentStream>
<ComponentStream component="Path" componentId="dev1_path" name="path">
<Samples>
<Position dataItemId="dev1_pos" timestamp="2026-07-24T12:10:00.100Z" sequence="201" subType="ACTUAL">UNAVAILABLE</Position>
<PathFeedrate dataItemId="dev1_vibration_ts" timestamp="2026-07-24T12:10:00.200Z" sequence="202"
sampleCount="10" sampleRate="100">UNAVAILABLE</PathFeedrate>
</Samples>
<Events>
<PartCount dataItemId="dev1_partcount" timestamp="2026-07-24T12:10:00.300Z" sequence="203">UNAVAILABLE</PartCount>
<Execution dataItemId="dev1_execution" timestamp="2026-07-24T12:10:00.400Z" sequence="204">UNAVAILABLE</Execution>
<Program dataItemId="dev1_program" timestamp="2026-07-24T12:10:00.500Z" sequence="205">UNAVAILABLE</Program>
</Events>
<Condition>
<Unavailable dataItemId="dev1_system_cond" timestamp="2026-07-24T12:10:00.600Z" sequence="206" type="SYSTEM"/>
</Condition>
</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.3 http://www.mtconnect.org/schemas/MTConnectStreams_1.3.xsd">
<Header creationTime="2026-07-24T12:00:00.700Z"
sender="fixture-agent"
instanceId="1655000000"
version="1.3.0.12"
assetBufferSize="1024"
assetCount="0"
bufferSize="131072"
firstSequence="1"
lastSequence="107"
nextSequence="108"/>
<Streams>
<DeviceStream name="VMC-3Axis" uuid="dev1-uuid">
<ComponentStream component="Device" componentId="dev1" name="dev1">
<Events>
<Availability dataItemId="dev1_avail" timestamp="2026-07-24T12:00:00.100Z" sequence="101">AVAILABLE</Availability>
</Events>
</ComponentStream>
<ComponentStream component="Path" componentId="dev1_path" name="path">
<Samples>
<!-- Good/present value used by Task 10's snapshot-read test -->
<Position dataItemId="dev1_pos" timestamp="2026-07-24T12:00:00.200Z" sequence="102" subType="ACTUAL">123.4567</Position>
<PathFeedrate dataItemId="dev1_vibration_ts" timestamp="2026-07-24T12:00:00.300Z" sequence="103"
sampleCount="10" sampleRate="100">1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0</PathFeedrate>
</Samples>
<Events>
<!-- UNAVAILABLE value used by Task 8's quality-mapping (BadNoCommunication) test -->
<PartCount dataItemId="dev1_partcount" timestamp="2026-07-24T12:00:00.400Z" sequence="104">UNAVAILABLE</PartCount>
<Execution dataItemId="dev1_execution" timestamp="2026-07-24T12:00:00.500Z" sequence="105">ACTIVE</Execution>
<Program dataItemId="dev1_program" timestamp="2026-07-24T12:00:00.600Z" sequence="106">O1234</Program>
</Events>
<Condition>
<Normal dataItemId="dev1_system_cond" timestamp="2026-07-24T12:00:00.700Z" sequence="107" type="SYSTEM"/>
</Condition>
</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectDevices:1.3 http://www.mtconnect.org/schemas/MTConnectDevices_1.3.xsd">
<Header creationTime="2026-07-24T12:00:00Z"
sender="fixture-agent"
instanceId="1655000000"
version="1.3.0.12"
assetBufferSize="1024"
assetCount="0"
bufferSize="131072"/>
<Devices>
<Device id="dev1" name="VMC-3Axis" uuid="dev1-uuid">
<Description manufacturer="Fixture Corp">Test fixture device for MTConnect driver unit tests</Description>
<DataItems>
<!-- EVENT, controlled-vocabulary, directly on the Device (no nested Component) -->
<DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
</DataItems>
<Components>
<Controller id="dev1_controller" name="controller">
<Components>
<Path id="dev1_path" name="path">
<DataItems>
<!-- SAMPLE with units -->
<DataItem id="dev1_pos" category="SAMPLE" type="POSITION" subType="ACTUAL"
units="MILLIMETER" nativeUnits="MILLIMETER"/>
<!-- SAMPLE, representation=TIME_SERIES with sampleCount -->
<DataItem id="dev1_vibration_ts" category="SAMPLE" type="PATH_FEEDRATE"
representation="TIME_SERIES" units="MILLIMETER/SECOND"
nativeUnits="MILLIMETER/SECOND" sampleCount="10" sampleRate="100"/>
<!-- EVENT, numeric type -->
<DataItem id="dev1_partcount" category="EVENT" type="PART_COUNT"/>
<!-- EVENT, controlled vocabulary -->
<DataItem id="dev1_execution" category="EVENT" type="EXECUTION"/>
<!-- EVENT, free text -->
<DataItem id="dev1_program" category="EVENT" type="PROGRAM"/>
<!-- CONDITION -->
<DataItem id="dev1_system_cond" category="CONDITION" type="SYSTEM"/>
</DataItems>
</Path>
</Components>
</Controller>
</Components>
</Device>
</Devices>
</MTConnectDevices>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.3 http://www.mtconnect.org/schemas/MTConnectStreams_1.3.xsd">
<!--
Ring-buffer-overflow / forced-nextSequence-gap fixture.
firstSequence (5000) is strictly greater than any requestedFrom the driver would plausibly
have used to continue from current.xml/sample.xml (last nextSequence was 113), and is also
comfortably greater than the requestedFrom=1 asserted by Task 7's IsSequenceGap(1, chunk)==true
test. instanceId is identical to current.xml/sample.xml so the gap - not an agent restart -
is what's under test.
-->
<Header creationTime="2026-07-24T12:05:00.000Z"
sender="fixture-agent"
instanceId="1655000000"
version="1.3.0.12"
assetBufferSize="1024"
assetCount="0"
bufferSize="131072"
firstSequence="5000"
lastSequence="5004"
nextSequence="5005"/>
<Streams>
<DeviceStream name="VMC-3Axis" uuid="dev1-uuid">
<ComponentStream component="Path" componentId="dev1_path" name="path">
<Samples>
<Position dataItemId="dev1_pos" timestamp="2026-07-24T12:05:00.100Z" sequence="5000" subType="ACTUAL">200.0000</Position>
<PathFeedrate dataItemId="dev1_vibration_ts" timestamp="2026-07-24T12:05:00.200Z" sequence="5001"
sampleCount="10" sampleRate="100">3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 4.0</PathFeedrate>
</Samples>
<Events>
<PartCount dataItemId="dev1_partcount" timestamp="2026-07-24T12:05:00.300Z" sequence="5002">99</PartCount>
<Execution dataItemId="dev1_execution" timestamp="2026-07-24T12:05:00.400Z" sequence="5003">ACTIVE</Execution>
<Program dataItemId="dev1_program" timestamp="2026-07-24T12:05:00.500Z" sequence="5004">O5678</Program>
</Events>
</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.3 http://www.mtconnect.org/schemas/MTConnectStreams_1.3.xsd">
<!-- Continues contiguously from current.xml: firstSequence (108) == the nextSequence current.xml advertised. -->
<Header creationTime="2026-07-24T12:00:01.200Z"
sender="fixture-agent"
instanceId="1655000000"
version="1.3.0.12"
assetBufferSize="1024"
assetCount="0"
bufferSize="131072"
firstSequence="108"
lastSequence="112"
nextSequence="113"/>
<Streams>
<DeviceStream name="VMC-3Axis" uuid="dev1-uuid">
<ComponentStream component="Path" componentId="dev1_path" name="path">
<Samples>
<Position dataItemId="dev1_pos" timestamp="2026-07-24T12:00:00.800Z" sequence="108" subType="ACTUAL">124.0100</Position>
<PathFeedrate dataItemId="dev1_vibration_ts" timestamp="2026-07-24T12:00:00.900Z" sequence="109"
sampleCount="10" sampleRate="100">2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0</PathFeedrate>
</Samples>
<Events>
<PartCount dataItemId="dev1_partcount" timestamp="2026-07-24T12:00:01.000Z" sequence="110">42</PartCount>
<Execution dataItemId="dev1_execution" timestamp="2026-07-24T12:00:01.100Z" sequence="111">ACTIVE</Execution>
<Program dataItemId="dev1_program" timestamp="2026-07-24T12:00:01.200Z" sequence="112">O1234</Program>
</Events>
</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
@@ -0,0 +1,311 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Golden table for <see cref="MTConnectDataTypeInference.Infer" /> (design §3.3).
/// Three call sites must agree byte-for-byte on this mapping — <c>ITagDiscovery.DiscoverAsync</c>,
/// the universal browser's commit path, and the AdminUI typed tag editor — so the table is
/// pinned here rather than re-derived at each seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDataTypeInferenceTests
{
// ---------------------------------------------------------------------
// The design §3.3 golden table.
// ---------------------------------------------------------------------
[Theory]
// SAMPLE numeric with units → Float64.
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
[InlineData("SAMPLE", "SpindleSpeed", "REVOLUTION/MINUTE", "VALUE", DriverDataType.Float64)]
// EVENT numeric type → Int64.
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
[InlineData("EVENT", "Line", null, null, DriverDataType.Int64)]
// EVENT controlled vocabulary → String.
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
[InlineData("EVENT", "ControllerMode", null, null, DriverDataType.String)]
[InlineData("EVENT", "Availability", null, null, DriverDataType.String)]
// EVENT free text → String.
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
[InlineData("EVENT", "Block", null, null, DriverDataType.String)]
[InlineData("EVENT", "Message", null, null, DriverDataType.String)]
// CONDITION → String (the state word).
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
[InlineData("CONDITION", "SystemCondition", null, null, DriverDataType.String)]
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
// ---------------------------------------------------------------------
// The SAME golden table, spelled the way a real agent's /probe document
// spells it. MTConnect writes the same concept two ways: the Devices
// (/probe) document's `DataItem@type` is UPPER_SNAKE (PART_COUNT), while
// the Streams (/current, /sample) document's observation element name is
// PascalCase (PartCount). `DiscoverAsync` feeds the *probe* spelling, so
// an inference that only knows the PascalCase spelling is green in tests
// and wrong live. Every row below is a DataItem from Fixtures/probe.xml.
// ---------------------------------------------------------------------
[Theory]
[InlineData("EVENT", "PART_COUNT", null, DriverDataType.Int64)] // dev1_partcount
[InlineData("SAMPLE", "POSITION", "MILLIMETER", DriverDataType.Float64)] // dev1_pos
[InlineData("EVENT", "EXECUTION", null, DriverDataType.String)] // dev1_execution
[InlineData("EVENT", "PROGRAM", null, DriverDataType.String)] // dev1_program
[InlineData("EVENT", "AVAILABILITY", null, DriverDataType.String)] // dev1_avail
[InlineData("CONDITION", "SYSTEM", null, DriverDataType.String)] // dev1_system_cond
public void Infer_maps_the_probe_documents_UPPER_SNAKE_spelling(
string cat, string type, string? units, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, null).DataType.ShouldBe(expected);
[Fact]
public void The_probes_TIME_SERIES_item_is_a_float64_array_of_its_declared_sample_count()
{
// dev1_vibration_ts: category=SAMPLE type=PATH_FEEDRATE representation=TIME_SERIES sampleCount=10.
var r = MTConnectDataTypeInference.Infer(
"SAMPLE", "PATH_FEEDRATE", "MILLIMETER/SECOND", "TIME_SERIES", sampleCount: 10);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(10u);
}
[Theory]
[InlineData("PART_COUNT", "PartCount")]
[InlineData("LINE_NUMBER", "LineNumber")]
[InlineData("part_count", "partcount")]
[InlineData("PART-COUNT", "PartCount")]
[InlineData("PART_COUNT", " Part Count ")]
public void The_two_document_spellings_of_one_type_infer_identically(string probeSpelling, string streamsSpelling)
{
// The equivalence is the whole point: a tag discovered from /probe and the same tag
// named from a /current observation must land on one type, or the authored config
// stops matching the value that arrives.
var fromProbe = MTConnectDataTypeInference.Infer("EVENT", probeSpelling, null, null);
var fromStreams = MTConnectDataTypeInference.Infer("EVENT", streamsSpelling, null, null);
fromProbe.ShouldBe(fromStreams);
fromProbe.DataType.ShouldBe(DriverDataType.Int64);
}
[Theory]
[InlineData("PART_COUNTER")]
[InlineData("PARTS_COUNT")]
[InlineData("LINE_NUMBERS")]
[InlineData("_")]
public void Separator_insensitivity_does_not_widen_the_numeric_EVENT_exception_list(string type)
// Falsifiability control: ignoring separators must not turn a *different* type into a
// match. The exception list stays exactly PartCount / Line / LineNumber.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(8u);
}
// ---------------------------------------------------------------------
// Array shape: only a SAMPLE/TIME_SERIES is an array, and ArrayDim is
// only populated when the probe declared a usable sampleCount.
// ---------------------------------------------------------------------
[Fact]
public void TimeSeries_without_a_declared_sample_count_is_a_variable_length_array()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES");
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TimeSeries_with_a_non_positive_sample_count_reports_no_dimension(int declared)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", declared);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("SAMPLE", "Position", "VALUE")]
[InlineData("SAMPLE", "Position", null)]
[InlineData("EVENT", "PartCount", "VALUE")]
[InlineData("CONDITION", "Temperature", null)]
public void A_scalar_item_is_never_an_array(string cat, string type, string? rep)
{
var r = MTConnectDataTypeInference.Infer(cat, type, null, rep);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Fact]
public void A_declared_sample_count_is_ignored_when_the_item_is_not_a_time_series()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "VALUE", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("EVENT")]
[InlineData("CONDITION")]
public void TIME_SERIES_outside_the_SAMPLE_category_is_not_treated_as_an_array(string cat)
{
// TIME_SERIES is defined only for SAMPLE. Honouring it elsewhere would hand the
// address space an array node whose observations are scalars.
var r = MTConnectDataTypeInference.Infer(cat, "Anything", null, "TIME_SERIES", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
r.DataType.ShouldBe(DriverDataType.String);
}
// ---------------------------------------------------------------------
// The defaulting rule for unrecognised `type` values — the part of the
// table that is a *rule*, not an enumeration. MTConnect defines hundreds
// of types; the fallback per category is what actually ships.
// ---------------------------------------------------------------------
[Theory]
[InlineData("Xacceleration")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_SAMPLE_type_still_defaults_to_Float64(string? type)
=> MTConnectDataTypeInference.Infer("SAMPLE", type, null, null).DataType.ShouldBe(DriverDataType.Float64);
[Fact]
public void A_SAMPLE_without_units_is_still_numeric()
// SAMPLE is numeric by definition in the standard; a missing `units` attribute is a
// gap in the device model, not evidence that the observation is text.
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", null, null).DataType
.ShouldBe(DriverDataType.Float64);
[Theory]
[InlineData("ToolNumber")]
[InlineData("PalletId")]
[InlineData("LineLabel")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_EVENT_type_defaults_to_String(string? type)
// Fail-safe direction: a wrong numeric coercion produces a Bad-quality value at runtime,
// whereas String always round-trips and the author can override.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
[Theory]
[InlineData("LineNumber")]
public void The_modern_spelling_of_a_known_numeric_EVENT_is_also_numeric(string type)
// `Line` was superseded by `LineNumber`; mapping only the deprecated spelling would
// silently mistype the same data item on any current-version agent.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("Anything")]
[InlineData(null)]
public void Every_CONDITION_is_a_String_regardless_of_type(string? type)
=> MTConnectDataTypeInference.Infer("CONDITION", type, "CELSIUS", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Unknown / missing category — probe XML in the wild is inconsistent.
// ---------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("ASSET")]
[InlineData("nonsense")]
public void An_unknown_or_missing_category_falls_back_to_String(string? cat)
// We cannot know the observation is numeric, so we choose the type that always
// round-trips rather than one that can fail to parse.
=> MTConnectDataTypeInference.Infer(cat, "Position", "MILLIMETER", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Case / whitespace tolerance.
// ---------------------------------------------------------------------
[Theory]
[InlineData("sample", DriverDataType.Float64)]
[InlineData("Sample", DriverDataType.Float64)]
[InlineData(" SAMPLE ", DriverDataType.Float64)]
[InlineData("condition", DriverDataType.String)]
public void Category_matching_is_case_and_whitespace_insensitive(string cat, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, "Position", "MM", null).DataType.ShouldBe(expected);
[Theory]
[InlineData("partcount")]
[InlineData("PARTCOUNT")]
[InlineData(" PartCount ")]
public void Numeric_EVENT_type_matching_is_case_and_whitespace_insensitive(string type)
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("time_series")]
[InlineData("Time_Series")]
[InlineData(" TIME_SERIES ")]
public void Representation_matching_is_case_and_whitespace_insensitive(string rep)
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", rep, 4).IsArray.ShouldBeTrue();
// ---------------------------------------------------------------------
// Non-scalar representations. A DATA_SET / TABLE observation is a
// key-value map, not a number — typing it Float64 guarantees Bad quality.
// ---------------------------------------------------------------------
[Theory]
[InlineData("DATA_SET")]
[InlineData("TABLE")]
[InlineData("data_set")]
public void A_key_value_representation_is_a_String_even_on_a_SAMPLE(string rep)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", rep);
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void A_key_value_representation_also_demotes_a_numeric_EVENT()
{
var r = MTConnectDataTypeInference.Infer("EVENT", "PartCount", null, "DATA_SET");
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void DISCRETE_is_a_scalar_representation_and_does_not_change_the_type()
{
// DISCRETE changes *when* the agent reports, not what the value is.
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", "DISCRETE");
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeFalse();
}
// ---------------------------------------------------------------------
// Purity — the three call sites rely on identical results for identical
// inputs with no shared state between calls.
// ---------------------------------------------------------------------
[Fact]
public void Infer_is_deterministic()
{
var first = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
var second = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
second.ShouldBe(first);
}
}
@@ -0,0 +1,774 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 12 — <see cref="MTConnectDriver"/>'s <see cref="ITagDiscovery"/> half: the
/// <c>/probe</c> device model streamed into an <see cref="IAddressSpaceBuilder"/> as
/// Device → Component → DataItem, and the one-member browse opt-in
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) the Wave-0 universal browser keys off.
/// </summary>
/// <remarks>
/// <para>
/// <b>The load-bearing assertion is <c>FullName == DataItem@id</c>.</b> The universal
/// browser commits a browsed leaf's <see cref="DriverAttributeInfo.FullName"/> straight into
/// <c>TagConfig.FullName</c>, and that is the key <c>ReadAsync</c> / the <c>/sample</c> pump
/// resolve an observation against (<see cref="MTConnectObservationIndex"/> is keyed by
/// <c>DataItem@id</c>). Streaming the browse <i>name</i> instead would produce a picker that
/// looks perfect and commits tags that can never report a value — every one of them stuck at
/// <c>BadWaitingForInitialData</c> behind a Healthy driver.
/// </para>
/// <para>
/// <b>The second load-bearing assertion is the type shape.</b> Discovery must stamp exactly
/// what <see cref="MTConnectDataTypeInference"/> returns, including the array shape — the
/// AdminUI typed editor calls the same function, so any local recomputation here makes the
/// picker and the editor disagree about the same data item. <c>PART_COUNT → Int64</c> is
/// called out on its own because the probe document's UPPER_SNAKE spelling is the case a
/// PascalCase-only match silently types as <c>String</c>.
/// </para>
/// </remarks>
public sealed class MTConnectDiscoverTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>Every DataItem id the canned <c>Fixtures/probe.xml</c> declares.</summary>
private static readonly string[] FixtureDataItemIds =
[
"dev1_avail",
"dev1_pos",
"dev1_vibration_ts",
"dev1_partcount",
"dev1_execution",
"dev1_program",
"dev1_system_cond",
];
private static MTConnectDriverOptions Opts(string? deviceName = null) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
// Discovery is a pure read of the /probe device model: it must enumerate what the Agent
// declares, NOT what an operator has already authored. Left empty on purpose so a driver
// that streamed its authored tag table instead could not pass.
Tags = [],
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions? options = null,
MTConnectProbeModel? probe = null,
RecordingDriverLogger? logger = null)
{
var client = CannedAgentClient.FromFixtures();
if (probe is not null)
{
client.Probe = probe;
}
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client, logger), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
MTConnectDriverOptions? options = null,
MTConnectProbeModel? probe = null,
RecordingDriverLogger? logger = null)
{
var (driver, client) = NewDriver(options, probe, logger);
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (driver, client);
}
private static async Task<CapturingBuilder> DiscoverAsync(MTConnectDriver driver)
{
var builder = new CapturingBuilder();
await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken);
return builder;
}
// ---- the plan's headline test ----
/// <summary>
/// The whole contract in one place: the fixture's Device → Controller → Path tree reaches the
/// builder, the CONDITION data item is present under its own <c>id</c>, it types as
/// <see cref="DriverDataType.String"/>, and the driver declares the browse opt-in the
/// universal browser gates on.
/// </summary>
[Fact]
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev1_system_cond");
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
driver.SupportsOnlineDiscovery.ShouldBeTrue();
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
}
// ---- tree shape ----
/// <summary>
/// The folder tree mirrors the probe document exactly: one folder per Device (named by its
/// <c>name</c>), then one per nested Component to arbitrary depth, each under its own parent.
/// </summary>
[Fact]
public async Task Discover_materialises_a_folder_per_device_and_nested_component()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(
["VMC-3Axis", "VMC-3Axis/controller", "VMC-3Axis/controller/path"], ignoreOrder: true);
// Parentage, not just membership: a flat "three folders exist" assertion passes for a walker
// that streamed all three onto the root builder.
cap.Folders.Single(f => f.Path == "VMC-3Axis").ParentPath.ShouldBe(string.Empty);
cap.Folders.Single(f => f.Path == "VMC-3Axis/controller").ParentPath.ShouldBe("VMC-3Axis");
cap.Folders.Single(f => f.Path == "VMC-3Axis/controller/path").ParentPath.ShouldBe("VMC-3Axis/controller");
}
/// <summary>
/// <c>dev1_avail</c> hangs directly off <c>&lt;Device&gt;&lt;DataItems&gt;</c>, outside every
/// Component. A walker that only recursed <c>&lt;Components&gt;</c> would drop it silently, so
/// it is asserted to land in the <b>device</b> folder while the Path-owned items land in the
/// path folder.
/// </summary>
[Fact]
public async Task Discover_places_device_level_data_items_in_the_device_folder()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_avail").ParentPath.ShouldBe("VMC-3Axis");
cap.Variables
.Where(v => v.Attr.FullName != "dev1_avail")
.ShouldAllBe(v => v.ParentPath == "VMC-3Axis/controller/path");
}
/// <summary>
/// Every DataItem the fixture declares is streamed exactly once, and every leaf's
/// <see cref="DriverAttributeInfo.FullName"/> is its <c>DataItem@id</c> — the read/subscribe
/// key, aligned with the browser's commit by construction.
/// </summary>
[Fact]
public async Task Discover_streams_every_data_item_once_with_fullname_equal_to_its_id()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Count.ShouldBe(FixtureDataItemIds.Length);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
}
/// <summary>
/// The <c>FullName</c>-is-the-<c>id</c> rule, asserted where the two can actually differ.
/// </summary>
/// <remarks>
/// <b>The fixture cannot prove this on its own</b> — its data items declare no <c>name</c>, so
/// <c>browseName</c> and <c>id</c> coincide and a driver that streamed the browse name as the
/// <c>FullName</c> would pass every fixture-based assertion in this file. This model gives
/// every data item a name that is disjoint from its id, so the two sets cannot be confused:
/// the commit key must be the id set, and the presentation must be the name set.
/// </remarks>
[Fact]
public async Task Discover_fullname_is_the_id_even_when_the_data_item_declares_a_different_name()
{
var probe = ModelWith(
new MTConnectDataItem("id_pos", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
new MTConnectDataItem("id_count", "PartsMade", "EVENT", "PART_COUNT", null, null, null, null),
new MTConnectDataItem("id_cond", "SystemHealth", "CONDITION", "SYSTEM", null, null, null, null),
new MTConnectDataItem(
"id_series", "Feed", "SAMPLE", "PATH_FEEDRATE", null, null, "TIME_SERIES", 10));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName)
.ShouldBe(["id_pos", "id_count", "id_cond", "id_series"], ignoreOrder: true);
cap.Variables.Select(v => v.BrowseName)
.ShouldBe(["Xpos", "PartsMade", "SystemHealth", "Feed"], ignoreOrder: true);
// …and the id-keyed metadata still lands on the right leaf once the two differ.
cap.Variables.Single(v => v.Attr.FullName == "id_count").Attr.DriverDataType
.ShouldBe(DriverDataType.Int64);
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.FullName.ShouldBe("id_cond");
cap.Variables.Single(v => v.Attr.IsArray).Attr.FullName.ShouldBe("id_series");
}
// ---- type shape (must equal MTConnectDataTypeInference, not a local recomputation) ----
/// <summary>
/// The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled
/// vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String.
/// </summary>
[Theory]
[InlineData("dev1_pos", DriverDataType.Float64)]
[InlineData("dev1_avail", DriverDataType.String)]
[InlineData("dev1_execution", DriverDataType.String)]
[InlineData("dev1_program", DriverDataType.String)]
[InlineData("dev1_system_cond", DriverDataType.String)]
public async Task Discover_stamps_the_inferred_data_type(string dataItemId, DriverDataType expected)
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == dataItemId).Attr.DriverDataType.ShouldBe(expected);
}
/// <summary>
/// <c>PART_COUNT</c> is the live-only bug this driver's inference was already fixed for once:
/// the probe document spells it UPPER_SNAKE, so a match written against the Streams
/// document's <c>PartCount</c> types every real agent's part counter as
/// <see cref="DriverDataType.String"/> while a PascalCase unit test stays green. Asserted at
/// the discovery seam because that is where the probe spelling is actually read.
/// </summary>
[Fact]
public async Task Discover_types_the_upper_snake_PART_COUNT_event_as_Int64()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_partcount").Attr.DriverDataType
.ShouldBe(DriverDataType.Int64);
}
/// <summary>
/// A <c>TIME_SERIES</c> SAMPLE is an array whose length is the probe-declared
/// <c>sampleCount</c>. The shape comes from <see cref="MTConnectDataTypeInference"/>'s
/// returned record — recomputing it inline at this seam is what makes discovery and the
/// AdminUI editor disagree.
/// </summary>
[Fact]
public async Task Discover_carries_the_time_series_array_shape_from_the_inference()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
var series = cap.Variables.Single(v => v.Attr.FullName == "dev1_vibration_ts").Attr;
series.DriverDataType.ShouldBe(DriverDataType.Float64);
series.IsArray.ShouldBeTrue();
series.ArrayDim.ShouldBe(10u);
}
/// <summary>
/// A <c>DATA_SET</c> SAMPLE demotes to <see cref="DriverDataType.String"/> and stays scalar —
/// a rule that lives <b>only</b> inside the inference. An inline
/// "IsArray = representation == TIME_SERIES, DataType = category == SAMPLE ? Float64 : String"
/// at this seam would type it Float64 and go Bad on every parse, and no fixture-only test
/// would notice.
/// </summary>
[Fact]
public async Task Discover_demotes_a_data_set_sample_to_string_per_the_inference()
{
var probe = ModelWith(
new MTConnectDataItem(
"dev9_toolset", null, "SAMPLE", "POSITION", null, "MILLIMETER", "DATA_SET", 4));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_toolset").Attr;
attr.DriverDataType.ShouldBe(DriverDataType.String);
attr.IsArray.ShouldBeFalse();
attr.ArrayDim.ShouldBeNull();
}
/// <summary>
/// <c>TIME_SERIES</c> is defined for SAMPLE only; on an EVENT the inference ignores it. Pins
/// the other half of "use the inference's answer, do not recompute the array shape".
/// </summary>
[Fact]
public async Task Discover_ignores_time_series_on_a_non_sample_per_the_inference()
{
var probe = ModelWith(
new MTConnectDataItem("dev9_msg", null, "EVENT", "MESSAGE", null, null, "TIME_SERIES", 8));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_msg").Attr;
attr.DriverDataType.ShouldBe(DriverDataType.String);
attr.IsArray.ShouldBeFalse();
attr.ArrayDim.ShouldBeNull();
}
// ---- the flags this seam owns ----
/// <summary>
/// <see cref="DriverAttributeInfo.IsAlarm"/> is the caller's job — the inference deliberately
/// does not compute it. Exactly the CONDITION data item carries it.
/// </summary>
[Fact]
public async Task Discover_marks_only_the_condition_data_item_as_an_alarm()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Where(v => v.Attr.IsAlarm).Select(v => v.Attr.FullName).ShouldBe(["dev1_system_cond"]);
}
/// <summary>
/// MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
/// browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
/// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
/// never inferred from a device model.
/// </summary>
[Fact]
public async Task Discover_streams_every_leaf_view_only_and_not_historized()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.ViewOnly);
cap.Variables.ShouldAllBe(v => !v.Attr.IsHistorized);
cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
}
// ---- browse names ----
/// <summary>
/// The browse name is the DataItem's <c>name</c> when it declares one and its <c>id</c>
/// otherwise — the fixture's data items declare none, so the fallback is the normal case for
/// this protocol, not an edge case.
/// </summary>
[Fact]
public async Task Discover_browse_name_prefers_the_data_item_name_and_falls_back_to_its_id()
{
var probe = ModelWith(
new MTConnectDataItem("dev9_named", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
new MTConnectDataItem("dev9_unnamed", null, "EVENT", "PROGRAM", null, null, null, null));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var named = cap.Variables.Single(v => v.Attr.FullName == "dev9_named");
named.BrowseName.ShouldBe("Xpos");
named.DisplayName.ShouldBe("Xpos");
var unnamed = cap.Variables.Single(v => v.Attr.FullName == "dev9_unnamed");
unnamed.BrowseName.ShouldBe("dev9_unnamed");
// The browse name is presentation; the read key is not. Restated here because this is the
// one test where the two legitimately differ.
named.Attr.FullName.ShouldBe("dev9_named");
}
/// <summary>
/// A Device or Component with no <c>name</c> attribute falls back to its <c>id</c> rather
/// than materialising a blank folder.
/// </summary>
[Fact]
public async Task Discover_folder_names_fall_back_to_the_id_when_no_name_is_declared()
{
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9",
null,
[
new MTConnectComponent(
"dev9_axes",
null,
[],
[new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null)]),
],
[]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["dev9", "dev9/dev9_axes"], ignoreOrder: true);
cap.Variables.Single().ParentPath.ShouldBe("dev9/dev9_axes");
}
// ---- DeviceName scoping ----
/// <summary>
/// With no <c>DeviceName</c> the driver is agent-wide and every device's subtree is streamed.
/// The control for the scoping test below — without it, a discovery that dropped the second
/// device for an unrelated reason would look like correct scoping.
/// </summary>
[Fact]
public async Task Discover_without_a_device_scope_streams_every_device()
{
var (driver, _) = await InitializedDriverAsync(probe: TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.Where(f => f.ParentPath.Length == 0).Select(f => f.BrowseName)
.ShouldBe(["VMC-3Axis", "Lathe-2Axis"], ignoreOrder: true);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev2_avail");
}
/// <summary>
/// With <c>DeviceName</c> set, only that device's subtree is streamed.
/// </summary>
/// <remarks>
/// Belt-and-braces rather than dead code: the production client already narrows the request
/// to <c>{AgentUri}/{DeviceName}/probe</c>, so a conforming Agent answers with one device
/// anyway. But a non-conforming Agent (or a proxy that drops the path segment) that answered
/// agent-wide would otherwise publish every other machine's data items into an instance
/// scoped to one — and the operator's only evidence would be a picker full of ids they never
/// configured.
/// </remarks>
[Fact]
public async Task Discover_with_a_device_scope_streams_only_that_device_subtree()
{
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "Lathe-2Axis"), TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["Lathe-2Axis"]);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev2_avail"]);
}
/// <summary>
/// A device scope naming a device the Agent does not declare streams nothing. Kept explicit
/// because the alternative reading ("scope did not match, so stream everything") would hand a
/// mis-typed device name a picker showing the whole plant.
/// </summary>
[Fact]
public async Task Discover_with_an_unmatched_device_scope_streams_nothing()
{
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "not-a-device"), TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.ShouldBeEmpty();
cap.Variables.ShouldBeEmpty();
}
/// <summary>
/// A device with no <c>name</c> is addressable by its <c>id</c> — which is what a scoped
/// Agent URL would carry for it too.
/// </summary>
[Fact]
public async Task Discover_device_scope_matches_a_nameless_device_by_id()
{
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9", null, [], [new MTConnectDataItem("dev9_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
new MTConnectDevice(
"dev8", "Other", [], [new MTConnectDataItem("dev8_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
]);
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "dev9"), probe);
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
}
/// <summary>
/// A configured <c>DeviceName</c> that matches nothing is an authoring error whose only
/// symptom is an empty picker. It must be reported at <b>Warning</b> — at Debug the operator
/// sees a browse that "worked" and a machine that apparently declares nothing, with no
/// evidence pointing at their typo.
/// </summary>
[Fact]
public async Task Discover_warns_when_the_configured_device_scope_matches_nothing()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "no-such-device"), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("no-such-device", StringComparison.Ordinal));
}
/// <summary>
/// …and an agent-wide browse that finds nothing is NOT that warning: it is a different
/// statement (the Agent declares no devices) and must not cry scope-typo.
/// </summary>
[Fact]
public async Task Discover_does_not_warn_about_scope_when_no_scope_is_configured()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(probe: new MTConnectProbeModel([]), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot().ShouldBeEmpty();
}
/// <summary>
/// A component declaring neither a <c>name</c> nor an <c>id</c> has no browse path at all,
/// and folding it in would open a folder called <c>""</c> — a blank segment in the path of
/// everything beneath it. It is skipped; its siblings are unaffected. (A component with a
/// blank id but a real name is fine — the id is only the fallback.)
/// </summary>
[Fact]
public async Task Discover_skips_a_component_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9",
"VMC",
[
new MTConnectComponent(" ", " ", [], [item]),
new MTConnectComponent(" ", "Axes", [], [item]),
new MTConnectComponent("dev9_ctrl", null, [], [item]),
],
[]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC", "VMC/Axes", "VMC/dev9_ctrl"], ignoreOrder: true);
cap.Folders.ShouldAllBe(f => !f.Path.Contains("//", StringComparison.Ordinal));
}
/// <summary>The same rule one level up: a device with no browse path is skipped, and said so.</summary>
[Fact]
public async Task Discover_skips_a_device_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var logger = new RecordingDriverLogger();
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(" ", " ", [], [item]),
new MTConnectDevice("dev9", "VMC", [], [item]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe, logger: logger);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC"]);
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("neither a name nor an id", StringComparison.Ordinal));
}
// ---- the probe-cache contract (anti-wedge) ----
/// <summary>
/// Discovery is served from the cache <c>InitializeAsync</c> already populated — a browse
/// does not re-hit the Agent for a model it is holding.
/// </summary>
[Fact]
public async Task Discover_serves_from_the_cached_probe_model()
{
var (driver, client) = await InitializedDriverAsync();
client.ProbeCallCount.ShouldBe(1);
await DiscoverAsync(driver);
client.ProbeCallCount.ShouldBe(1);
}
/// <summary>
/// …and after <see cref="MTConnectDriver.FlushOptionalCachesAsync"/> has dropped that cache,
/// discovery re-fetches and still streams the full tree. This is the whole reason discovery
/// must go through <c>GetOrFetchProbeModelAsync</c> rather than reading the cached field: a
/// memory-budget flush must never leave browse permanently answering "this agent has no data
/// items".
/// </summary>
[Fact]
public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.CachedProbeModel.ShouldBeNull();
var cap = await DiscoverAsync(driver);
client.ProbeCallCount.ShouldBe(2);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
driver.CachedProbeModel.ShouldNotBeNull();
}
// ---- discovery never touches the subscription surface ----
/// <summary>
/// Discovery is a pure read of the device model: it does not write the shared observation
/// index (the <c>/sample</c> pump is its sole writer) and it does not issue a <c>/current</c>.
/// A discovery that folded a snapshot into the index would be a second writer racing the
/// pump, rolling subscribed values backwards.
/// </summary>
[Fact]
public async Task Discover_does_not_touch_the_observation_index_or_call_current()
{
var options = new MTConnectDriverOptions
{
AgentUri = AgentUri,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
};
var (driver, client) = await InitializedDriverAsync(options);
var index = driver.ObservationIndex;
var indexed = index.Count;
var currentCalls = client.CurrentCallCount;
await DiscoverAsync(driver);
ReferenceEquals(driver.ObservationIndex, index).ShouldBeTrue();
driver.ObservationIndex.Count.ShouldBe(indexed);
client.CurrentCallCount.ShouldBe(currentCalls);
}
// ---- failure posture: loud, never a silently-empty tree ----
/// <summary>
/// Discovery before <c>InitializeAsync</c> throws rather than streaming an empty tree.
/// </summary>
/// <remarks>
/// <b>This is the deliberate opposite of <see cref="MTConnectDriver.ReadAsync"/>'s
/// never-throw posture, and the difference is what the caller can do with the answer.</b> A
/// read has per-reference Bad status codes to report a failure <i>in-band</i>; a discovery
/// stream has no such channel — an empty tree is indistinguishable from "this Agent declares
/// no data items", which is the #485 shape (failure wearing success's clothes) and would read
/// to an operator in the browse picker as a working connection to an empty machine. Both
/// production callers handle the throw: the universal browser surfaces it as a failed browse,
/// and <c>DriverInstanceActor</c> logs it, publishes an empty node set, and does NOT retry
/// (retrying is an <c>UntilStable</c> behaviour; this driver is <c>Once</c>) — which today
/// reaches nothing anyway, since <c>DriverHostActor</c>'s discovered-node injection is
/// dormant in v3. The browse path is the consumer this posture is chosen for.
/// </remarks>
[Fact]
public async Task Discover_before_initialize_throws_and_streams_nothing()
{
var (driver, client) = NewDriver();
var builder = new CapturingBuilder();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Folders.ShouldBeEmpty();
builder.Variables.ShouldBeEmpty();
client.ProbeCallCount.ShouldBe(0);
}
/// <summary>Same posture after <c>ShutdownAsync</c> — a stopped driver has no model to browse.</summary>
[Fact]
public async Task Discover_after_shutdown_throws_and_streams_nothing()
{
var (driver, _) = await InitializedDriverAsync();
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
var builder = new CapturingBuilder();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Variables.ShouldBeEmpty();
}
/// <summary>
/// An Agent that fails the <c>/probe</c> a discovery needs fails the discovery — it does not
/// degrade to an empty tree. Same #485 reasoning as the un-initialized case.
/// </summary>
[Fact]
public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
client.ProbeFailure = new HttpRequestException("agent down");
var builder = new CapturingBuilder();
await Should.ThrowAsync<HttpRequestException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Variables.ShouldBeEmpty();
}
/// <summary>Caller cancellation propagates, as it does on every other seam of this driver.</summary>
[Fact]
public async Task Discover_propagates_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
() => driver.DiscoverAsync(new CapturingBuilder(), cts.Token));
}
/// <summary>A null builder is a caller bug — the one thing this method is loud about up front.</summary>
[Fact]
public async Task Discover_with_a_null_builder_throws_ArgumentNullException()
{
var (driver, _) = await InitializedDriverAsync();
await Should.ThrowAsync<ArgumentNullException>(
() => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken));
}
// ---- the browse opt-in ----
/// <summary>
/// The universal browser's <c>CanBrowse</c> constructs a throwaway instance purely to read
/// these two members, so they must answer on an <b>un-initialized</b> driver without
/// dialling anything.
/// </summary>
[Fact]
public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting()
{
var (driver, client) = NewDriver();
driver.ShouldBeAssignableTo<ITagDiscovery>();
driver.SupportsOnlineDiscovery.ShouldBeTrue();
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
client.ProbeCallCount.ShouldBe(0);
client.CurrentCallCount.ShouldBe(0);
}
// ---- helpers ----
/// <summary>A one-device model whose single Path component owns <paramref name="dataItems"/>.</summary>
private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) =>
new(
[
new MTConnectDevice(
"dev9",
"Probe9",
[new MTConnectComponent("dev9_path", "path", [], dataItems)],
[]),
]);
/// <summary>
/// The canned fixture's device plus a second, independent one — the multi-device Agent the
/// <c>DeviceName</c> scope exists for.
/// </summary>
private static MTConnectProbeModel TwoDeviceModel()
{
var fixture = MTConnectProbeParser.Parse(File.ReadAllText("Fixtures/probe.xml"));
var second = new MTConnectDevice(
"dev2",
"Lathe-2Axis",
[],
[new MTConnectDataItem("dev2_avail", null, "EVENT", "AVAILABILITY", null, null, null, null)]);
return new MTConnectProbeModel([.. fixture.Devices, second]);
}
}
@@ -0,0 +1,734 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 9 — <see cref="MTConnectDriver"/>'s <see cref="IDriver"/> lifecycle over the
/// <see cref="IMTConnectAgentClient"/> seam: construction, initialize, reinitialize, shutdown,
/// health, footprint, and cache flush. Every test runs against
/// <see cref="CannedAgentClient"/>; no socket is opened anywhere in this file.
/// </summary>
/// <remarks>
/// The load-bearing assertions here are the ones about <b>what must NOT happen</b>: a
/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
/// defect the happy-path assertions alone would pass straight over.
/// </remarks>
public sealed class MTConnectDriverLifecycleTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>Every dataItemId the canned probe declares — the browse-cache footprint basis.</summary>
private const int FixtureDataItemCount = 7;
private static MTConnectDriverOptions Opts(
string agentUri = AgentUri,
string? deviceName = null,
int requestTimeoutMs = 5000,
int heartbeatMs = 10000,
int sampleIntervalMs = 1000,
int sampleCount = 1000) =>
new()
{
AgentUri = agentUri,
DeviceName = deviceName,
RequestTimeoutMs = requestTimeoutMs,
HeartbeatMs = heartbeatMs,
SampleIntervalMs = sampleIntervalMs,
SampleCount = sampleCount,
Tags =
[
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
],
};
/// <summary>A driver wired to one canned client, plus that client, for the common case.</summary>
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions? options = null)
{
var client = CannedAgentClient.FromFixtures();
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync()
{
var (driver, client) = NewDriver();
await driver.InitializeAsync("{}", CancellationToken.None);
return (driver, client);
}
// ---- connection-free construction ----
[Fact]
public void Constructor_dials_nothing_and_does_not_even_build_a_client()
{
var factoryCalls = 0;
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
{
factoryCalls++;
return client;
});
// The universal discovery browser constructs a throwaway instance purely to ask CanBrowse.
// If the ctor built (or worse, dialled) a client, every browse probe would open a connection
// pool against a possibly-unreachable Agent.
factoryCalls.ShouldBe(0);
client.ProbeCallCount.ShouldBe(0);
client.CurrentCallCount.ShouldBe(0);
client.SampleCallCount.ShouldBe(0);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public void Identity_is_the_constructor_arguments()
{
var (driver, _) = NewDriver();
driver.DriverInstanceId.ShouldBe("mt1");
driver.DriverType.ShouldBe("MTConnect");
}
// ---- initialize ----
[Fact]
public async Task Initialize_primes_index_and_reports_healthy()
{
var (driver, client) = NewDriver();
await driver.InitializeAsync("{}", CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
client.ProbeCallCount.ShouldBe(1);
client.CurrentCallCount.ShouldBe(1);
// The /current snapshot is what makes the driver able to answer a read at all.
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
}
[Fact]
public async Task Initialize_records_the_agent_instance_id_and_the_probe_model()
{
var (driver, client) = NewDriver();
await driver.InitializeAsync("{}", CancellationToken.None);
driver.AgentInstanceId.ShouldBe(client.Current.InstanceId);
driver.CachedProbeModel.ShouldNotBeNull();
driver.CachedProbeModel!.Devices.Count.ShouldBe(1);
}
[Fact]
public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries()
{
var (driver, _) = NewDriver();
var before = DateTime.UtcNow;
await driver.InitializeAsync("{}", CancellationToken.None);
var after = DateTime.UtcNow;
var lastRead = driver.GetHealth().LastSuccessfulRead;
lastRead.ShouldNotBeNull();
lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
lastRead.Value.ShouldBeLessThanOrEqualTo(after);
}
[Fact]
public async Task Initialize_faults_and_rethrows_when_probe_fails()
{
var (driver, client) = NewDriver();
client.ProbeFailure = new HttpRequestException("agent unreachable");
var thrown = await Should.ThrowAsync<HttpRequestException>(
() => driver.InitializeAsync("{}", CancellationToken.None));
thrown.Message.ShouldBe("agent unreachable");
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNull();
health.LastError!.ShouldContain("agent unreachable");
// No half-initialized object: the client it built must not survive the failure.
client.IsDisposed.ShouldBeTrue();
}
[Fact]
public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
{
// Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose
// priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy
// there would render every tag BadWaitingForInitialData forever while the dashboard shows a
// green driver — the #485 "a failure that looks like success" shape.
var (driver, client) = NewDriver();
client.CurrentFailure = new InvalidDataException("current unparseable");
await Should.ThrowAsync<InvalidDataException>(
() => driver.InitializeAsync("{}", CancellationToken.None));
client.ProbeCallCount.ShouldBe(1);
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
driver.CachedProbeModel.ShouldBeNull();
driver.ObservationIndex.Count.ShouldBe(0);
client.IsDisposed.ShouldBeTrue();
}
/// <summary>
/// arch-review 01/S-6: an operator-authorable <c>0</c> must never come to mean "wait
/// forever" (or "ask the Agent for nothing"). All four knobs share one
/// <c>RequirePositive</c> path, so all four are asserted — a guard that covered only the
/// one knob with a test is exactly how three of them would quietly lose the check.
/// </summary>
/// <remarks>
/// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than
/// leaning on the production client's own constructor guard.
/// </remarks>
[Theory]
[InlineData("RequestTimeoutMs")]
[InlineData("HeartbeatMs")]
[InlineData("SampleIntervalMs")]
[InlineData("SampleCount")]
public async Task Initialize_rejects_a_non_positive_timing_knob_before_dialling(string knob)
{
var options = knob switch
{
"RequestTimeoutMs" => Opts(requestTimeoutMs: 0),
"HeartbeatMs" => Opts(heartbeatMs: 0),
"SampleIntervalMs" => Opts(sampleIntervalMs: 0),
"SampleCount" => Opts(sampleCount: 0),
_ => throw new ArgumentOutOfRangeException(nameof(knob), knob, "unhandled knob"),
};
var factoryCalls = 0;
var driver = new MTConnectDriver(options, "mt1", _ =>
{
factoryCalls++;
return CannedAgentClient.FromFixtures();
});
var thrown = await Should.ThrowAsync<ArgumentOutOfRangeException>(
() => driver.InitializeAsync("{}", CancellationToken.None));
// The message must name the offending key, or an operator cannot act on it.
thrown.Message.ShouldContain(knob);
factoryCalls.ShouldBe(0);
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
// ---- the driverConfigJson argument ----
[Fact]
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
{
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
// MTConnect config change a silent no-op that still seals the deployment green.
MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
seen = o;
return CannedAgentClient.FromFixtures();
});
await driver.InitializeAsync(
"""{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""",
CancellationToken.None);
seen.ShouldNotBeNull();
seen!.AgentUri.ShouldBe("http://other-agent:5001");
seen.DeviceName.ShouldBe("VMC-3Axis");
}
/// <summary>
/// "Semantically empty" is decided by parsing, not by matching the literal two-character
/// spellings. A pretty-printer, a formatter, or a hand edit turns <c>{}</c> into <c>{ }</c>
/// or <c>{\n}</c> without changing its meaning — and under literal matching each of those
/// reached ParseOptions, failed the required-AgentUri check, and turned an empty document
/// into a cold-start fault or a rejected deployment.
/// </summary>
[Theory]
[InlineData("{}")]
[InlineData("{ }")]
[InlineData("{\n}")]
[InlineData(" {\r\n\t} ")]
[InlineData("[]")]
[InlineData("[ ]")]
[InlineData("null")]
[InlineData("")]
[InlineData(" ")]
public async Task Initialize_with_a_semantically_empty_config_body_keeps_the_constructor_options(string json)
{
MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
seen = o;
return CannedAgentClient.FromFixtures();
});
await driver.InitializeAsync(json, CancellationToken.None);
seen.ShouldNotBeNull();
seen!.AgentUri.ShouldBe(AgentUri);
seen.Tags.Count.ShouldBe(2);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Initialize_treats_a_malformed_config_body_as_a_parse_error_not_as_empty()
{
// The other half of the emptiness rule: unreadable text must NOT fall through to "no config
// supplied" and silently start the driver on stale options.
var (driver, _) = NewDriver();
var thrown = await Should.ThrowAsync<InvalidOperationException>(
() => driver.InitializeAsync("""{"agentUri": """, CancellationToken.None));
thrown.Message.ShouldContain("not valid JSON");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task Initialize_faults_on_a_config_body_with_no_agent_uri()
{
var (driver, _) = NewDriver();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task Initialize_on_a_live_instance_with_an_unreadable_config_keeps_it_serving()
{
// One rule for both entry points: an unreadable document never destroys working state; it
// faults only a driver that had none. InitializeAsync IS reachable on a live instance
// (DriverInstanceActor re-initialises during Connecting/Reconnecting), so parsing must
// happen BEFORE the teardown — otherwise a bad edit kills a healthy client and the driver
// ends up in exactly the state ReinitializeAsync is written to avoid.
var (driver, client) = await InitializedDriverAsync();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.InitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
client.IsDisposed.ShouldBeFalse();
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
}
[Fact]
public async Task Initialize_parses_authored_tags_out_of_the_config_json()
{
MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
seen = o;
return CannedAgentClient.FromFixtures();
});
await driver.InitializeAsync(
"""
{"agentUri":"http://a:5000",
"tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]}
""",
CancellationToken.None);
seen.ShouldNotBeNull();
seen!.Tags.Count.ShouldBe(1);
seen.Tags[0].FullName.ShouldBe("dev1_partcount");
// Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES.
seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32);
}
// ---- reinitialize ----
[Fact]
public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client()
{
var built = new List<CannedAgentClient>();
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
{
var c = CannedAgentClient.FromFixtures();
built.Add(c);
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.ReinitializeAsync(
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
CancellationToken.None);
// Config-only: the live connection pool survives, so no client churn...
built.Count.ShouldBe(1);
built[0].IsDisposed.ShouldBeFalse();
// ...but everything DERIVED from config is genuinely rebuilt against the new document.
built[0].ProbeCallCount.ShouldBe(2);
built[0].CurrentCallCount.ShouldBe(2);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client()
{
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
var c = CannedAgentClient.FromFixtures();
built.Add((o, c));
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.ReinitializeAsync(
"""{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None);
// A re-pointed driver that kept the old client would keep reading the OLD machine while the
// deployment reports success.
built.Count.ShouldBe(2);
built[0].Client.IsDisposed.ShouldBeTrue();
built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000");
built[1].Client.IsDisposed.ShouldBeFalse();
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client()
{
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
var c = CannedAgentClient.FromFixtures();
built.Add((o, c));
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.ReinitializeAsync(
$$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None);
// DeviceName is baked into every request URI at client construction.
built.Count.ShouldBe(2);
built[0].Client.IsDisposed.ShouldBeTrue();
built[1].Options.DeviceName.ShouldBe("VMC-3Axis");
}
[Fact]
public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client()
{
// RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client
// CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client
// because the URI happened not to change would silently discard the operator's edit.
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
var c = CannedAgentClient.FromFixtures();
built.Add((o, c));
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.ReinitializeAsync(
$$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None);
built.Count.ShouldBe(2);
built[1].Options.HeartbeatMs.ShouldBe(2500);
built[0].Client.IsDisposed.ShouldBeTrue();
}
[Fact]
public async Task Reinitialize_before_initialize_behaves_as_initialize()
{
var (driver, client) = NewDriver();
await driver.ReinitializeAsync("{}", CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
client.ProbeCallCount.ShouldBe(1);
}
[Fact]
public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy()
{
var built = new List<CannedAgentClient>();
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
{
var c = CannedAgentClient.FromFixtures();
built.Add(c);
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
built[0].CurrentFailure = new HttpRequestException("agent went away");
// Config-only shape (only the tag list changes), so the failing leg is the re-prime against
// the client that is already live — the path a rebuild would otherwise mask.
await Should.ThrowAsync<HttpRequestException>(
() => driver.ReinitializeAsync(
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None));
built.Count.ShouldBe(1);
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away");
// A failed refresh must not leave a live client nobody owns.
built[0].IsDisposed.ShouldBeTrue();
}
[Fact]
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
{
// Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
// not down a driver that is happily serving its previous, valid configuration.
var (driver, client) = await InitializedDriverAsync();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
client.IsDisposed.ShouldBeFalse();
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
}
// ---- shutdown ----
[Fact]
public async Task Shutdown_disposes_the_client_and_drops_the_served_state()
{
var (driver, client) = await InitializedDriverAsync();
var lastRead = driver.GetHealth().LastSuccessfulRead;
await driver.ShutdownAsync(CancellationToken.None);
client.DisposeCount.ShouldBe(1);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
// Retained: it is diagnostic ("when did we last hear from the Agent"), not served data.
driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead);
// Dropped: values from a torn-down connection must never keep reading Good.
driver.ObservationIndex.Count.ShouldBe(0);
driver.CachedProbeModel.ShouldBeNull();
}
[Fact]
public async Task Shutdown_is_idempotent()
{
var (driver, client) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
await driver.ShutdownAsync(CancellationToken.None);
client.DisposeCount.ShouldBe(1);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public async Task Shutdown_before_initialize_is_a_no_op()
{
var (driver, client) = NewDriver();
await driver.ShutdownAsync(CancellationToken.None);
client.DisposeCount.ShouldBe(0);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
// ---- footprint + flush ----
[Fact]
public async Task Memory_footprint_grows_when_initialize_populates_the_caches()
{
var (driver, _) = NewDriver();
var before = driver.GetMemoryFootprint();
await driver.InitializeAsync("{}", CancellationToken.None);
var after = driver.GetMemoryFootprint();
// Before init the only config-attributable state is the two authored tags.
before.ShouldBeLessThan(after);
after.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
{
var (driver, _) = await InitializedDriverAsync();
var before = driver.GetMemoryFootprint();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeLessThan(before);
driver.CachedProbeModel.ShouldBeNull();
// The index is required for correctness (it IS the read surface) — never flushable.
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
{
// Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
// cache-budget breach would permanently disable browse on that driver instance.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
client.ProbeCallCount.ShouldBe(1);
var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
model.Devices.Count.ShouldBe(1);
client.ProbeCallCount.ShouldBe(2);
driver.CachedProbeModel.ShouldNotBeNull();
}
[Fact]
public async Task Probe_model_is_served_from_cache_while_it_is_warm()
{
var (driver, client) = await InitializedDriverAsync();
_ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
client.ProbeCallCount.ShouldBe(1);
}
[Fact]
public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty()
{
var (driver, _) = NewDriver();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
}
[Fact]
public async Task Fetching_the_probe_model_after_shutdown_is_rejected()
{
var (driver, _) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
}
// ---- lock-free session snapshot: the probe fetch races the lifecycle ----
[Fact]
public async Task A_shutdown_landing_mid_fetch_surfaces_as_not_connected_not_as_object_disposed()
{
// Browse is deliberately lock-free (it awaits the network; holding the lifecycle lock would
// let it stall a shutdown for a whole RequestTimeoutMs), so a fetch CAN outlive the client
// it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
// the caller already handles "not connected" and should have exactly one failure mode.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
// Held locally: the gate is one-shot, so the client has already cleared its own reference by
// the time the request is parked on it.
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.ProbeGate = gate;
var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
await driver.ShutdownAsync(CancellationToken.None);
client.IsDisposed.ShouldBeTrue();
gate.TrySetResult();
var thrown = await Should.ThrowAsync<InvalidOperationException>(() => inFlight);
thrown.InnerException.ShouldBeOfType<ObjectDisposedException>();
}
[Fact]
public async Task A_fetch_that_completes_after_a_reinitialize_does_not_overwrite_the_newer_cache()
{
// The probe cache is re-published only onto the session it was fetched against. Without
// that check, a slow browse would install a device model belonging to a superseded
// configuration over the one the re-initialize just established — stale browse output that
// nothing would ever correct.
var built = new List<CannedAgentClient>();
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
{
var c = CannedAgentClient.FromFixtures();
built.Add(c);
return c;
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var client = built[0];
var staleModel = client.Probe;
// Park a fetch on the OLD (about to be superseded) session; it captures staleModel now.
// Held locally — the gate is one-shot and clears the client's own reference when it parks.
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.ProbeGate = gate;
var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
// A config-only re-initialize: same client (so nothing is disposed), brand-new session.
var freshModel = new MTConnectProbeModel(staleModel.Devices);
client.Probe = freshModel;
await driver.ReinitializeAsync(
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
CancellationToken.None);
built.Count.ShouldBe(1);
driver.CachedProbeModel.ShouldBeSameAs(freshModel);
// Now let the stale fetch finish. It still returns what the Agent gave it...
gate.TrySetResult();
(await inFlight).ShouldBeSameAs(staleModel);
// ...but it must not have published that over the newer session's cache.
driver.CachedProbeModel.ShouldBeSameAs(freshModel);
}
[Fact]
public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
{
// Core polls the footprint and asks for a flush on its own schedule, so a flush arriving
// after the driver was torn down is ordinary, not exotic. It must observe the retired
// session and do nothing — publishing a "flushed" copy of it would reinstate a session
// holding an already-disposed client for every later caller.
//
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has
// no await point, so nothing can be interleaved between its read and its publish from a
// single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
// lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
// fetch-side CAS, which does have an await point, is covered by the test above.
var (driver, client) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.CachedProbeModel.ShouldBeNull();
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
client.DisposeCount.ShouldBe(1);
await Should.ThrowAsync<InvalidOperationException>(
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
}
}
@@ -0,0 +1,121 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 2 coverage for <see cref="MTConnectDriverOptions"/> / <see cref="MTConnectTagDefinition"/>:
/// defaults are sane (non-zero timers, probe on) and the required-field shape (AgentUri) binds.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDriverOptionsTests
{
/// <summary>Default sample/heartbeat timers are non-zero and the probe is enabled by default.</summary>
[Fact]
public void Options_default_sample_and_heartbeat_are_sane()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.SampleIntervalMs.ShouldBeGreaterThan(0);
o.HeartbeatMs.ShouldBeGreaterThan(0);
o.Probe.Enabled.ShouldBeTrue();
}
/// <summary>AgentUri is the only required field; DeviceName scope defaults to unset (agent-wide).</summary>
[Fact]
public void Options_agent_uri_is_required_device_name_defaults_to_null()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.AgentUri.ShouldBe("http://agent:5000");
o.DeviceName.ShouldBeNull();
}
/// <summary>RequestTimeoutMs and SampleCount default to sane, non-zero, non-wedging values.</summary>
[Fact]
public void Options_request_timeout_and_sample_count_are_non_zero()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.RequestTimeoutMs.ShouldBeGreaterThan(0);
o.SampleCount.ShouldBeGreaterThan(0);
}
/// <summary>Probe sub-options mirror ModbusProbeOptions' shape: Enabled/Interval/Timeout, both non-zero.</summary>
[Fact]
public void Probe_defaults_have_non_zero_interval_and_timeout()
{
var probe = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Probe;
probe.Interval.ShouldBeGreaterThan(TimeSpan.Zero);
probe.Timeout.ShouldBeGreaterThan(TimeSpan.Zero);
}
/// <summary>Reconnect sub-options default to a bounded, non-degenerate geometric backoff.</summary>
[Fact]
public void Reconnect_defaults_bound_backoff_growth()
{
var reconnect = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Reconnect;
reconnect.MaxBackoffMs.ShouldBeGreaterThan(reconnect.MinBackoffMs);
reconnect.BackoffMultiplier.ShouldBeGreaterThan(1.0);
}
/// <summary>
/// <see cref="MTConnectDriverOptions.Tags"/> defaults to an <i>empty</i> collection, never
/// <c>null</c> — a null collection here is an operator-authorable NullReferenceException at
/// deploy, since a driver instance authored with no tags binds this default.
/// </summary>
[Fact]
public void Options_tags_default_to_empty_and_are_never_null()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.Tags.ShouldNotBeNull();
o.Tags.ShouldBeEmpty();
}
/// <summary>
/// A populated options object carries the authored tag definitions through unchanged —
/// the observation index (Task 8) coerces each present observation to <i>its tag's</i>
/// DriverDataType, so the definitions must reach the driver from the factory config.
/// </summary>
[Fact]
public void Options_round_trip_the_authored_tag_definitions()
{
var pos = new MTConnectTagDefinition(
FullName: "dev1_pos",
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
MtCategory: "SAMPLE",
MtType: "POSITION",
Units: "MILLIMETER");
var partCount = new MTConnectTagDefinition(
FullName: "dev1_partcount",
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int64,
MtCategory: "EVENT",
MtType: "PART_COUNT");
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000", Tags = [pos, partCount] };
o.Tags.Count.ShouldBe(2);
o.Tags[0].ShouldBe(pos);
o.Tags[1].ShouldBe(partCount);
}
/// <summary>MTConnectTagDefinition round-trips its positional fields, including MTConnect metadata.</summary>
[Fact]
public void TagDefinition_carries_dataItemId_and_mtconnect_metadata()
{
var tag = new MTConnectTagDefinition(
FullName: "dtop_2",
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
MtCategory: "SAMPLE",
MtType: "POSITION",
MtSubType: "ACTUAL",
Units: "MILLIMETER");
tag.FullName.ShouldBe("dtop_2");
tag.DriverDataType.ShouldBe(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64);
tag.IsArray.ShouldBeFalse();
tag.ArrayDim.ShouldBe(0);
tag.MtCategory.ShouldBe("SAMPLE");
tag.MtType.ShouldBe("POSITION");
tag.MtSubType.ShouldBe("ACTUAL");
tag.Units.ShouldBe("MILLIMETER");
}
}
@@ -0,0 +1,319 @@
using System.Net;
using System.Net.Http;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 14 — <see cref="MTConnectDriverProbe"/>: the AdminUI Test Connect reachability probe.
/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
/// exercised through a stubbed <see cref="HttpMessageHandler"/> via the internal test-seam
/// constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
/// a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
// ── stub handler ─────────────────────────────────────────────────────────
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
respond(request, ct);
}
private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
{
Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
};
private static MTConnectDriverProbe ProbeWithHandler(
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond) =>
new(new StubHandler(respond));
private const string ValidProbeXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.3">
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Devices>
<Device id="dev1" name="StubDevice">
<DataItems>
<DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
</DataItems>
</Device>
</Devices>
</MTConnectDevices>
""";
private const string MTConnectErrorXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors>
<Error errorCode="NO_DEVICE">Could not find the device named 'nope'.</Error>
</Errors>
</MTConnectError>
""";
private const string NotMTConnectXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<Foo><Bar/></Foo>
""";
// ── from the plan (verbatim) ─────────────────────────────────────────────
[Fact]
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Probe_on_blank_config_returns_not_ok()
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
// ── unparseable JSON ──────────────────────────────────────────────────────
[Fact]
public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("invalid", Case.Insensitive);
}
// ── malformed / relative URI ─────────────────────────────────────────────
[Theory]
[InlineData("not a uri")]
[InlineData("/relative/path")]
[InlineData("ftp://host/probe")]
public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
// ── credential redaction ─────────────────────────────────────────────────
[Fact]
public async Task AgentUri_credentials_never_appear_in_the_message()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
TimeSpan.FromMilliseconds(300),
CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("sneaky-pass");
r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
}
// ── 200 body that is not MTConnect at all ────────────────────────────────
[Fact]
public async Task NonMTConnect_200_body_returns_not_ok()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Latency.ShouldBeNull();
}
// ── MTConnectError under HTTP 200 ────────────────────────────────────────
[Fact]
public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("NO_DEVICE");
r.Message.ShouldContain("Could not find the device named 'nope'.");
}
// ── valid MTConnectDevices body ───────────────────────────────────────────
[Fact]
public async Task Valid_probe_document_returns_ok_true_with_latency()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
r.Message.ShouldNotBeNull();
r.Latency.ShouldNotBeNull();
r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
}
// ── timeout bounded (hard hang-risk guard) ───────────────────────────────
[Fact(Timeout = 5000)]
public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
{
var probe = ProbeWithHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
throw new UnreachableException();
});
var sw = System.Diagnostics.Stopwatch.StartNew();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
sw.Stop();
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("timed out", Case.Insensitive);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
}
// ── non-positive timeout does not mean "wait forever" ────────────────────
[Fact(Timeout = 8000)]
public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
{
var probe = ProbeWithHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
throw new UnreachableException();
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);
r.Ok.ShouldBeFalse();
}
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
private sealed class UnreachableException : Exception;
// ── review follow-up: pathological (out-of-CancelAfter-range) timeout ────
/// <summary>
/// Reproduces the reviewer's finding: <c>CancelAfter</c>'s legal range tops out near ~49.7
/// days, and the caller-supplied <c>timeout</c> reached it uncapped (only floored on the low
/// end). A caller with no clamp of its own — unlike today's only caller,
/// <c>AdminOperationsActor</c>, which clamps to [1,60]s — must still get a
/// <see cref="Core.Abstractions.DriverProbeResult"/>, never an escaping
/// <see cref="ArgumentOutOfRangeException"/>: that is the whole point of the "Never throws"
/// contract.
/// </summary>
[Fact]
public async Task Pathologically_large_timeout_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromDays(1000), CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
// ── review follow-up: unenumerated exception type must not leak ex.Message ──
/// <summary>Message text crafted to look like it embedded a credentialed URI, exactly what an unaudited <c>ex.Message</c> could leak.</summary>
private sealed class LeakyException()
: Exception("dial failed for http://leaky-user:leaky-pass@internal-host/probe (boom)");
/// <summary>
/// Reproduces the reviewer's finding: the final <c>catch (Exception ex)</c> forwarded
/// <c>ex.Message</c> verbatim for any type not enumerated above it, breaking the redaction
/// discipline every other catch was audited for. No currently-reachable exception type
/// exploits this, but nothing stopped a future one from doing so either — this test closes
/// that open door by asserting the final catch never echoes exception text.
/// </summary>
[Fact]
public async Task Unenumerated_exception_type_does_not_leak_ex_Message_via_final_catch()
{
var probe = ProbeWithHandler((_, _) => Task.FromException<HttpResponseMessage>(new LeakyException()));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("leaky-pass");
r.Message.ShouldNotContain("leaky-user:leaky-pass");
}
// ── minor: genuine non-2xx status (not just connection-refused) ──────────
[Fact]
public async Task NonSuccess_status_code_returns_not_ok()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(string.Empty),
}));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Latency.ShouldBeNull();
}
// ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
[Fact]
public async Task Credentialed_agentUri_is_redacted_even_on_a_successful_probe()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-user:stub-pass@stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("stub-pass");
r.Message.ShouldNotContain("stub-user:stub-pass");
}
}
@@ -0,0 +1,352 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 15 — <see cref="MTConnectDriverFactoryExtensions"/>: the seam that turns a
/// <c>DriverInstance</c> row's <c>DriverConfig</c> JSON into a live <see cref="MTConnectDriver"/>.
/// <para>
/// Three properties carry the weight here. (1) <b>One parser.</b> The factory delegates to
/// <c>MTConnectDriver.ParseOptions</c> rather than deserializing a second DTO, so the config
/// document has a single authority and the runtime's <c>ReinitializeAsync</c> path cannot
/// drift from the bootstrap path. (2) <b>Connection-free construction.</b> The Wave-0
/// universal browser builds a throwaway instance purely to read
/// <c>SupportsOnlineDiscovery</c>, so a factory that dialled would open a socket per AdminUI
/// browse render against a possibly-unreachable Agent. (3) <b>No dropped capability.</b> The
/// registry hands the runtime an <see cref="IDriver"/>; every capability is resolved by
/// pattern-matching the concrete instance, so the interface set on the object the factory
/// returns IS the dispatch surface.
/// </para>
/// </summary>
public sealed class MTConnectFactoryTests
{
private const uint Good = 0x00000000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const uint BadNodeIdUnknown = 0x80340000u;
// ----------------------------------------------------------------- construction
/// <summary>The plan's headline case: a one-key document is a complete MTConnect config.</summary>
[Fact]
public void Factory_builds_a_driver_from_minimal_config()
{
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
d.DriverType.ShouldBe("MTConnect");
d.DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// The factory's own type name and the instance's <see cref="IDriver.DriverType"/> must be
/// the same string, or a registered factory would materialise a driver the runtime looks up
/// under a different key.
/// </summary>
[Fact]
public void DriverTypeName_matches_the_instances_DriverType()
{
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
MTConnectDriverFactoryExtensions.DriverTypeName.ShouldBe("MTConnect");
d.DriverType.ShouldBe(MTConnectDriverFactoryExtensions.DriverTypeName);
}
// ----------------------------------------------------------------- rejection
/// <summary>
/// No <c>agentUri</c> means there is no Agent to talk to. Throwing is correct: the browser's
/// <c>CanBrowse</c> catches factory exceptions (a half-authored config just disables the
/// picker), and a deployment carrying such a row must fail rather than register a driver
/// pointed at nothing.
/// </summary>
[Fact]
public void Factory_rejects_config_without_agentUri()
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
/// <summary>
/// A whitespace-only <c>agentUri</c> is the same absence spelled differently — it must not
/// slip past the required check and become a driver dialling an empty URI.
/// </summary>
[Fact]
public void Factory_rejects_a_blank_agentUri()
=> Should.Throw<InvalidOperationException>(
() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\" \"}"));
/// <summary>
/// Unparseable JSON faults loudly rather than silently yielding a defaulted driver — the
/// "empty bytes are not an empty configuration" rule (#485) applied at the factory seam.
/// </summary>
[Fact]
public void Factory_rejects_unparseable_json_rather_than_defaulting()
{
var ex = Should.Throw<InvalidOperationException>(
() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json"));
ex.Message.ShouldContain("mt1");
}
/// <summary>A JSON <c>null</c> document is not a config either.</summary>
[Fact]
public void Factory_rejects_a_null_json_document()
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null"));
/// <summary>An empty config string has nothing to build from; the id likewise.</summary>
[Theory]
[InlineData("mt1", "")]
[InlineData("mt1", " ")]
[InlineData("", "{\"agentUri\":\"http://a:5000\"}")]
public void Factory_rejects_empty_arguments(string id, string json)
=> Should.Throw<ArgumentException>(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json));
// ----------------------------------------------------------------- connection-free
/// <summary>
/// <b>The factory opens no socket.</b> Asserted directly: a real listener is bound, the
/// driver is built against its address, and nothing ever arrives on the accept queue. A
/// regression here would make every AdminUI browse render dial the Agent.
/// </summary>
[Fact]
public async Task CreateInstance_opens_no_socket_to_the_configured_agent()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var sw = Stopwatch.StartNew();
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
"mt1", $"{{\"agentUri\":\"http://127.0.0.1:{port}\"}}");
sw.Stop();
driver.ShouldNotBeNull();
// Generous grace for a connect that a background thread might land late.
await Task.Delay(250, TestContext.Current.CancellationToken);
listener.Pending().ShouldBeFalse(
"MTConnectDriverFactoryExtensions.CreateInstance dialled the Agent — the browser " +
"builds a throwaway instance per browse probe, so construction must open nothing.");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
}
finally
{
listener.Stop();
listener.Dispose();
}
}
/// <summary>
/// The same property against an address that can never answer (TEST-NET-1, RFC 5737):
/// construction returns promptly instead of blocking on a connect that will only end in a
/// timeout.
/// </summary>
[Fact]
public void CreateInstance_returns_immediately_for_a_black_hole_agent()
{
var sw = Stopwatch.StartNew();
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
"mt1", "{\"agentUri\":\"http://192.0.2.1:5000\"}");
sw.Stop();
driver.DriverInstanceId.ShouldBe("mt1");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
}
// ----------------------------------------------------------------- registry wiring
/// <summary>
/// <see cref="MTConnectDriverFactoryExtensions.Register"/> puts the type in the registry
/// under the same key the bootstrapper looks up, and the registered delegate — not just the
/// static helper — builds a working instance.
/// </summary>
[Fact]
public void Register_adds_the_type_and_the_registered_delegate_builds_a_driver()
{
var registry = new DriverFactoryRegistry();
MTConnectDriverFactoryExtensions.Register(registry);
registry.RegisteredTypes.ShouldContain("MTConnect");
var factory = registry.TryGet("MTConnect");
factory.ShouldNotBeNull();
var driver = factory("mt-from-registry", "{\"agentUri\":\"http://a:5000\"}");
driver.DriverInstanceId.ShouldBe("mt-from-registry");
driver.DriverType.ShouldBe("MTConnect");
}
/// <summary>
/// <b>The anti-dormancy pin.</b> The runtime resolves every optional capability by
/// pattern-matching the instance the registry hands back, so a capability the driver
/// implements but the factory's return path hides would be implemented-yet-never-dispatched
/// — a failure shape this repo has shipped twice. Asserted on the <see cref="IDriver"/>
/// the registry delegate returns, which is exactly what the bootstrapper holds.
/// </summary>
[Fact]
public void Registered_delegate_returns_an_instance_carrying_every_capability()
{
var registry = new DriverFactoryRegistry();
MTConnectDriverFactoryExtensions.Register(registry);
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
driver.ShouldBeOfType<MTConnectDriver>();
(driver is IReadable).ShouldBeTrue("MTConnect must dispatch reads");
(driver is ISubscribable).ShouldBeTrue("MTConnect must dispatch subscriptions");
(driver is ITagDiscovery).ShouldBeTrue("MTConnect must dispatch browse/discovery");
(driver is IHostConnectivityProbe).ShouldBeTrue("MTConnect must dispatch host connectivity");
(driver is IRediscoverable).ShouldBeTrue("MTConnect must dispatch rediscovery");
}
/// <summary>
/// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
/// instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
/// would start offering an operation the protocol cannot honour.
/// </summary>
[Fact]
public void Registered_delegate_returns_an_instance_that_is_not_writable()
{
var registry = new DriverFactoryRegistry();
MTConnectDriverFactoryExtensions.Register(registry);
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
(driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
}
/// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
[Fact]
public void Register_rejects_a_null_registry()
=> Should.Throw<ArgumentNullException>(() => MTConnectDriverFactoryExtensions.Register(null!));
// ----------------------------------------------------------------- config round-trip
/// <summary>
/// An authored <c>tags</c> array reaches <c>options.Tags</c>. Observed through the
/// observation index the constructor builds from those options: an authored id answers
/// <c>BadWaitingForInitialData</c> ("subscribed, no value yet"), an unauthored one answers
/// <c>BadNodeIdUnknown</c>. Status codes are literals so a wrong constant cannot be masked
/// by both sides sharing one symbol.
/// </summary>
[Fact]
public void Authored_tags_round_trip_into_the_driver_options()
{
const string json = """
{
"agentUri": "http://a:5000",
"tags": [
{ "fullName": "dev1_pos", "driverDataType": "Float64" },
{ "fullName": "dev1_execution", "driverDataType": "String" }
]
}
""";
var driver = MTConnectDriverFactoryExtensions.CreateInstance("mt1", json);
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(BadWaitingForInitialData);
driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData);
driver.ObservationIndex.Get("dev1_never_authored").StatusCode.ShouldBe(BadNodeIdUnknown);
}
/// <summary>
/// An enum authored as a <b>name</b> parses to that member — proved by behaviour, not by
/// reading the option back: a <c>Float64</c> tag coerces the Agent's text into a real
/// <see cref="double"/>, whereas the enum's member 0 (<c>Boolean</c>) or a defaulted
/// <c>String</c> would not.
/// </summary>
[Fact]
public void Enum_fields_authored_as_names_parse_to_that_member()
{
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
"mt1",
"""
{ "agentUri": "http://a:5000",
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64" } ] }
""");
driver.ObservationIndex.Apply(new MTConnectStreamsResult(
InstanceId: 1L,
NextSequence: 2L,
FirstSequence: 1L,
Observations: [new MTConnectObservation(
"dev1_pos", "12.5", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc), false)]));
var snap = driver.ObservationIndex.Get("dev1_pos");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double>().ShouldBe(12.5d);
}
/// <summary>
/// <b>The enum-serialization trap.</b> The factory deliberately carries no
/// <c>JsonStringEnumConverter</c>: a numerically-serialized enum — the shape an AdminUI page
/// that serialized the enum by value would emit — must fault at deploy rather than silently
/// landing on member 0 (<c>Boolean</c>), which would mis-coerce every value of that tag.
/// </summary>
[Fact]
public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero()
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
"mt1",
"""
{ "agentUri": "http://a:5000",
"tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] }
"""));
/// <summary>An enum name that names no member is an authoring error, reported with the field.</summary>
[Fact]
public void Unknown_enum_name_faults_naming_the_offending_tag()
{
var ex = Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
"mt1",
"""
{ "agentUri": "http://a:5000",
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] }
"""));
ex.Message.ShouldContain("dev1_pos");
}
/// <summary>
/// Scalar knobs reach the options too — the factory must not quietly drop a section it does
/// not itself read. Proved through the one seam a non-initialized driver exposes: an
/// authored <c>reconnect</c> block changes the backoff the driver would apply.
/// </summary>
[Fact]
public void Reconnect_and_probe_sections_round_trip_into_the_driver_options()
{
const string json = """
{
"agentUri": "http://a:5000",
"deviceName": "dev1",
"requestTimeoutMs": 1234,
"reconnect": { "minBackoffMs": 500, "maxBackoffMs": 4000, "backoffMultiplier": 3.0 },
"probe": { "enabled": false, "intervalMs": 7000, "timeoutMs": 900 }
}
""";
var options = MTConnectDriver.ParseOptions("mt1", json);
options.AgentUri.ShouldBe("http://a:5000");
options.DeviceName.ShouldBe("dev1");
options.RequestTimeoutMs.ShouldBe(1234);
options.Reconnect.MinBackoffMs.ShouldBe(500);
options.Reconnect.MaxBackoffMs.ShouldBe(4000);
options.Reconnect.BackoffMultiplier.ShouldBe(3.0);
options.Probe.Enabled.ShouldBeFalse();
options.Probe.Interval.ShouldBe(TimeSpan.FromMilliseconds(7000));
options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(900));
// …and the factory is built on exactly that parse, not a second DTO: the same document
// through the factory must produce an instance, and the same rejection rules (asserted
// above) hold for both entry points.
MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
}
}
@@ -0,0 +1,676 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 13 — <see cref="MTConnectDriver"/>'s two optional capabilities:
/// <see cref="IHostConnectivityProbe"/> (a periodic reachability tick that flips one host
/// Running ↔ Stopped) and <see cref="IRediscoverable"/> (the Agent <c>instanceId</c> watch that
/// is the ONLY thing making <see cref="DiscoveryRediscoverPolicy.Once"/> safe).
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here waits on wall-clock time, and the probe is a timer.</b> The driver takes
/// its inter-tick delay through an injected scheduler seam, and this suite supplies
/// <see cref="ManualTicker"/>: the loop parks in the seam, the test releases exactly one
/// tick, and the ticker's next park is the proof that the probe which followed has already
/// completed. No test sleeps, no test polls a counter, and
/// <see cref="CannedAgentClient"/> stays timer-free.
/// </para>
/// <para>
/// <b>The load-bearing tests are the anti-inertness ones.</b> Both capabilities have a
/// plausible-looking implementation that compiles, ships, and does nothing:
/// a connectivity probe routed through the driver's cached <c>/probe</c> accessor never
/// touches the network once the cache is warm (so it reports Running forever, whatever the
/// Agent is doing), and a rediscovery signal raised without dropping that same cache makes
/// Core re-run discovery and receive the identical stale device tree. Both are pinned here
/// by asserting the Agent was actually called.
/// </para>
/// </remarks>
public sealed class MTConnectHostAndRediscoverTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The <c>instanceId</c> every canned fixture shares.</summary>
private const long FixtureInstanceId = 1655000000L;
/// <summary>A DIFFERENT <c>instanceId</c> — the Agent came back as a new process.</summary>
private const long RestartedInstanceId = 1655999999L;
/// <summary>A third <c>instanceId</c> — the Agent restarted twice.</summary>
private const long SecondRestartInstanceId = 1656111111L;
/// <summary>The cursor <c>Fixtures/current.xml</c> primes the pump with.</summary>
private const long PrimedNextSequence = 108L;
/// <summary>
/// Failure guard for the awaits a defect could turn into a permanent hang (a probe loop that
/// never parks, a teardown that deadlocks on the lifecycle semaphore). It exists to make a
/// hang red, not to assert a duration — no assertion is ever made about elapsed time.
/// </summary>
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);
/// <summary>The interval the probe tests author, asserted to reach the scheduler verbatim.</summary>
private static readonly TimeSpan AuthoredInterval = TimeSpan.FromSeconds(7);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---- fixtures ----
/// <summary>
/// Options with the background probe <b>disabled</b> — the default for the rediscovery half
/// of this suite, so no probe loop can add calls behind a <c>ProbeCallCount</c> assertion.
/// </summary>
private static MTConnectDriverOptions Opts(MTConnectProbeOptions? probe = null) =>
new()
{
AgentUri = AgentUri,
RequestTimeoutMs = 5000,
Probe = probe ?? new MTConnectProbeOptions { Enabled = false },
Tags =
[
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
],
};
private static MTConnectProbeOptions Probing(bool enabled = true) =>
new() { Enabled = enabled, Interval = AuthoredInterval, Timeout = TimeSpan.FromSeconds(2) };
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
CannedAgentClient? client = null,
MTConnectDriverOptions? options = null,
ManualTicker? ticker = null)
{
var agent = client ?? CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(
options ?? Opts(), "mt1", _ => agent, probeScheduler: ticker is null ? null : ticker.WaitAsync);
await driver.InitializeAsync("{}", Ct);
return (driver, agent);
}
/// <summary>
/// Brings a driver up with the probe loop wired to a manual ticker, and returns once the loop
/// is demonstrably parked in its first inter-tick wait (i.e. it has run zero probes).
/// </summary>
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client, ManualTicker Ticker)>
ProbingDriverAsync(CannedAgentClient? client = null)
{
var ticker = new ManualTicker();
var (driver, agent) = await InitializedDriverAsync(client, Opts(Probing()), ticker);
await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
return (driver, agent, ticker);
}
/// <summary>
/// Releases exactly one probe tick and returns once the probe it triggered has completed —
/// proven by the loop parking again, not by a delay.
/// </summary>
private static async Task TickAsync(ManualTicker ticker)
{
ticker.Tick();
await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
}
/// <summary>
/// <b>Teardown symmetry with the <c>/sample</c> pump (remediation I1).</b> The probe loop is
/// the second long-lived background task in this driver, and it is stopped the same way and
/// from the same place: while the lifecycle semaphore is held, on a path
/// <c>DriverInstanceActor.PostStop</c> blocks an Akka dispatcher thread on. So its wait is
/// bounded and honours the caller's token too — a blocking
/// <see cref="IHostConnectivityProbe.OnHostStatusChanged"/> handler must not be able to wedge
/// shutdown, any more than a blocking data-change handler can.
/// </summary>
/// <remarks>
/// Its blast radius is genuinely smaller than the pump's (each iteration is bounded by the
/// validated-positive <c>Probe.Timeout</c>, where the pump's is an unbounded long poll), but
/// leaving the two divergent would make the un-bounded one the pattern a future reader
/// copies — the defect class would be closed in one place and re-opened in the other, in the
/// same file.
/// </remarks>
[Fact]
public async Task Shutdown_honours_the_callers_deadline_when_a_host_status_subscriber_blocks()
{
var (driver, _, ticker) = await ProbingDriverAsync();
var blocking = new ManualResetEventSlim(false);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, _) =>
{
entered.TrySetResult();
blocking.Wait();
};
try
{
// The first successful probe transitions Unknown -> Running and raises the event, which
// now blocks inside the loop.
ticker.Tick();
await entered.Task.WaitAsync(Watchdog, Ct);
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await driver.ShutdownAsync(deadline.Token).WaitAsync(Watchdog, Ct);
driver.ProbeLoopTask.ShouldBeNull();
}
finally
{
blocking.Set();
}
}
private static ConcurrentQueue<RediscoveryEventArgs> RecordRediscovery(MTConnectDriver driver)
{
var seen = new ConcurrentQueue<RediscoveryEventArgs>();
((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => seen.Enqueue(e);
return seen;
}
private static ConcurrentQueue<HostStatusChangedEventArgs> RecordHostStatus(MTConnectDriver driver)
{
var seen = new ConcurrentQueue<HostStatusChangedEventArgs>();
((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, e) => seen.Enqueue(e);
return seen;
}
private static MTConnectStreamsResult ChunkFrom(
long instanceId, long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
new(
instanceId,
nextSequence,
firstSequence,
[.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);
/// <summary>A device model that is recognisably NOT the canned fixture's.</summary>
private static MTConnectProbeModel OtherModel() =>
new([
new MTConnectDevice(
"other-device",
"other-device",
[],
[new MTConnectDataItem("other_item", "other_item", "EVENT", "AVAILABILITY", null, null, null, null)]),
]);
// ---- IRediscoverable: the instanceId watch ----
/// <summary>
/// The plan's TDD case. <see cref="MTConnectDriver.RediscoverPolicy"/> is
/// <see cref="DiscoveryRediscoverPolicy.Once"/>, so the runtime runs exactly one discovery
/// pass per connect — an Agent that restarts and renumbers (or adds) data items would leave a
/// stale address space behind a Healthy driver. This event is the only thing that makes that
/// policy safe.
/// </summary>
[Fact]
public async Task InstanceId_change_raises_rediscovery()
{
var (driver, client) = await InitializedDriverAsync();
var seen = RecordRediscovery(driver);
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
await client
.PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
.WaitAsync(Watchdog, Ct);
var raised = seen.ShouldHaveSingleItem();
raised.Reason.ShouldBe("MTConnect agent instanceId changed");
raised.ScopeHint.ShouldBeNull();
}
/// <summary>
/// Once per <b>change</b>, not once per chunk. The chunks that follow a restart all carry the
/// new <c>instanceId</c>; a driver that compared each chunk against the id it was
/// <i>initialized</i> with — or that simply re-raised on every chunk — would ask Core to
/// rebuild the whole address space on every heartbeat, forever.
/// </summary>
[Fact]
public async Task InstanceId_change_raises_rediscovery_once_per_change_not_once_per_chunk()
{
var (driver, client) = await InitializedDriverAsync();
var seen = RecordRediscovery(driver);
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
await client
.PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
.WaitAsync(Watchdog, Ct);
seen.Count.ShouldBe(1);
// Two more chunks from the SAME restarted Agent, contiguous with the re-baseline.
await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "10.5"))).WaitAsync(Watchdog, Ct);
await client.PumpAsync(ChunkFrom(RestartedInstanceId, 45, 48, ("dev1_pos", "11.5"))).WaitAsync(Watchdog, Ct);
seen.Count.ShouldBe(1);
// …but a SECOND restart is a second change, and must be announced.
client.CurrentAnswers.Enqueue(ChunkFrom(SecondRestartInstanceId, 10, 12, ("dev1_pos", "1.5")));
await client
.PumpAsync(ChunkFrom(SecondRestartInstanceId, 6000, 6005, ("dev1_pos", "2.5")))
.WaitAsync(Watchdog, Ct);
seen.Count.ShouldBe(2);
}
/// <summary>
/// An unchanged <c>instanceId</c> is the overwhelmingly common case — every ordinary chunk,
/// every heartbeat, and every ring-buffer re-baseline of a still-running Agent. None of them
/// may cost an address-space rebuild.
/// </summary>
[Fact]
public async Task Unchanged_instanceId_raises_no_rediscovery()
{
var (driver, client) = await InitializedDriverAsync();
var seen = RecordRediscovery(driver);
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await client
.PumpAsync(ChunkFrom(FixtureInstanceId, PrimedNextSequence, 113, ("dev1_pos", "1.5")))
.WaitAsync(Watchdog, Ct);
// A heartbeat…
await client.PumpAsync(ChunkFrom(FixtureInstanceId, 1, 120)).WaitAsync(Watchdog, Ct);
// …and a ring-buffer gap, which re-baselines but is NOT a restart.
client.CurrentAnswers.Enqueue(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5")));
await client
.PumpAsync(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5")))
.WaitAsync(Watchdog, Ct);
seen.ShouldBeEmpty();
}
/// <summary>
/// <b>The anti-inertness pin for <see cref="IRediscoverable"/>.</b> A restart invalidates the
/// cached <c>/probe</c> device model — it describes a process that no longer exists. Raising
/// the event while keeping that cache would have Core dutifully re-run
/// <see cref="MTConnectDriver.DiscoverAsync"/> and receive the <i>identical stale tree</i>
/// from <c>GetOrFetchProbeModelAsync</c>'s warm cache: a feature that fires, logs, and
/// changes nothing.
/// </summary>
[Fact]
public async Task InstanceId_change_drops_the_cached_probe_model_so_rediscovery_sees_the_new_one()
{
var (driver, client) = await InitializedDriverAsync();
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
driver.CachedProbeModel.ShouldNotBeNull();
// The restarted Agent declares a different device model.
client.Probe = OtherModel();
client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
await client
.PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
.WaitAsync(Watchdog, Ct);
driver.CachedProbeModel.ShouldBeNull();
var probeCalls = client.ProbeCallCount;
var builder = new CapturingBuilder();
await driver.DiscoverAsync(builder, Ct);
// Discovery went back to the Agent, and streamed the NEW model rather than the old one.
client.ProbeCallCount.ShouldBe(probeCalls + 1);
builder.Variables.Select(v => v.Attr.FullName).ShouldBe(["other_item"]);
}
/// <summary>
/// A rediscovery handler is arbitrary caller code. One that throws is a bug in the consumer,
/// not a reason to stop streaming data to every subscriber on this driver.
/// </summary>
[Fact]
public async Task A_throwing_rediscovery_handler_does_not_kill_the_pump()
{
var (driver, client) = await InitializedDriverAsync();
var seen = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => seen.Enqueue(e);
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) =>
throw new InvalidOperationException("rediscovery subscriber blew up");
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
await client
.PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
.WaitAsync(Watchdog, Ct);
// The pump survived the throw, re-baselined, and keeps delivering.
await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "12.5"))).WaitAsync(Watchdog, Ct);
driver.SampleStreamTask!.IsCompleted.ShouldBeFalse();
seen.Where(e => e.FullReference == "dev1_pos").Last().Snapshot.Value.ShouldBe(12.5d);
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// One host per Agent, named by the authored <c>AgentUri</c> — that is the identity the Admin
/// dashboard shows and the key Core scopes its Bad-quality fan-out by. Before the first tick
/// the honest answer is <see cref="HostState.Unknown"/>: the probe loop is the sole writer of
/// this field, and it has not run yet.
/// </summary>
[Fact]
public async Task Host_statuses_report_one_host_named_by_the_agent_uri()
{
var (driver, _, _) = await ProbingDriverAsync();
driver.HostName.ShouldBe(AgentUri);
var status = ((IHostConnectivityProbe)driver).GetHostStatuses().ShouldHaveSingleItem();
status.HostName.ShouldBe(AgentUri);
status.State.ShouldBe(HostState.Unknown);
}
/// <summary>
/// <b>The anti-inertness pin for <see cref="IHostConnectivityProbe"/>.</b> Every tick must
/// reach the Agent. A probe routed through the driver's cached-model accessor
/// (<c>GetOrFetchProbeModelAsync</c>) compiles, ships, and — with a cache warmed by
/// initialize — never issues a single request, reporting Running forever regardless of what
/// the Agent is doing. The call count is the only thing that can tell the two apart.
/// </summary>
[Fact]
public async Task Probe_loop_calls_the_agent_on_every_tick()
{
var (_, client, ticker) = await ProbingDriverAsync();
// Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
// the only call so far.
client.ProbeCallCount.ShouldBe(1);
await TickAsync(ticker);
client.ProbeCallCount.ShouldBe(2);
await TickAsync(ticker);
client.ProbeCallCount.ShouldBe(3);
await TickAsync(ticker);
client.ProbeCallCount.ShouldBe(4);
// …and it waits the authored interval between them, rather than a hard-coded cadence.
ticker.LastInterval.ShouldBe(AuthoredInterval);
}
/// <summary>
/// Running ↔ Stopped, and the event fires on the <b>transition</b> only. A driver that raised
/// on every tick would emit one event per interval per Agent forever; Core would re-fan Bad
/// quality across the host's subtree each time, and an operator watching the feed could not
/// tell a new outage from an ongoing one.
/// </summary>
[Fact]
public async Task Probe_flips_the_host_on_transition_only()
{
var (driver, client, ticker) = await ProbingDriverAsync();
var seen = RecordHostStatus(driver);
client.ProbeFailure = new HttpRequestException("agent unreachable");
await TickAsync(ticker);
await TickAsync(ticker);
var down = seen.ShouldHaveSingleItem();
down.HostName.ShouldBe(AgentUri);
down.OldState.ShouldBe(HostState.Unknown);
down.NewState.ShouldBe(HostState.Stopped);
((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
client.ProbeFailure = null;
await TickAsync(ticker);
await TickAsync(ticker);
seen.Count.ShouldBe(2);
var up = seen.Last();
up.OldState.ShouldBe(HostState.Stopped);
up.NewState.ShouldBe(HostState.Running);
((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
/// <summary>
/// <b>The probe is a liveness check, never a cache writer.</b> Publishing its answer into
/// <c>AgentSession.ProbeModel</c> would put a second writer on the field the lifecycle owns
/// under a compare-and-swap, and would silently re-warm a cache that
/// <c>FlushOptionalCachesAsync</c> (or an Agent restart) deliberately dropped.
/// </summary>
[Fact]
public async Task Probe_ticks_never_publish_into_the_session_probe_cache()
{
var (driver, client, ticker) = await ProbingDriverAsync();
var original = driver.CachedProbeModel.ShouldNotBeNull();
// The Agent now answers /probe with a different model. Nothing the connectivity probe does
// may let that model reach the driver's cache.
client.Probe = OtherModel();
await TickAsync(ticker);
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeSameAs(original);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0);
// …and a flushed cache stays flushed until a real consumer asks for it.
await driver.FlushOptionalCachesAsync(Ct);
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeNull();
}
/// <summary>
/// <b>Host connectivity is deliberately NOT an input to <c>DriverHealth</c>.</b> The two real
/// data paths (<c>/current</c> reads, the <c>/sample</c> pump) own health and will report the
/// same outage with the failure that actually mattered. The probe's deadline
/// (<c>Probe.Timeout</c>, 2 s) is far tighter than <c>RequestTimeoutMs</c> and its request is
/// the <i>largest</i> document the Agent serves, so letting it degrade the driver would flap
/// a perfectly working instance on a slow-but-alive Agent.
/// </summary>
[Fact]
public async Task An_unreachable_host_does_not_degrade_driver_health()
{
var (driver, client, ticker) = await ProbingDriverAsync();
client.ProbeFailure = new HttpRequestException("agent unreachable");
await TickAsync(ticker);
((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// …and the converse: a reachable host does not clear a genuinely failing read path either.
client.ProbeFailure = null;
client.CurrentFailure = new HttpRequestException("connection refused");
await driver.ReadAsync(["dev1_pos"], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
await TickAsync(ticker);
((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// <c>Probe.Enabled = false</c> means no loop, no scheduler wait, and not one extra request.
/// An operator who turned probing off must not still be paying for it.
/// </summary>
[Fact]
public async Task Probe_disabled_starts_no_loop_and_makes_no_calls()
{
var ticker = new ManualTicker();
var (driver, client) = await InitializedDriverAsync(options: Opts(Probing(enabled: false)), ticker: ticker);
driver.ProbeLoopTask.ShouldBeNull();
ticker.Waits.ShouldBe(0);
client.ProbeCallCount.ShouldBe(1); // the initialize prime, and nothing else
// The capability is still implemented — it just has nothing to report.
((IHostConnectivityProbe)driver).GetHostStatuses()
.ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown);
}
/// <summary>
/// Teardown stops the timer and waits for it. A loop left running would keep dialling an
/// Agent through a client its owner has already disposed — the "torn down but still
/// reporting" shape the rest of this driver's lifecycle is written to prevent.
/// </summary>
[Fact]
public async Task Shutdown_stops_the_probe_loop_and_makes_no_further_calls()
{
var (driver, client, ticker) = await ProbingDriverAsync();
await TickAsync(ticker);
var loop = driver.ProbeLoopTask.ShouldNotBeNull();
await driver.ShutdownAsync(Ct).WaitAsync(Watchdog, Ct);
loop.IsCompleted.ShouldBeTrue();
loop.IsFaulted.ShouldBeFalse();
driver.ProbeLoopTask.ShouldBeNull();
client.IsDisposed.ShouldBeTrue();
// The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
// nobody — no probe against a disposed client, and no further waits on the scheduler.
var calls = client.ProbeCallCount;
var waits = ticker.Waits;
ticker.Tick();
client.ProbeCallCount.ShouldBe(calls);
ticker.Waits.ShouldBe(waits);
}
/// <summary>
/// A re-initialize replaces the client, so it must replace the loop that dials it — the old
/// one holds a reference to a client the re-initialize may be about to dispose.
/// </summary>
[Fact]
public async Task Reinitialize_replaces_the_probe_loop()
{
var (driver, _, ticker) = await ProbingDriverAsync();
var first = driver.ProbeLoopTask.ShouldNotBeNull();
await driver.ReinitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
first.IsCompleted.ShouldBeTrue();
var second = driver.ProbeLoopTask.ShouldNotBeNull();
second.ShouldNotBeSameAs(first);
// The replacement loop is live: it parks, ticks, and probes like the first.
await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
await TickAsync(ticker);
}
// ---- operator-authorable zeroes (arch-review 01/S-6) ----
/// <summary>
/// arch-review 01/S-6, applied to the two knobs Task 13 is the first consumer of. A
/// <c>0</c> interval turns the "periodic" probe into an unthrottled hot loop against the
/// Agent; a <c>0</c> timeout cancels every probe before it can be answered, pinning the host
/// to Stopped forever and reporting an outage that does not exist. Both are refused with the
/// same <c>RequirePositive</c> posture as the four timing knobs Task 9 guarded.
/// </summary>
[Theory]
[InlineData(0, 2000)]
[InlineData(-1, 2000)]
[InlineData(5000, 0)]
[InlineData(5000, -1)]
public async Task A_non_positive_probe_interval_or_timeout_is_refused(int intervalMs, int timeoutMs)
{
var options = Opts(new MTConnectProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(intervalMs),
Timeout = TimeSpan.FromMilliseconds(timeoutMs),
});
var driver = new MTConnectDriver(options, "mt1", _ => CannedAgentClient.FromFixtures());
await Should.ThrowAsync<ArgumentOutOfRangeException>(() => driver.InitializeAsync("{}", Ct));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// …but only when probing is on. Faulting a deployment over a field the driver never reads
/// would be a new way for a deploy to fail with nothing gained; flipping
/// <c>Enabled</c> back on is a config edit that re-runs the same validation.
/// </summary>
[Fact]
public async Task A_non_positive_probe_interval_is_ignored_when_probing_is_disabled()
{
var options = Opts(new MTConnectProbeOptions
{
Enabled = false, Interval = TimeSpan.Zero, Timeout = TimeSpan.Zero,
});
var (driver, _) = await InitializedDriverAsync(options: options);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.ProbeLoopTask.ShouldBeNull();
}
/// <summary>
/// A hand-driven replacement for <see cref="Task.Delay(TimeSpan, CancellationToken)"/>: the
/// probe loop parks here, and a test releases exactly one tick at a time. The park signal is
/// what makes the suite deterministic — when the loop is parked again, the probe that
/// followed the previous release has demonstrably finished.
/// </summary>
/// <remarks>
/// <see cref="Tick"/> installs the <i>next</i> park signal <b>before</b> releasing the
/// current wait, so a loop that races ahead and parks again immediately cannot lose the
/// signal a test is about to await.
/// </remarks>
private sealed class ManualTicker
{
private readonly Lock _gate = new();
private TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _waits;
/// <summary>How many times the loop has parked in this scheduler.</summary>
public int Waits => Volatile.Read(ref _waits);
/// <summary>The interval the loop most recently asked to wait for.</summary>
public TimeSpan LastInterval { get; private set; }
/// <summary>The scheduler seam handed to the driver.</summary>
public async Task WaitAsync(TimeSpan interval, CancellationToken ct)
{
TaskCompletionSource release;
lock (_gate)
{
LastInterval = interval;
Interlocked.Increment(ref _waits);
release = _release;
_parked.TrySetResult();
}
await release.Task.WaitAsync(ct).ConfigureAwait(false);
}
/// <summary>Completes once the loop is parked in the current wait.</summary>
public Task ParkedAsync()
{
lock (_gate)
{
return _parked.Task;
}
}
/// <summary>Releases the current wait so exactly one probe runs.</summary>
public void Tick()
{
lock (_gate)
{
var release = _release;
_release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_parked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
release.TrySetResult();
}
}
}
}
@@ -0,0 +1,823 @@
using System.Globalization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 8 — <see cref="MTConnectObservationIndex"/>: the thread-safe
/// <c>dataItemId → DataValueSnapshot</c> map that <c>/current</c> and the <c>/sample</c> pump
/// both write into, and the quality mapping that turns the Agent's weakly-typed text into an
/// OPC UA-shaped snapshot.
/// <para>
/// Status codes are asserted as <b>literals</b> here on purpose. The production constants
/// are <c>private</c>, so a wrong numeric value in the driver cannot be masked by both
/// sides sharing one symbol — the literal is an independent statement of the OPC UA
/// numeric contract (verified against <c>Opc.Ua.StatusCodes</c>).
/// </para>
/// </summary>
public sealed class MTConnectObservationIndexTests
{
private const string CurrentFixture = "Fixtures/current.xml";
private const string UnavailableFixture = "Fixtures/current-unavailable.xml";
private const string SampleFixture = "Fixtures/sample.xml";
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadNotSupported = 0x803D0000u;
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>Every dataItemId both /current fixtures report.</summary>
private static readonly string[] AllFixtureDataItemIds =
[
"dev1_avail",
"dev1_pos",
"dev1_vibration_ts",
"dev1_partcount",
"dev1_execution",
"dev1_program",
"dev1_system_cond"
];
private static MTConnectStreamsResult ParseFixture(string path) =>
MTConnectStreamsParser.Parse(File.ReadAllText(path));
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
private static MTConnectStreamsResult Synthetic(
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
new(
InstanceId: 1L,
NextSequence: 2L,
FirstSequence: 1L,
Observations: [new MTConnectObservation(
dataItemId,
value,
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
isStructured)]);
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
// --------------------------------------------------------------- UNAVAILABLE => BadNoCommunication
/// <summary>The plan's headline case: the Agent's UNAVAILABLE sentinel is a no-comms quality, not a value.</summary>
[Fact]
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(UnavailableFixture));
var snap = idx.Get("dev1_partcount");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(0x80310000u);
}
/// <summary>
/// Every one of the seven observations in the all-UNAVAILABLE fixture maps to no-comms —
/// including <c>dev1_system_cond</c>, which reaches the index as a CONDITION whose
/// <c>&lt;Unavailable/&gt;</c> element name the parser normalized onto the exact literal
/// <c>UNAVAILABLE</c>. A case-sensitive miss on that one spelling is the defect this covers.
/// </summary>
[Fact]
public void Every_observation_in_the_unavailable_fixture_maps_to_BadNoCommunication()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(UnavailableFixture));
foreach (var id in AllFixtureDataItemIds)
{
var snap = idx.Get(id);
snap.StatusCode.ShouldBe(BadNoCommunication, $"dataItemId '{id}' should be no-comms");
snap.Value.ShouldBeNull($"dataItemId '{id}' must not carry a value when unavailable");
}
}
/// <summary>An UNAVAILABLE observation still carries the Agent's timestamp — the quality changed, not the clock.</summary>
[Fact]
public void Unavailable_observation_keeps_the_agent_source_timestamp()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(UnavailableFixture));
var snap = idx.Get("dev1_partcount");
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 10, 0, 300, DateTimeKind.Utc));
snap.SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>The sentinel is matched exactly; a lowercase spelling is a real value, not a no-comms marker.</summary>
[Theory]
[InlineData("UNAVAILABLE", BadNoCommunication)]
[InlineData("Unavailable", Good)]
[InlineData("unavailable", Good)]
public void Unavailable_sentinel_is_matched_on_the_exact_literal(string value, uint expectedStatus)
{
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
idx.Apply(Synthetic("x", value));
idx.Get("x").StatusCode.ShouldBe(expectedStatus);
}
// ------------------------------------------------------------------------- present values
/// <summary>The plan's second headline case.</summary>
[Fact]
public void Present_value_indexes_good_with_source_timestamp()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_pos");
snap.StatusCode.ShouldBe(0u);
snap.SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary>SourceTimestamp is the Agent's observation timestamp, never the indexing clock.</summary>
[Fact]
public void Present_value_carries_the_observation_timestamp_not_the_index_clock()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_pos");
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
snap.ServerTimestampUtc.ShouldBeGreaterThan(snap.SourceTimestampUtc!.Value);
snap.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>A Float64-authored SAMPLE coerces to a real double, not the raw text.</summary>
[Fact]
public void Float64_authored_tag_coerces_numeric_text_to_a_double()
{
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_pos");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double>().ShouldBe(123.4567);
}
/// <summary>An Int64-authored EVENT (PartCount) coerces to a long. sample.xml is the fixture with a present count.</summary>
[Fact]
public void Int64_authored_tag_coerces_integer_text_to_a_long()
{
var idx = new MTConnectObservationIndex([Tag("dev1_partcount", DriverDataType.Int64)]);
idx.Apply(ParseFixture(SampleFixture));
var snap = idx.Get("dev1_partcount");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<long>().ShouldBe(42L);
}
/// <summary>String-authored EVENT/CONDITION observations carry the Agent's text verbatim.</summary>
[Theory]
[InlineData("dev1_execution", "ACTIVE")]
[InlineData("dev1_program", "O1234")]
[InlineData("dev1_avail", "AVAILABLE")]
[InlineData("dev1_system_cond", "Normal")]
public void String_authored_tags_carry_the_raw_agent_text(string dataItemId, string expected)
{
var idx = new MTConnectObservationIndex([Tag(dataItemId, DriverDataType.String)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get(dataItemId);
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<string>().ShouldBe(expected);
}
/// <summary>The remaining scalar coercions, each landing on its exact CLR type.</summary>
[Theory]
[InlineData(DriverDataType.Boolean, "true", true)]
[InlineData(DriverDataType.Boolean, "FALSE", false)]
[InlineData(DriverDataType.Boolean, "1", true)]
[InlineData(DriverDataType.Boolean, "0", false)]
[InlineData(DriverDataType.Int16, "-12", (short)-12)]
[InlineData(DriverDataType.Int32, "70000", 70000)]
[InlineData(DriverDataType.UInt16, "65535", (ushort)65535)]
[InlineData(DriverDataType.UInt32, "4000000000", 4000000000u)]
[InlineData(DriverDataType.UInt64, "18446744073709551615", 18446744073709551615ul)]
[InlineData(DriverDataType.Float32, "1.5", 1.5f)]
public void Scalar_coercions_land_on_the_authored_clr_type(
DriverDataType type, string text, object expected)
{
var idx = new MTConnectObservationIndex([Tag("x", type)]);
idx.Apply(Synthetic("x", text));
var snap = idx.Get("x");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
/// <summary>A DateTime-authored tag parses ISO-8601 and lands on UTC kind.</summary>
[Fact]
public void DateTime_authored_tag_coerces_to_a_utc_datetime()
{
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.DateTime)]);
idx.Apply(Synthetic("x", "2026-07-24T12:00:00.250Z"));
var snap = idx.Get("x");
snap.StatusCode.ShouldBe(Good);
var value = snap.Value.ShouldBeOfType<DateTime>();
value.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 250, DateTimeKind.Utc));
value.Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>
/// Numeric coercion is culture-invariant. On a de-DE machine a naive
/// <c>double.TryParse("123.4567")</c> yields 1234567 — a silently wrong value with Good
/// quality, the worst possible outcome. Run on a dedicated thread so the culture change
/// cannot leak into any parallel test.
/// </summary>
[Fact]
public void Numeric_coercion_is_culture_invariant()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
idx.Get("dev1_pos").Value.ShouldBeOfType<double>().ShouldBe(123.4567);
}
catch (Exception ex)
{
failure = ex;
}
});
thread.Start();
thread.Join();
failure.ShouldBeNull();
}
// ----------------------------------------------------------------------- coercion failures
/// <summary>
/// A vocabulary EVENT authored as a number cannot parse. It must degrade to a Bad-coded
/// snapshot — never an exception (the index sits under <c>IReadable</c>), and never a
/// silently-wrong zero.
/// </summary>
[Fact]
public void Coercion_failure_yields_BadTypeMismatch_and_never_throws()
{
var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
Should.NotThrow(() => idx.Apply(ParseFixture(CurrentFixture)));
var snap = idx.Get("dev1_execution");
snap.StatusCode.ShouldBe(0x80740000u);
snap.Value.ShouldBeNull();
}
/// <summary>Non-numeric text is a type mismatch; a number the authored type cannot hold is out of range.</summary>
[Theory]
[InlineData(DriverDataType.Float64, "ACTIVE", BadTypeMismatch)]
[InlineData(DriverDataType.Int64, "ACTIVE", BadTypeMismatch)]
[InlineData(DriverDataType.Boolean, "ACTIVE", BadTypeMismatch)]
[InlineData(DriverDataType.Int16, "99999999999", BadOutOfRange)]
[InlineData(DriverDataType.Int32, "3.5", BadOutOfRange)]
[InlineData(DriverDataType.UInt32, "-1", BadOutOfRange)]
public void Failed_coercion_distinguishes_type_mismatch_from_out_of_range(
DriverDataType type, string text, uint expectedStatus)
{
var idx = new MTConnectObservationIndex([Tag("x", type)]);
idx.Apply(Synthetic("x", text));
var snap = idx.Get("x");
snap.StatusCode.ShouldBe(expectedStatus);
snap.Value.ShouldBeNull();
}
/// <summary>A Bad-coded coercion failure still reports when the Agent observed it.</summary>
[Fact]
public void Failed_coercion_keeps_the_agent_source_timestamp()
{
var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
idx.Get("dev1_execution").SourceTimestampUtc
.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc));
}
/// <summary>An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Empty_observation_value_maps_to_BadNoCommunication(string value)
{
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
idx.Apply(Synthetic("x", value));
var snap = idx.Get("x");
snap.StatusCode.ShouldBe(BadNoCommunication);
snap.Value.ShouldBeNull();
}
// -------------------------------------------------------------------------- unknown lookups
/// <summary>A dataItemId that is neither authored nor ever observed is unknown to this driver.</summary>
[Fact]
public void Unknown_dataItemId_yields_BadNodeIdUnknown()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("no_such_data_item");
snap.StatusCode.ShouldBe(BadNodeIdUnknown);
snap.Value.ShouldBeNull();
snap.SourceTimestampUtc.ShouldBeNull();
}
/// <summary>
/// An authored tag the Agent has not reported yet is a different condition from an unknown
/// id — it is the standard OPC UA "subscribed, no value yet" state. Collapsing the two
/// would tell an operator their correctly-authored tag does not exist.
/// </summary>
[Fact]
public void Authored_but_never_observed_tag_yields_BadWaitingForInitialData()
{
var idx = new MTConnectObservationIndex([Tag("dev1_spindle_speed", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_spindle_speed");
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
snap.Value.ShouldBeNull();
}
/// <summary>An empty / whitespace ref is not a lookup; it degrades rather than throwing.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Blank_dataItemId_yields_BadNodeIdUnknown(string dataItemId)
{
var idx = new MTConnectObservationIndex();
idx.Get(dataItemId).StatusCode.ShouldBe(BadNodeIdUnknown);
}
// --------------------------------------------------------------------- unauthored observations
/// <summary>
/// The Agent streams the whole device, so most observations have no authored tag. They are
/// indexed as their raw text under <see cref="DriverDataType.String"/> — the type that
/// cannot fail to coerce — rather than dropped or coded Bad.
/// </summary>
[Fact]
public void Unauthored_observation_is_indexed_as_a_good_string()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_pos");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<string>().ShouldBe("123.4567");
}
/// <summary>Every fixture observation is indexed, authored or not — nothing is silently dropped.</summary>
[Fact]
public void Every_fixture_observation_is_indexed_even_with_no_authored_tags()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
foreach (var id in AllFixtureDataItemIds)
{
idx.Get(id).StatusCode.ShouldNotBe(BadNodeIdUnknown, $"'{id}' should have been indexed");
}
}
// -------------------------------------------------------------------------------- TIME_SERIES
/// <summary>
/// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
/// reports BadNotSupported rather than pretending: the vector is real data this build
/// cannot represent, which is neither a type mismatch nor a comms failure.
/// </summary>
[Fact]
public void Time_series_authored_as_an_array_reports_BadNotSupported()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.Value.ShouldBeNull();
}
/// <summary>
/// The falsifiable half of the deferral: the vector must never be silently reduced to its
/// first element. A Good 1.1 here would be a wrong value an operator would trust.
/// </summary>
[Fact]
public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
snap.Value.ShouldNotBe(1.1);
snap.StatusCode.ShouldNotBe(Good);
}
/// <summary>
/// No-comms outranks the unsupported shape: an UNAVAILABLE array tag is a genuine, fully
/// representable state and must report it rather than the deferral code.
/// </summary>
[Fact]
public void Unavailable_wins_over_the_unsupported_array_shape()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
idx.Apply(ParseFixture(UnavailableFixture));
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>
/// An unauthored TIME_SERIES observation carries the Agent's exact text as a string. That
/// is the raw truth with no interpretation applied — not a scalar guess.
/// </summary>
[Fact]
public void Unauthored_time_series_observation_carries_the_verbatim_vector_text()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<string>().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
}
// --------------------------------------------------------------------- DATA_SET / TABLE
/// <summary>
/// A structured (DATA_SET / TABLE) observation's real content is its <c>&lt;Entry&gt;</c>
/// children, which reach the index concatenated with the keys discarded. It is coded
/// BadNotSupported rather than published as that noise — regardless of whether the data item
/// is authored, because the shape is a fact about the wire, not about the tag.
/// </summary>
[Fact]
public void Structured_observation_reports_BadNotSupported_when_unauthored()
{
var idx = new MTConnectObservationIndex();
idx.Apply(Synthetic("ds1", "12", isStructured: true));
var snap = idx.Get("ds1");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.Value.ShouldBeNull();
}
/// <summary>The String authoring the type inference gives a DATA_SET must not smuggle the noise through.</summary>
[Fact]
public void Structured_observation_is_never_published_as_concatenated_text()
{
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
idx.Apply(Synthetic("ds1", "12", isStructured: true));
var snap = idx.Get("ds1");
snap.Value.ShouldNotBe("12");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(BadNotSupported);
}
/// <summary>No-comms outranks the unsupported shape, exactly as it does for a TIME_SERIES array.</summary>
[Fact]
public void Unavailable_wins_over_the_structured_shape()
{
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
idx.Apply(Synthetic("ds1", "UNAVAILABLE", isStructured: true));
idx.Get("ds1").StatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void A_parsed_data_set_observation_never_surfaces_as_a_good_value()
{
const string xml = """
<MTConnectStreams>
<Header instanceId="1" nextSequence="2" firstSequence="1"/>
<Streams><DeviceStream><ComponentStream><Events>
<VariableDataSet dataItemId="ds1" timestamp="2026-07-24T12:00:00Z" count="2">
<Entry key="V1">1</Entry>
<Entry key="V2">2</Entry>
</VariableDataSet>
</Events></ComponentStream></DeviceStream></Streams>
</MTConnectStreams>
""";
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
idx.Apply(MTConnectStreamsParser.Parse(xml));
var snap = idx.Get("ds1");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.Value.ShouldBeNull();
}
/// <summary>An ordinary text observation is not structured — the flag must not be over-applied.</summary>
[Fact]
public void An_ordinary_observation_is_not_treated_as_structured()
{
var idx = new MTConnectObservationIndex([Tag("dev1_program", DriverDataType.String)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_program");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("O1234");
}
// -------------------------------------------------------------------------------- ordering
/// <summary>Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).</summary>
[Fact]
public void Last_write_wins_within_a_single_apply()
{
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.Int64)]);
idx.Apply(new MTConnectStreamsResult(1L, 4L, 1L, [
new MTConnectObservation("x", "1", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
new MTConnectObservation("x", "2", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
new MTConnectObservation("x", "3", new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc)),
]));
var snap = idx.Get("x");
snap.Value.ShouldBe(3L);
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc));
}
/// <summary>
/// A <c>&lt;Condition&gt;</c> container may carry several simultaneously-active states
/// sharing one dataItemId (a Fault and a Warning at once), which the parser flattens into
/// repeats in one result. Within a single document those states are concurrent, so the most
/// severe wins — plain last-wins would under-report an active Fault as a Warning purely
/// because of element order.
/// </summary>
[Theory]
[InlineData("Fault", "Warning", "Fault")]
[InlineData("Warning", "Fault", "Fault")]
[InlineData("Normal", "Warning", "Warning")]
[InlineData("Warning", "Normal", "Warning")]
[InlineData("Fault", "Normal", "Fault")]
public void Simultaneous_condition_states_keep_the_most_severe_within_one_apply(
string first, string second, string expected)
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
new MTConnectObservation("c", first, new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
new MTConnectObservation("c", second, new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
]));
idx.Get("c").Value.ShouldBe(expected);
}
/// <summary>An active fault carries more information than "no value"; it outranks UNAVAILABLE.</summary>
[Fact]
public void An_active_fault_outranks_unavailable_within_one_apply()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
new MTConnectObservation("c", "UNAVAILABLE", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
new MTConnectObservation("c", "Fault", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
]));
var snap = idx.Get("c");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("Fault");
}
/// <summary>
/// Worst-of is scoped to ONE document. A later document is the Agent's new statement of the
/// condition's state, so a cleared fault must fall back to Normal — a worst-of that spanned
/// Applies would latch every fault forever.
/// </summary>
[Fact]
public void A_later_apply_clears_a_condition_to_its_new_state()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
idx.Apply(Synthetic("c", "Fault"));
idx.Get("c").Value.ShouldBe("Fault");
idx.Apply(Synthetic("c", "Normal"));
idx.Get("c").Value.ShouldBe("Normal");
}
/// <summary>Severity reconciliation is for condition words only; repeated ordinary values stay last-wins.</summary>
[Fact]
public void Repeated_non_condition_values_stay_last_wins()
{
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
new MTConnectObservation("x", "Fault", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
new MTConnectObservation("x", "O1234", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
]));
idx.Get("x").Value.ShouldBe("O1234");
}
/// <summary>A later Apply overwrites an earlier one — including Good going to no-comms.</summary>
[Fact]
public void A_later_apply_overwrites_an_earlier_one()
{
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
idx.Get("dev1_pos").StatusCode.ShouldBe(Good);
idx.Apply(ParseFixture(UnavailableFixture));
idx.Get("dev1_pos").StatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>An Apply carrying no observations leaves the index untouched (an idle /sample heartbeat).</summary>
[Fact]
public void An_observation_free_apply_leaves_the_index_untouched()
{
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
idx.Apply(ParseFixture(CurrentFixture));
idx.Apply(new MTConnectStreamsResult(1L, 200L, 1L, []));
idx.Get("dev1_pos").Value.ShouldBe(123.4567);
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
}
/// <summary>Clear drops every indexed observation — the Agent-restart re-baseline Task 11 needs.</summary>
[Fact]
public void Clear_drops_every_indexed_observation()
{
var idx = new MTConnectObservationIndex();
idx.Apply(ParseFixture(CurrentFixture));
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
idx.Clear();
idx.Count.ShouldBe(0);
idx.Get("dev1_pos").StatusCode.ShouldBe(BadNodeIdUnknown);
}
// ------------------------------------------------------------------------ authored-tag map
/// <summary>
/// Nothing enforces FullName uniqueness at the options layer, so a duplicate is operator-
/// authorable. Last definition wins rather than throwing — refusing to construct would turn
/// an authoring slip into a driver that will not start.
/// </summary>
[Fact]
public void Duplicate_authored_fullname_takes_the_last_definition()
{
var idx = new MTConnectObservationIndex([
Tag("dev1_pos", DriverDataType.String),
Tag("dev1_pos", DriverDataType.Float64),
]);
idx.Apply(ParseFixture(CurrentFixture));
idx.Get("dev1_pos").Value.ShouldBeOfType<double>().ShouldBe(123.4567);
}
/// <summary>The index binds to the options' Tags collection, which defaults to empty and is never null.</summary>
[Fact]
public void Index_can_be_built_from_driver_options()
{
var options = new MTConnectDriverOptions
{
AgentUri = "http://agent:5000",
Tags = [Tag("dev1_pos", DriverDataType.Float64)],
};
var idx = new MTConnectObservationIndex(options.Tags);
idx.Apply(ParseFixture(CurrentFixture));
idx.Get("dev1_pos").Value.ShouldBe(123.4567);
}
// -------------------------------------------------------------------------- thread safety
/// <summary>
/// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
/// snapshot. Three properties, each stated so it holds at <b>every instant</b> rather than
/// only once the race settles: no call throws; no read observes a torn snapshot; and the
/// state after a deterministic closing Apply is exact.
/// <para>
/// <b>Torn-snapshot check.</b> The whole <c>(status, value, source timestamp)</c> triple
/// is matched against the closed set of snapshots <c>dev1_pos</c> may legally present —
/// never a field in isolation. A Good code beside the unavailable document's timestamp,
/// or beside a null value, matches no member and fails.
/// </para>
/// <para>
/// <b>The legal set has THREE members, not two.</b> <c>dev1_pos</c> is authored, so a
/// reader that beats the first Apply correctly observes <c>BadWaitingForInitialData</c> —
/// the designed "authored, not yet reported" state. An earlier version of this test
/// asserted a post-Apply post-condition from inside the race window and failed whenever
/// a reader got there first: it was asserting something the index never promised, and it
/// verified nothing its name claimed.
/// </para>
/// </summary>
[Fact]
public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
{
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
var current = ParseFixture(CurrentFixture);
var unavailable = ParseFixture(UnavailableFixture);
var goodAt = new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc);
(uint Status, object? Value, DateTime? Source)[] legalSnapshots =
[
(BadWaitingForInitialData, null, null),
(Good, 123.4567, goodAt),
(BadNoCommunication, null, new DateTime(2026, 7, 24, 12, 10, 0, 100, DateTimeKind.Utc)),
];
var reads = 0L;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
{
while (!cts.IsCancellationRequested)
{
idx.Apply(i % 2 == 0 ? current : unavailable);
}
})).ToArray();
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
while (!cts.IsCancellationRequested)
{
var snap = idx.Get("dev1_pos");
legalSnapshots.ShouldContain((snap.StatusCode, snap.Value, snap.SourceTimestampUtc));
Interlocked.Increment(ref reads);
}
})).ToArray();
await Task.WhenAll(writers.Concat(readers));
// Guards a vacuous pass: a reader loop that never ran would have asserted nothing.
Interlocked.Read(ref reads).ShouldBeGreaterThan(0L);
// The racing writers alternate documents, so the state DURING the race is unknowable by
// construction — pinning a specific one there is exactly the defect this test used to have.
// One deterministic apply, after every writer has stopped, gives an exact end state.
idx.Apply(current);
var final = idx.Get("dev1_pos");
final.StatusCode.ShouldBe(Good);
final.Value.ShouldBe(123.4567);
final.SourceTimestampUtc.ShouldBe(goodAt);
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
}
/// <summary>Applying a null result is a programming error, not degradable data.</summary>
[Fact]
public void Apply_rejects_a_null_result()
{
var idx = new MTConnectObservationIndex();
Should.Throw<ArgumentNullException>(() => idx.Apply(null!));
}
}
@@ -0,0 +1,496 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 6 — <c>/probe</c> parse into the neutral <see cref="MTConnectProbeModel"/>, plus the
/// <see cref="MTConnectAgentClient.ProbeAsync"/> leg that feeds it (URL composition + the
/// per-op deadline), all exercised with no socket.
/// </summary>
public sealed class MTConnectProbeParseTests
{
private const string ProbeFixturePath = "Fixtures/probe.xml";
/// <summary>Every DataItem id the committed fixture declares — all seven, at every depth.</summary>
private static readonly string[] AllFixtureDataItemIds =
[
"dev1_avail", // sits directly on <Device><DataItems>, NOT under a component
"dev1_pos",
"dev1_vibration_ts",
"dev1_partcount",
"dev1_execution",
"dev1_program",
"dev1_system_cond" // CONDITION
];
// ---------------------------------------------------------------- parse: the committed fixture
[Fact]
public async Task Probe_parse_yields_nested_components_and_all_dataitems()
{
var xml = await File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
var model = MTConnectProbeParser.Parse(xml);
model.Devices.ShouldHaveSingleItem();
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
ids.ShouldContain("dev1_system_cond");
model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
}
[Fact]
public async Task Probe_parse_finds_every_dataitem_including_the_device_level_one()
{
var model = MTConnectProbeParser.Parse(await ReadFixtureAsync());
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
// All seven — a walker that only recurses <Components> silently drops dev1_avail.
ids.ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
model.Devices[0].DataItems.Select(d => d.Id).ShouldBe(["dev1_avail"]);
}
[Fact]
public async Task Probe_parse_reads_the_device_identity()
{
var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
device.Id.ShouldBe("dev1");
device.Name.ShouldBe("VMC-3Axis");
}
[Fact]
public async Task Probe_parse_preserves_the_component_nesting_chain()
{
var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
// Device -> Controller -> Path. The element names are the component TYPES (there is no
// literal <Component> element in MTConnect Devices XML).
var controller = device.Components.ShouldHaveSingleItem();
controller.Id.ShouldBe("dev1_controller");
controller.Name.ShouldBe("controller");
controller.DataItems.ShouldBeEmpty();
var path = controller.Components.ShouldHaveSingleItem();
path.Id.ShouldBe("dev1_path");
path.Name.ShouldBe("path");
path.Components.ShouldBeEmpty();
path.DataItems.Select(d => d.Id).ShouldBe(
["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond"]);
}
[Fact]
public async Task Probe_parse_round_trips_every_dataitem_attribute()
{
var items = MTConnectProbeParser.Parse(await ReadFixtureAsync())
.Devices[0].AllDataItems().ToDictionary(d => d.Id);
var pos = items["dev1_pos"];
pos.Category.ShouldBe("SAMPLE");
pos.Type.ShouldBe("POSITION");
pos.SubType.ShouldBe("ACTUAL");
pos.Units.ShouldBe("MILLIMETER");
pos.Representation.ShouldBeNull();
pos.SampleCount.ShouldBeNull();
var timeSeries = items["dev1_vibration_ts"];
timeSeries.Category.ShouldBe("SAMPLE");
timeSeries.Type.ShouldBe("PATH_FEEDRATE");
timeSeries.Representation.ShouldBe("TIME_SERIES");
timeSeries.SampleCount.ShouldBe(10);
timeSeries.Units.ShouldBe("MILLIMETER/SECOND");
var condition = items["dev1_system_cond"];
condition.Category.ShouldBe("CONDITION");
condition.Type.ShouldBe("SYSTEM");
var availability = items["dev1_avail"];
availability.Category.ShouldBe("EVENT");
availability.Type.ShouldBe("AVAILABILITY");
availability.Name.ShouldBeNull();
availability.SubType.ShouldBeNull();
availability.Units.ShouldBeNull();
availability.Representation.ShouldBeNull();
availability.SampleCount.ShouldBeNull();
}
[Fact]
public async Task Probe_parse_from_a_stream_matches_the_string_overload()
{
await using var stream = File.OpenRead(ProbeFixturePath);
var fromStream = MTConnectProbeParser.Parse(stream);
// Records compare their IReadOnlyList members by reference, so compare the flattened
// content rather than the graphs themselves.
var fromString = MTConnectProbeParser.Parse(await ReadFixtureAsync());
fromStream.Devices.Select(d => d.Id).ShouldBe(fromString.Devices.Select(d => d.Id));
fromStream.Devices[0].AllDataItems().ShouldBe(fromString.Devices[0].AllDataItems());
fromStream.Devices[0].Components[0].Components[0].Id.ShouldBe("dev1_path");
}
// ------------------------------------------------------- parse: not shaped to the 1.3 fixture
[Theory]
[InlineData("urn:mtconnect.org:MTConnectDevices:1.3")]
[InlineData("urn:mtconnect.org:MTConnectDevices:1.5")]
[InlineData("urn:mtconnect.org:MTConnectDevices:2.0")]
[InlineData("")] // no namespace at all
public void Probe_parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
{
var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
var xml = $"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectDevices{xmlns}>
<Header instanceId="1" bufferSize="131072"/>
<Devices>
<Device id="d" name="D">
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
</Device>
</Devices>
</MTConnectDevices>
""";
var model = MTConnectProbeParser.Parse(xml);
model.Devices.ShouldHaveSingleItem().AllDataItems().Select(d => d.Id).ShouldBe(["d_avail"]);
}
[Fact]
public void Probe_parse_recurses_components_to_arbitrary_depth()
{
// Four levels deep, and each level carries its own data item — deeper than the fixture, and
// with component element names the parser has never seen.
const string Xml = """
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
<Devices>
<Device id="d">
<DataItems><DataItem id="l0" category="EVENT" type="AVAILABILITY"/></DataItems>
<Components>
<Axes id="ax">
<DataItems><DataItem id="l1" category="EVENT" type="PART_COUNT"/></DataItems>
<Components>
<Linear id="x">
<DataItems><DataItem id="l2" category="SAMPLE" type="POSITION"/></DataItems>
<Components>
<Motor id="m">
<DataItems><DataItem id="l3" category="SAMPLE" type="TEMPERATURE"/></DataItems>
</Motor>
</Components>
</Linear>
</Components>
</Axes>
</Components>
</Device>
</Devices>
</MTConnectDevices>
""";
var device = MTConnectProbeParser.Parse(Xml).Devices[0];
device.AllDataItems().Select(d => d.Id).ShouldBe(["l0", "l1", "l2", "l3"]);
device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
}
[Fact]
public void Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace()
{
// The shape the parser's ignore-the-namespace rule exists for, and the reason attributes are
// read unqualified: the vendor component carries its OWN namespace prefix, while its
// standard <DataItems>/<DataItem> children inherit the DEFAULT MTConnect namespace. Matching
// components on a fully-qualified XName would silently drop <x:Pump> and every data item
// beneath it, leaving the browse tree short with no error anywhere.
const string Xml = """
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
xmlns:x="urn:vendor:acme:1.0">
<Devices>
<Device id="d" name="D">
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
<Components>
<x:Pump id="pump1" name="pump">
<DataItems>
<DataItem id="pump_pressure" category="SAMPLE" type="PRESSURE" units="PASCAL"/>
</DataItems>
<Components>
<x:Impeller id="imp1">
<DataItems>
<DataItem id="imp_rpm" category="SAMPLE" type="ROTARY_VELOCITY"/>
</DataItems>
</x:Impeller>
</Components>
</x:Pump>
</Components>
</Device>
</Devices>
</MTConnectDevices>
""";
var device = MTConnectProbeParser.Parse(Xml).Devices[0];
var pump = device.Components.ShouldHaveSingleItem();
pump.Id.ShouldBe("pump1");
pump.Name.ShouldBe("pump");
pump.Components.ShouldHaveSingleItem().Id.ShouldBe("imp1");
device.AllDataItems().Select(d => d.Id).ShouldBe(["d_avail", "pump_pressure", "imp_rpm"]);
device.AllDataItems().Single(d => d.Id == "pump_pressure").Units.ShouldBe("PASCAL");
}
[Fact]
public void Probe_parse_reads_every_device_of_a_multi_device_agent()
{
const string Xml = """
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
<Devices>
<Device id="agent" name="Agent">
<DataItems><DataItem id="agent_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
</Device>
<Device id="d1" name="One">
<DataItems><DataItem id="d1_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
</Device>
</Devices>
</MTConnectDevices>
""";
var model = MTConnectProbeParser.Parse(Xml);
model.Devices.Select(d => d.Id).ShouldBe(["agent", "d1"]);
}
// ------------------------------------------------------------------ parse: fails loudly, never
// a silently-empty model
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not xml at all")]
[InlineData("<MTConnectDevices><Devices>")] // truncated mid-document
public void Probe_parse_throws_on_malformed_xml(string xml)
{
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
}
[Fact]
public void Probe_parse_throws_on_a_non_mtconnect_document()
{
var ex = Should.Throw<InvalidDataException>(
() => MTConnectProbeParser.Parse("<html><body>404 Not Found</body></html>"));
ex.Message.ShouldContain("MTConnectDevices");
ex.Message.ShouldContain("html");
}
[Fact]
public void Probe_parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
{
// A real Agent answers an unknown device path with an MTConnectError document under HTTP 200.
const string Xml = """
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header instanceId="1"/>
<Errors>
<Error errorCode="NO_DEVICE">Could not find the device 'nope'</Error>
</Errors>
</MTConnectError>
""";
var ex = Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
ex.Message.ShouldContain("NO_DEVICE");
ex.Message.ShouldContain("Could not find the device 'nope'");
}
[Theory]
[InlineData("<MTConnectDevices><Header instanceId=\"1\"/></MTConnectDevices>")] // no <Devices>
[InlineData("<MTConnectDevices><Header instanceId=\"1\"/><Devices/></MTConnectDevices>")] // empty <Devices>
public void Probe_parse_throws_when_the_document_declares_no_devices(string xml)
{
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
}
[Theory]
// DataItem missing its required id / category / type, and a garbage sampleCount.
[InlineData("<DataItem category=\"EVENT\" type=\"AVAILABILITY\"/>")]
[InlineData("<DataItem id=\"x\" type=\"AVAILABILITY\"/>")]
[InlineData("<DataItem id=\"x\" category=\"EVENT\"/>")]
[InlineData("<DataItem id=\"x\" category=\"SAMPLE\" type=\"POSITION\" sampleCount=\"lots\"/>")]
public void Probe_parse_throws_on_a_malformed_dataitem(string dataItemXml)
{
var xml = $"""
<MTConnectDevices>
<Devices><Device id="d"><DataItems>{dataItemXml}</DataItems></Device></Devices>
</MTConnectDevices>
""";
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
}
[Fact]
public void Probe_parse_throws_when_a_device_has_no_id()
{
const string Xml = """
<MTConnectDevices>
<Devices><Device name="nameless"/></Devices>
</MTConnectDevices>
""";
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
}
[Fact]
public void Probe_parse_throws_when_a_component_has_no_id()
{
const string Xml = """
<MTConnectDevices>
<Devices><Device id="d"><Components><Controller name="c"/></Components></Device></Devices>
</MTConnectDevices>
""";
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
}
// ---------------------------------------------------------------- MTConnectAgentClient.ProbeAsync
[Fact]
public async Task ProbeAsync_requests_the_agent_probe_path_and_returns_the_parsed_model()
{
var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
var model = await client.ProbeAsync(TestContext.Current.CancellationToken);
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
model.Devices.ShouldHaveSingleItem().Id.ShouldBe("dev1");
}
[Theory]
[InlineData("http://agent:5000", null, "http://agent:5000/probe")]
[InlineData("http://agent:5000/", null, "http://agent:5000/probe")]
[InlineData("http://agent:5000", "VMC-3Axis", "http://agent:5000/VMC-3Axis/probe")]
[InlineData("http://agent:5000/", "VMC 3Axis", "http://agent:5000/VMC%203Axis/probe")]
public async Task ProbeAsync_scopes_the_request_to_the_configured_device_name(
string agentUri, string? deviceName, string expectedUri)
{
var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = agentUri, DeviceName = deviceName }, handler);
await client.ProbeAsync(TestContext.Current.CancellationToken);
handler.LastRequestUri!.AbsoluteUri.ShouldBe(expectedUri);
}
[Fact]
public async Task ProbeAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
{
// The R2-01 frozen-peer lesson: a wedged agent must surface as a cancelled call, not a hang.
var handler = StubHandler.Hanging();
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }, handler);
var started = Stopwatch.StartNew();
var ex = await Should.ThrowAsync<TimeoutException>(
async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
// A deadline hit must NOT be reported as a plain cancellation — the caller never cancelled.
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
ex.Message.ShouldContain("http://agent:5000/probe");
started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
[Fact]
public async Task ProbeAsync_honours_the_callers_cancellation_token()
{
var handler = StubHandler.Hanging();
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(async () => await client.ProbeAsync(cts.Token));
}
[Fact]
public async Task ProbeAsync_throws_rather_than_returning_an_empty_model_when_the_agent_errors()
{
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
await Should.ThrowAsync<HttpRequestException>(
async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Ctor_rejects_a_non_positive_request_timeout(int requestTimeoutMs)
{
// An operator-authorable 0 must never mean "wait forever" (arch-review 01/S-6).
Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = requestTimeoutMs }));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a uri")]
public void Ctor_rejects_an_unusable_agent_uri(string agentUri)
{
Should.Throw<ArgumentException>(() => new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = agentUri }));
}
[Fact]
public void Ctor_opens_no_connection()
{
// The universal browser's throwaway-instance CanBrowse pattern depends on this.
var handler = StubHandler.Hanging();
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
handler.RequestCount.ShouldBe(0);
}
private static Task<string> ReadFixtureAsync() =>
File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
/// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
private sealed class StubHandler : HttpMessageHandler
{
private readonly string? _body;
private readonly HttpStatusCode _status;
private readonly bool _hang;
private StubHandler(string? body, HttpStatusCode status, bool hang)
{
_body = body;
_status = status;
_hang = hang;
}
public Uri? LastRequestUri { get; private set; }
public int RequestCount { get; private set; }
public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
new(body, status, hang: false);
public static StubHandler Hanging() => new(null, HttpStatusCode.OK, hang: true);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestUri = request.RequestUri;
RequestCount++;
if (_hang)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
return new HttpResponseMessage(_status)
{
Content = new StringContent(_body ?? string.Empty, Encoding.UTF8, "text/xml")
};
}
}
}
@@ -0,0 +1,458 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The v3 wire contract: the runtime hands this driver its authored tags as a <c>RawTags</c>
/// array of <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob) and then
/// reads, subscribes, and routes published values <b>by RawPath</b> — never by the driver's own
/// internal address (here, the MTConnect DataItem <c>id</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Every config in this file is built by the real
/// <see cref="DriverDeviceConfigMerger"/></b> — the same merge
/// <c>DeploymentArtifact.TryReadSpec</c> runs to produce a
/// <c>DriverInstanceSpec.DriverConfig</c>. A hand-written JSON literal would only prove the
/// driver binds the shape the test author imagined; this proves it binds the shape the
/// deploy artifact actually produces, including the Pascal-case property names
/// <c>RawTagEntry</c> serialises under and the <c>TagConfig</c> blob arriving as an escaped
/// string rather than a nested object.
/// </para>
/// <para>
/// <b>The load-bearing assertion is the reference on the event, not the value.</b>
/// <c>DriverHostActor</c> routes a published value by <c>(DriverInstanceId, RawPath)</c>
/// against a NodeId table it built from the same RawPaths; a driver that published a
/// perfectly correct value under its own dataItemId would miss that table on every tick and
/// the value would be dropped silently — with no error anywhere and every unit test green.
/// </para>
/// </remarks>
public sealed class MTConnectRawTagBindingTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The DataItem ids the canned <c>Fixtures/</c> documents report.</summary>
private const string PositionDataItemId = "dev1_pos";
private const string ExecutionDataItemId = "dev1_execution";
private const string PartCountDataItemId = "dev1_partcount";
/// <summary>The RawPaths a deployed <c>/raw</c> tree would give those data items.</summary>
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution";
private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount";
/// <summary>The <c>instanceId</c> every canned fixture shares.</summary>
private const long FixtureInstanceId = 1655000000L;
/// <summary>The cursor <c>Fixtures/current.xml</c> primes the pump with.</summary>
private const long PrimedNextSequence = 108L;
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadTypeMismatch = 0x80740000u;
private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// The constructor options a spawned driver starts from: the endpoint only. Everything the
/// data plane binds arrives in the merged config document, exactly as it does in production.
/// </summary>
private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri };
/// <summary>
/// Builds the merged <c>DriverConfig</c> for one MTConnect driver instance the way the deploy
/// artifact does: driver-level config + the device's <c>DeviceConfig</c> + the authored raw
/// tags injected as the <c>RawTags</c> array.
/// </summary>
private static string MergedConfig(params RawTagEntry[] rawTags) =>
DriverDeviceConfigMerger.Merge(
$$"""{"AgentUri":"{{AgentUri}}"}""",
[new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")],
rawTags);
/// <summary>One authored raw tag: a RawPath bound to a DataItem id, typed for coercion.</summary>
private static RawTagEntry Entry(string rawPath, string dataItemId, string? driverDataType = null) =>
new(
rawPath,
driverDataType is null
? $$"""{"fullName":"{{dataItemId}}"}"""
: $$"""{"fullName":"{{dataItemId}}","driverDataType":"{{driverDataType}}"}""",
WriteIdempotent: false,
DeviceName: "dev1");
private static MTConnectStreamsResult Chunk(
long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
new(
FixtureInstanceId,
nextSequence,
firstSequence,
[.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);
private static IReadOnlyList<DataChangeEventArgs> For(
ConcurrentQueue<DataChangeEventArgs> seen, string reference) =>
[.. seen.Where(e => e.FullReference == reference)];
private static ConcurrentQueue<DataChangeEventArgs> Record(MTConnectDriver driver)
{
var seen = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => seen.Enqueue(e);
return seen;
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> DeployedAsync(
params RawTagEntry[] rawTags)
{
var agent = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
await driver.InitializeAsync(MergedConfig(rawTags), Ct);
return (driver, agent);
}
// ---- the production data plane: subscribe by RawPath, publish by RawPath ----
/// <summary>
/// The whole blocker in one case: a deployed driver is subscribed by <b>RawPath</b> (that is
/// what <c>DriverInstanceActor.HandleSubscribeAsync</c> passes, and what
/// <c>DriverHostActor</c>'s NodeId table is keyed by) and must answer under that same
/// RawPath — both for the initial value and for every streamed chunk.
/// </summary>
[Fact]
public async Task Subscribing_by_raw_path_publishes_under_the_raw_path()
{
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
// Initial data, out of the priming /current — under the RawPath, not the dataItemId.
var initial = For(seen, PositionRawPath).ShouldHaveSingleItem();
initial.Snapshot.StatusCode.ShouldBe(Good);
initial.Snapshot.Value.ShouldBe(123.4567d);
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
var streamed = For(seen, PositionRawPath);
streamed.Count.ShouldBe(2);
streamed[1].Snapshot.StatusCode.ShouldBe(Good);
streamed[1].Snapshot.Value.ShouldBe(201.5d);
// …and NOTHING is ever published under the driver-internal dataItemId: a value routed by that
// reference misses DriverHostActor's NodeId table and is dropped without a trace.
For(seen, PositionDataItemId).ShouldBeEmpty();
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The coercion type must survive the new binding path. The <c>TagConfig</c> blob's
/// <c>driverDataType</c> is what tells the observation index to publish <c>201.5</c> as a
/// <see cref="double"/> rather than the Agent's raw text — losing it would turn every value
/// into a Good-coded string, which the OPC UA node (typed Double) cannot carry.
/// </summary>
[Fact]
public async Task Raw_tag_config_carries_the_coercion_type_into_the_index()
{
var (driver, _) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(ExecutionRawPath, ExecutionDataItemId, nameof(DriverDataType.String)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath, ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBeOfType<double>();
For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType<string>();
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// <see cref="IReadable.ReadAsync"/> keys by RawPath too. The server never calls it (the data
/// plane is subscribe-only), but the driver CLI does, and a read that answered
/// <c>BadNodeIdUnknown</c> for a tag the subscription serves happily would be a lie about the
/// deployment.
/// </summary>
[Fact]
public async Task Read_resolves_raw_paths()
{
var (driver, _) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));
var values = await driver.ReadAsync([PositionRawPath, PartCountRawPath, "Plant/MTConnect/dev1/Nope"], Ct);
values.Count.ShouldBe(3);
values[0].StatusCode.ShouldBe(Good);
values[0].Value.ShouldBe(123.4567d);
// Authored + bound, but the Agent reports it UNAVAILABLE in the fixture.
values[1].StatusCode.ShouldBe(BadNoCommunication);
// Not in RawTags at all: a miss is a miss, surfaced as Bad — never a throw.
values[2].StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The AdminUI's typed editor (<c>MTConnectTagConfigModel</c>) writes the coercion type under
/// <c>dataType</c>, not <c>driverDataType</c>. Reading only the driver's own spelling would
/// make every editor-authored tag fall back to String — the editor and the runtime disagreeing
/// about the type of every value, with nothing anywhere reporting it.
/// </summary>
[Fact]
public async Task The_admin_ui_datatype_spelling_reaches_the_coercion()
{
var (driver, _) = await DeployedAsync(
new RawTagEntry(
PositionRawPath,
"""{"fullName":"dev1_pos","dataType":"Float64","mtCategory":"SAMPLE","mtType":"POSITION"}""",
WriteIdempotent: false,
DeviceName: "dev1"));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A browse-committed tag's blob carries ONLY the address (<c>RawBrowseCommitMapper</c> has no
/// MTConnect branch, so it writes the generic <c>{"address": id}</c> shape) — no type at all.
/// The driver falls back to the Agent's own <c>/probe</c> declaration, so a POSITION SAMPLE
/// publishes as a <see cref="double"/> rather than as the Agent's raw text under a
/// numerically-typed OPC UA node.
/// </summary>
[Fact]
public async Task A_typeless_blob_takes_the_type_the_agent_declares()
{
var (driver, _) = await DeployedAsync(
new RawTagEntry(PositionRawPath, """{"address":"dev1_pos"}""", false, "dev1"));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
var initial = For(seen, PositionRawPath)[0];
initial.Snapshot.StatusCode.ShouldBe(Good);
initial.Snapshot.Value.ShouldBeOfType<double>().ShouldBe(123.4567d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A <see cref="MTConnectDriverOptions.Tags"/> entry supplies the coercion type for a raw tag
/// whose blob declares none — the middle rung of the documented precedence, and what keeps a
/// driver authored through both surfaces coherent.
/// </summary>
[Fact]
public async Task Tags_supply_the_type_for_a_raw_tag_whose_blob_declares_none()
{
var agent = CannedAgentClient.FromFixtures();
// dev1_execution is an EVENT the probe model would infer as String; the Tags entry overrides
// that with Reference (a type nothing could infer), so the assertion can only pass if the
// Tags entry — not the inference — supplied it.
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = AgentUri,
Tags = [new MTConnectTagDefinition(ExecutionDataItemId, DriverDataType.Int32)],
},
"mt1",
_ => agent);
// A config document with a body REPLACES the constructor options, so the Tags entry has to
// travel in it too — this is the shape a driver authored through both surfaces deploys as.
await driver.InitializeAsync(
$$"""
{"AgentUri":"{{AgentUri}}",
"Tags":[{"FullName":"{{ExecutionDataItemId}}","DriverDataType":"Int32"}],
"RawTags":[{"RawPath":"{{ExecutionRawPath}}","TagConfig":"{\"fullName\":\"{{ExecutionDataItemId}}\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
""",
Ct);
var seen = Record(driver);
await driver.SubscribeAsync([ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);
// The fixture reports dev1_execution as "ACTIVE" — not an Int32, so the Int32 the Tags entry
// declared is what makes this a type mismatch instead of a Good string.
For(seen, ExecutionRawPath)[0].Snapshot.StatusCode.ShouldBe(BadTypeMismatch);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// <c>RawTags</c> and <c>Tags</c> may both name the same DataItem. The RawTags blob is the
/// deploy-time authority and its declared type wins; the dataItemId-keyed reference keeps
/// working alongside the RawPath, so a CLI session against a deployed driver still resolves.
/// </summary>
[Fact]
public async Task Both_collections_together_bind_both_references_with_rawtags_winning_the_type()
{
var agent = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
await driver.InitializeAsync(
$$"""
{"AgentUri":"{{AgentUri}}",
"Tags":[{"FullName":"{{PositionDataItemId}}","DriverDataType":"String"}],
"RawTags":[{"RawPath":"{{PositionRawPath}}","TagConfig":"{\"fullName\":\"{{PositionDataItemId}}\",\"driverDataType\":\"Float64\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
""",
Ct);
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, PositionDataItemId], TimeSpan.FromMilliseconds(50), Ct);
// The RawTags blob's Float64 beat the Tags entry's String — for BOTH references, because
// there is one index and one definition per DataItem.
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
For(seen, PositionDataItemId)[0].Snapshot.Value.ShouldBe(123.4567d);
// …and a streamed chunk raises BOTH, once each.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
For(seen, PositionDataItemId).Count.ShouldBe(2);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// One DataItem may back several authored raw tags (the same machine signal projected into two
/// places in the <c>/raw</c> tree). Each is its own OPC UA node and each must receive the
/// value — a one-to-one map would silently serve only whichever tag was authored last.
/// </summary>
[Fact]
public async Task One_data_item_fans_out_to_every_raw_path_bound_to_it()
{
const string secondRawPath = "Plant/MTConnect/dev1/Mirror/Position";
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(secondRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, secondRawPath], TimeSpan.FromMilliseconds(50), Ct);
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
For(seen, secondRawPath).Count.ShouldBe(2);
For(seen, secondRawPath)[1].Snapshot.Value.ShouldBe(201.5d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A blob that names no DataItem id, or declares a <c>dataType</c> that is not a
/// <see cref="DriverDataType"/> (including the numerically-serialized-enum trap, where
/// <c>Enum.TryParse</c> would happily take <c>"8"</c> as the member with that ordinal), skips
/// that ONE tag — the rest of the driver deploys and serves normally.
/// </summary>
[Theory]
[InlineData("""{"units":"MILLIMETER"}""")]
[InlineData("""{"fullName":"dev1_pos","dataType":"Flot64"}""")]
[InlineData("""{"fullName":"dev1_pos","dataType":"8"}""")]
[InlineData("not json at all")]
public async Task An_unusable_blob_skips_only_its_own_tag(string badBlob)
{
var (driver, _) = await DeployedAsync(
new RawTagEntry("Plant/MTConnect/dev1/Bad", badBlob, false, "dev1"),
Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));
var values = await driver.ReadAsync(["Plant/MTConnect/dev1/Bad", PartCountRawPath], Ct);
values[0].StatusCode.ShouldBe(BadNodeIdUnknown);
values[1].StatusCode.ShouldBe(BadNoCommunication); // bound; the Agent reports it UNAVAILABLE
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A redeploy that re-homes a tag in the <c>/raw</c> tree changes its RawPath. The driver must
/// rebind to the new one — and stop answering for the old one, which no longer names anything.
/// This is also the ordering pin: the binding is installed with the options, before the index
/// the pump republishes from, so a standing subscription can never be served through one
/// document's RawPaths against another's values.
/// </summary>
[Fact]
public async Task Reinitialize_rebinds_raw_paths()
{
const string movedRawPath = "Plant/MTConnect/dev1/Spindle/Position";
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath, movedRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, movedRawPath)[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ReinitializeAsync(
MergedConfig(Entry(movedRawPath, PositionDataItemId, nameof(DriverDataType.Float64))), Ct);
// The restarted pump republishes every standing reference as its first act.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, movedRawPath)[^1].Snapshot.Value.ShouldBe(201.5d);
For(seen, PositionRawPath)[^1].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The factory is the production construction seam (<c>DriverFactoryRegistry</c> calls it with
/// the merged artifact config), so <c>RawTags</c> has to survive that path too — a driver whose
/// RawPaths only bind when constructed by hand is a driver that works in tests and nowhere else.
/// </summary>
[Fact]
public void The_factory_binds_raw_tags_from_the_merged_config()
{
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
"mt1",
MergedConfig(Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))));
var bound = driver.EffectiveOptions.RawTags.ShouldHaveSingleItem();
bound.RawPath.ShouldBe(PositionRawPath);
bound.DeviceName.ShouldBe("dev1");
bound.TagConfig.ShouldContain(PositionDataItemId);
}
/// <summary>
/// A subscribed RawPath with no <c>RawTags</c> entry behind it (an authoring slip, or a tag
/// whose blob would not map) must report Bad and keep serving every other reference — the
/// documented <see cref="EquipmentTagRefResolver{TDef}"/> posture: "a miss is a miss".
/// </summary>
[Fact]
public async Task An_unmapped_raw_path_reports_bad_and_never_throws()
{
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, "Plant/MTConnect/dev1/Ghost"], TimeSpan.FromMilliseconds(50), Ct);
For(seen, "Plant/MTConnect/dev1/Ghost")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
// The bound reference is unaffected — the miss did not poison the batch or the stream.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
await driver.ShutdownAsync(Ct);
}
}
@@ -0,0 +1,456 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 10 — <see cref="MTConnectDriver"/>'s <see cref="IReadable"/> half: one <c>/current</c>
/// per batch, one snapshot per requested reference in request order, and a Bad code for every
/// shape of failure. Every test runs against <see cref="CannedAgentClient"/>; no socket is
/// opened anywhere in this file.
/// </summary>
/// <remarks>
/// <para>
/// <b>The load-bearing assertions are about arity, order, and not-throwing.</b> The caller
/// (the runtime's dispatch layer) indexes the returned list <i>positionally</i> against the
/// references it asked for, so a length mismatch or a reorder is silent data corruption —
/// every value lands on the wrong tag under Good quality. And every failure this driver can
/// meet at read time (agent down, deadline blown, unparseable answer, unknown id, driver not
/// initialized) must arrive as a Bad-coded snapshot: an exception out of
/// <see cref="IReadable.ReadAsync"/> fails the whole batch including the references that
/// were perfectly readable.
/// </para>
/// <para>
/// <b>The one exception that may propagate is genuine caller cancellation</b>, and it is
/// pinned here alongside its opposite (an agent failure under an uncancelled token) — the
/// two are one <c>catch</c> filter apart in the implementation, and getting the filter
/// backwards turns every dead agent into a thrown batch.
/// </para>
/// </remarks>
public sealed class MTConnectReadTests
{
private const string AgentUri = "http://fixture-agent:5000";
// Canonical Opc.Ua.StatusCodes numerics, restated here so the test asserts the wire value an
// OPC UA client actually sees rather than a driver-private constant it could drift with.
private const uint Good = 0x00000000u;
private const uint BadMask = 0x80000000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadNotSupported = 0x803D0000u;
private const uint BadNotConnected = 0x808A0000u;
/// <summary>
/// Authored tags spanning every read outcome the fixtures can produce: a coercible Good
/// value, an <c>UNAVAILABLE</c> one, a TIME_SERIES vector, and one authored id no fixture
/// ever reports.
/// </summary>
private static MTConnectDriverOptions Opts(int requestTimeoutMs = 5000) =>
new()
{
AgentUri = AgentUri,
RequestTimeoutMs = requestTimeoutMs,
Tags =
[
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32),
new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10),
new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32),
],
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions? options = null)
{
var client = CannedAgentClient.FromFixtures();
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
MTConnectDriverOptions? options = null)
{
var (driver, client) = NewDriver(options);
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (driver, client);
}
// ---- arity + order: the contract the caller indexes positionally ----
/// <summary>The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw.</summary>
[Fact]
public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_pos", "does-not-exist"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(2);
res[0].StatusCode.ShouldBe(Good);
(res[1].StatusCode & BadMask).ShouldBe(BadMask);
}
/// <summary>
/// A mixed batch requested in an order that matches neither the document order nor any
/// dictionary enumeration order. This is the assertion that catches a driver that answers
/// with the index's own key order instead of the request's.
/// </summary>
[Fact]
public async Task Read_answers_in_request_order_not_document_order()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(
["dev1_program", "dev1_pos", "does-not-exist", "dev1_partcount", "dev1_execution"],
TestContext.Current.CancellationToken);
res.Count.ShouldBe(5);
// dev1_program is observed but NOT authored — String is the only coercion that cannot fail.
res[0].StatusCode.ShouldBe(Good);
res[0].Value.ShouldBe("O1234");
res[1].StatusCode.ShouldBe(Good);
res[1].Value.ShouldBe(123.4567d);
res[2].StatusCode.ShouldBe(BadNodeIdUnknown);
res[3].StatusCode.ShouldBe(BadNoCommunication);
res[4].StatusCode.ShouldBe(Good);
res[4].Value.ShouldBe("ACTIVE");
}
/// <summary>
/// A reference repeated in one request gets a snapshot per occurrence. De-duplicating would
/// shorten the list and shift every later reference onto the wrong tag.
/// </summary>
[Fact]
public async Task Read_returns_a_snapshot_per_duplicate_occurrence()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(
["dev1_pos", "dev1_execution", "dev1_pos"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(3);
res[0].StatusCode.ShouldBe(Good);
res[0].Value.ShouldBe(123.4567d);
res[1].Value.ShouldBe("ACTIVE");
res[2].StatusCode.ShouldBe(Good);
res[2].Value.ShouldBe(123.4567d);
}
/// <summary>An empty batch is empty — and, being empty, costs the Agent nothing.</summary>
[Fact]
public async Task Read_of_an_empty_batch_is_empty_and_costs_no_round_trip()
{
var (driver, client) = await InitializedDriverAsync();
var before = client.CurrentCallCount;
var res = await driver.ReadAsync([], TestContext.Current.CancellationToken);
res.Count.ShouldBe(0);
client.CurrentCallCount.ShouldBe(before);
}
/// <summary>
/// A batch of N references costs exactly ONE <c>/current</c>. The whole point of reading the
/// Agent's whole-device snapshot is that per-tag round-trips never happen.
/// </summary>
[Fact]
public async Task Read_of_many_refs_costs_exactly_one_current_call()
{
var (driver, client) = await InitializedDriverAsync();
var before = client.CurrentCallCount;
var res = await driver.ReadAsync(
["dev1_pos", "dev1_execution", "dev1_partcount", "dev1_program", "does-not-exist", "dev1_pos"],
TestContext.Current.CancellationToken);
res.Count.ShouldBe(6);
client.CurrentCallCount.ShouldBe(before + 1);
}
// ---- status-code fidelity: the index's distinctions must survive the trip ----
/// <summary>A genuinely unknown id — neither authored nor ever observed.</summary>
[Fact]
public async Task Read_of_an_unknown_ref_reports_BadNodeIdUnknown()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["nope"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNodeIdUnknown);
res[0].Value.ShouldBeNull();
}
/// <summary>
/// An authored id the Agent has never reported is <c>BadWaitingForInitialData</c>, NOT
/// <c>BadNodeIdUnknown</c> — collapsing the two would tell an operator their correctly
/// authored tag does not exist.
/// </summary>
[Fact]
public async Task Read_of_an_authored_but_unreported_ref_reports_BadWaitingForInitialData()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_never_reported"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadWaitingForInitialData);
}
/// <summary>The Agent said <c>UNAVAILABLE</c>: reachable Agent, absent machine value.</summary>
[Fact]
public async Task Read_of_an_unavailable_ref_reports_BadNoCommunication()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_partcount"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNoCommunication);
res[0].Value.ShouldBeNull();
res[0].SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
[Fact]
public async Task Read_of_a_time_series_ref_reports_BadNotSupported()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNotSupported);
}
/// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
[Fact]
public async Task Read_of_a_blank_ref_is_bad_not_a_throw()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["", " ", "dev1_pos"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(3);
res[0].StatusCode.ShouldBe(BadNodeIdUnknown);
res[1].StatusCode.ShouldBe(BadNodeIdUnknown);
res[2].StatusCode.ShouldBe(Good);
}
/// <summary>The authored type is honoured: the Agent's text becomes a CLR value of that type.</summary>
[Fact]
public async Task Read_coerces_the_agent_text_to_the_authored_type()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(Good);
res[0].Value.ShouldBeOfType<double>().ShouldBe(123.4567d);
res[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
}
// ---- freshness: a read is a read, not a replay of the initialize baseline ----
/// <summary>
/// The whole justification for spending a round-trip: an OPC UA Read must reflect the
/// device's <i>current</i> state, not whatever <see cref="MTConnectDriver.InitializeAsync"/>
/// happened to prime. A driver that served the primed index would pass every other test in
/// this file.
/// </summary>
[Fact]
public async Task Read_reflects_the_agent_state_at_read_time_not_the_initialize_baseline()
{
var (driver, client) = await InitializedDriverAsync();
// The Agent has moved on since the priming /current (sample.xml reports 124.0100).
client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml");
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(Good);
res[0].Value.ShouldBe(124.01d);
}
/// <summary>
/// <b>The anti-clobber pin.</b> <c>ReadAsync</c> indexes its <c>/current</c> into a
/// per-call snapshot and never writes the driver's shared observation index — that index has
/// exactly one writer, the Task 11 <c>/sample</c> pump. If a read wrote it, a <c>/current</c>
/// document older than the pump's newest delta would silently roll a subscribed value
/// backwards and leave it there until the next change.
/// </summary>
[Fact]
public async Task Read_does_not_write_the_shared_observation_index()
{
var (driver, client) = await InitializedDriverAsync();
var primed = driver.ObservationIndex.Get("dev1_pos");
client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml");
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
// The read saw the new value...
res[0].Value.ShouldBe(124.01d);
// ...and the shared index still holds exactly what the pump/priming left there.
var after = driver.ObservationIndex.Get("dev1_pos");
after.Value.ShouldBe(primed.Value);
after.Value.ShouldBe(123.4567d);
}
// ---- failure posture: Bad snapshots, never exceptions ----
/// <summary>
/// An unreachable Agent fails the batch's values, not the batch. Every reference codes
/// <c>BadCommunicationError</c> and the call returns normally.
/// </summary>
[Fact]
public async Task Read_codes_every_ref_bad_when_the_agent_call_fails()
{
var (driver, client) = await InitializedDriverAsync();
client.CurrentFailure = new HttpRequestException("connection refused");
var res = await driver.ReadAsync(
["dev1_pos", "dev1_execution", "does-not-exist"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(3);
res.ShouldAllBe(r => r.StatusCode == BadCommunicationError);
res.ShouldAllBe(r => r.Value == null);
}
/// <summary>An answer the driver cannot make sense of is a Bad batch, not a thrown one.</summary>
[Fact]
public async Task Read_codes_every_ref_bad_when_the_agent_answer_is_unusable()
{
var (driver, client) = await InitializedDriverAsync();
client.CurrentFailure = new System.Xml.XmlException("unexpected end of document");
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadCommunicationError);
}
/// <summary>
/// A failed read degrades the driver rather than leaving it advertising Healthy, and a later
/// successful read restores it. <c>LastSuccessfulRead</c> survives the degradation — it is
/// the "when did this driver last hear from the Agent" diagnostic.
/// </summary>
[Fact]
public async Task Read_failure_degrades_the_driver_and_a_later_success_restores_it()
{
var (driver, client) = await InitializedDriverAsync();
client.CurrentFailure = new HttpRequestException("connection refused");
await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
var degraded = driver.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
degraded.LastSuccessfulRead.ShouldNotBeNull();
degraded.LastError.ShouldNotBeNullOrWhiteSpace();
client.CurrentFailure = null;
await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// R2-01 — the <c>/current</c> runs under a bounded deadline. An Agent that accepts the
/// request and never answers surfaces as a Bad batch on the driver's own clock rather than
/// wedging the caller forever.
/// </summary>
[Fact]
public async Task Read_is_bounded_by_the_per_call_deadline()
{
var (driver, client) = await InitializedDriverAsync(Opts(requestTimeoutMs: 50));
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.CurrentGate = gate;
try
{
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(1);
res[0].StatusCode.ShouldBe(BadCommunicationError);
}
finally
{
gate.TrySetResult();
}
}
/// <summary>
/// The one exception that may cross the capability boundary. Note the contrast with
/// <see cref="Read_codes_every_ref_bad_when_the_agent_call_fails"/>: same code path, one
/// <c>catch</c> filter apart.
/// </summary>
[Fact]
public async Task Read_propagates_genuine_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
() => driver.ReadAsync(["dev1_pos"], cts.Token));
}
// ---- reads outside the initialized window ----
/// <summary>
/// A read before <c>InitializeAsync</c> has no Agent to ask. It reports
/// <c>BadNotConnected</c> for every reference — it does not throw, it does not
/// <c>NullReferenceException</c>, and it does not invent a value.
/// </summary>
[Fact]
public async Task Read_before_initialize_is_bad_not_connected()
{
var (driver, client) = NewDriver();
var res = await driver.ReadAsync(["dev1_pos", "nope"], TestContext.Current.CancellationToken);
res.Count.ShouldBe(2);
res.ShouldAllBe(r => r.StatusCode == BadNotConnected);
client.CurrentCallCount.ShouldBe(0);
// An un-started driver is Unknown, not Degraded: nothing has failed yet.
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// Same posture after <c>ShutdownAsync</c>, and shutting down must stay reported as
/// <see cref="DriverState.Unknown"/> — a read attempted against a deliberately stopped
/// driver is not a degradation.
/// </summary>
[Fact]
public async Task Read_after_shutdown_is_bad_not_connected_and_leaves_health_alone()
{
var (driver, _) = await InitializedDriverAsync();
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNotConnected);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// A <c>null</c> reference list is a caller bug, not device data — the one place this method
/// is allowed to be loud, matching <see cref="MTConnectObservationIndex"/>'s documented
/// posture ("the only exceptions raised are <c>ArgumentNullException</c> for a null argument").
/// </summary>
[Fact]
public async Task Read_of_a_null_ref_list_throws_ArgumentNullException()
{
var (driver, _) = await InitializedDriverAsync();
await Should.ThrowAsync<ArgumentNullException>(
() => driver.ReadAsync(null!, TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Captures what the driver logs, so that "and it says so" can be asserted rather than assumed.
/// </summary>
/// <remarks>
/// Used where the log line <b>is</b> the behaviour: a browse scope that matches nothing, and a
/// subscription the driver silently refuses to serve. Both are cases whose only other symptom is
/// an operator staring at an empty panel, so a fix that emits nothing is not a fix.
/// </remarks>
internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
{
/// <summary>Formatted <see cref="LogLevel.Warning"/> lines, in order.</summary>
public List<string> Warnings { get; } = [];
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
public List<string> Errors { get; } = [];
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
/// <inheritdoc/>
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc/>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
var sink = logLevel switch
{
LogLevel.Warning => Warnings,
LogLevel.Error => Errors,
_ => null,
};
// Locked: the /sample pump logs from its own task while the test thread reads.
if (sink is null)
{
return;
}
lock (sink)
{
sink.Add(formatter(state, exception));
}
}
/// <summary>A snapshot copy of <see cref="Warnings"/>, safe to enumerate while the pump runs.</summary>
public IReadOnlyList<string> WarningsSnapshot()
{
lock (Warnings)
{
return [.. Warnings];
}
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise wire-level behavior — the analyzer's documented intentional case ("move the suppression
into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
</ItemGroup>
<!-- Future response-capture fixtures (agent /probe, /current, /sample XML payloads) copied
beside the test binaries so file-based tests can load them by relative path. -->
<ItemGroup>
<Content Include="Fixtures\**\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,322 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Unit tests for the plain-C# core of <see cref="MTConnectDriverForm"/> — the pure form model and
/// its <c>DriverConfig</c> JSON projection. There is no bUnit in this project, so every decision the
/// form makes lives here (in code a test can reach) rather than in razor markup.
/// </summary>
/// <remarks>
/// The JSON shape asserted below is the one <c>MTConnectDriver.ParseOptions</c> binds
/// (<c>agentUri</c> / <c>deviceName</c> / <c>requestTimeoutMs</c> / <c>sampleIntervalMs</c> /
/// <c>sampleCount</c> / <c>heartbeatMs</c> / <c>probe</c> / <c>reconnect</c>). It is deliberately
/// NOT <c>MTConnectDriverOptions</c>: that type carries <c>TimeSpan</c> probe knobs, which serialise
/// as <c>"00:00:05"</c> and would not bind to the driver's <c>intervalMs</c> integers.
/// </remarks>
public sealed class MTConnectDriverFormModelTests
{
// Mirrors MTConnectDriverForm._jsonOpts for the read side of the assertions.
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private static JsonObject Emit(MTConnectDriverForm.MTConnectFormModel form, string? existingJson = null)
=> JsonNode.Parse(MTConnectDriverForm.BuildConfigJson(form, existingJson))!.AsObject();
// ---- default shape -------------------------------------------------------------------------
[Fact]
public void Default_form_emits_a_config_the_driver_can_parse()
{
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" });
o["agentUri"]!.GetValue<string>().ShouldBe("http://agent:5000");
o["requestTimeoutMs"]!.GetValue<int>().ShouldBe(5000);
o["sampleIntervalMs"]!.GetValue<int>().ShouldBe(1000);
o["sampleCount"]!.GetValue<int>().ShouldBe(1000);
o["heartbeatMs"]!.GetValue<int>().ShouldBe(10000);
o["probe"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
o["probe"]!["intervalMs"]!.GetValue<int>().ShouldBe(5000);
o["probe"]!["timeoutMs"]!.GetValue<int>().ShouldBe(2000);
o["reconnect"]!["maxBackoffMs"]!.GetValue<int>().ShouldBe(30000);
o["reconnect"]!["backoffMultiplier"]!.GetValue<double>().ShouldBe(2.0);
}
[Fact]
public void Blank_agent_uri_is_omitted_rather_than_written_as_an_empty_string()
{
// An empty "agentUri" and an absent one both fail ParseOptions, but omitting it keeps the
// driver's own "missing required AgentUri" message accurate.
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = " " });
o.ContainsKey("agentUri").ShouldBeFalse();
}
[Fact]
public void Agent_uri_and_device_name_are_trimmed_and_blank_device_name_is_omitted()
{
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = " http://agent:5000 ",
DeviceName = " ",
});
o["agentUri"]!.GetValue<string>().ShouldBe("http://agent:5000");
o.ContainsKey("deviceName").ShouldBeFalse();
}
// ---- arch-review 01/S-6: a zero must never reach the driver -------------------------------
[Theory]
[InlineData("requestTimeoutMs")]
[InlineData("sampleIntervalMs")]
[InlineData("sampleCount")]
[InlineData("heartbeatMs")]
public void Non_positive_timing_knobs_are_omitted_so_the_driver_applies_its_own_default(string key)
{
var form = new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" };
switch (key)
{
case "requestTimeoutMs": form.RequestTimeoutMs = 0; break;
case "sampleIntervalMs": form.SampleIntervalMs = -1; break;
case "sampleCount": form.SampleCount = 0; break;
case "heartbeatMs": form.HeartbeatMs = 0; break;
}
// MTConnectDriver.StartCore calls RequirePositive on all four and faults the driver at
// Initialize; omitting the key means ParseOptions substitutes the positive default instead.
Emit(form).ContainsKey(key).ShouldBeFalse();
}
[Fact]
public void Non_positive_probe_knobs_are_omitted_so_the_driver_applies_its_own_default()
{
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://agent:5000",
ProbeIntervalMs = 0,
ProbeTimeoutMs = -5,
});
var probe = o["probe"]!.AsObject();
probe["enabled"]!.GetValue<bool>().ShouldBeTrue();
probe.ContainsKey("intervalMs").ShouldBeFalse();
probe.ContainsKey("timeoutMs").ShouldBeFalse();
}
[Fact]
public void Zero_reconnect_min_backoff_is_preserved_because_the_driver_allows_it()
{
// MinBackoffMs = 0 means "retry immediately" and is the driver's own default — it must NOT be
// swept up by the positive-only rule that guards the four RequirePositive knobs.
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://agent:5000",
ReconnectMinBackoffMs = 0,
});
o["reconnect"]!["minBackoffMs"]!.GetValue<int>().ShouldBe(0);
}
// ---- Validate ------------------------------------------------------------------------------
[Fact]
public void Validate_rejects_a_blank_agent_uri()
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "" }
.Validate().ShouldNotBeNullOrEmpty();
[Fact]
public void Validate_rejects_a_non_positive_timing_knob()
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", SampleCount = 0 }
.Validate().ShouldNotBeNullOrEmpty();
[Fact]
public void Validate_ignores_probe_knobs_when_probing_is_off()
=> new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://agent:5000",
ProbeEnabled = false,
ProbeIntervalMs = 0,
ProbeTimeoutMs = 0,
}.Validate().ShouldBeNull();
[Fact]
public void Validate_accepts_a_fully_populated_form()
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }
.Validate().ShouldBeNull();
// ---- round trip ----------------------------------------------------------------------------
[Fact]
public void Round_trip_preserves_every_authored_value()
{
var original = new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://cell-a:5000",
DeviceName = "VMC-1",
RequestTimeoutMs = 7000,
SampleIntervalMs = 250,
SampleCount = 500,
HeartbeatMs = 15000,
ProbeEnabled = false,
ProbeIntervalMs = 9000,
ProbeTimeoutMs = 3000,
ReconnectMinBackoffMs = 100,
ReconnectMaxBackoffMs = 60000,
ReconnectBackoffMultiplier = 1.5,
};
var json = MTConnectDriverForm.BuildConfigJson(original, null);
var back = MTConnectDriverForm.MTConnectFormModel.FromJson(json);
back.AgentUri.ShouldBe("http://cell-a:5000");
back.DeviceName.ShouldBe("VMC-1");
back.RequestTimeoutMs.ShouldBe(7000);
back.SampleIntervalMs.ShouldBe(250);
back.SampleCount.ShouldBe(500);
back.HeartbeatMs.ShouldBe(15000);
back.ProbeEnabled.ShouldBeFalse();
back.ProbeIntervalMs.ShouldBe(9000);
back.ProbeTimeoutMs.ShouldBe(3000);
back.ReconnectMinBackoffMs.ShouldBe(100);
back.ReconnectMaxBackoffMs.ShouldBe(60000);
back.ReconnectBackoffMultiplier.ShouldBe(1.5);
}
[Fact]
public void FromJson_reads_a_pascal_case_seeded_config()
{
// Seed SQL / hand-authored configs are PascalCase; the driver reads them case-insensitively,
// so the form must too or it silently shows defaults over a real config.
const string seeded = """
{
"AgentUri": "http://10.100.0.35:5000",
"DeviceName": "Mill-01",
"RequestTimeoutMs": 8000,
"Probe": { "Enabled": false, "IntervalMs": 7000, "TimeoutMs": 4000 },
"Reconnect": { "MinBackoffMs": 250, "MaxBackoffMs": 45000, "BackoffMultiplier": 3.0 }
}
""";
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(seeded);
form.AgentUri.ShouldBe("http://10.100.0.35:5000");
form.DeviceName.ShouldBe("Mill-01");
form.RequestTimeoutMs.ShouldBe(8000);
form.ProbeEnabled.ShouldBeFalse();
form.ProbeIntervalMs.ShouldBe(7000);
form.ProbeTimeoutMs.ShouldBe(4000);
form.ReconnectMinBackoffMs.ShouldBe(250);
form.ReconnectMaxBackoffMs.ShouldBe(45000);
form.ReconnectBackoffMultiplier.ShouldBe(3.0);
}
[Fact]
public void FromJson_falls_back_to_defaults_for_an_unusable_document()
{
foreach (var bad in new[] { null, "", " ", "{", "\"scalar\"" })
{
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(bad);
form.RequestTimeoutMs.ShouldBe(5000);
form.SampleCount.ShouldBe(1000);
}
}
[Fact]
public void Unknown_top_level_keys_survive_a_load_save()
{
// The driver also accepts a hand-authored tags[] surface; opening the form must not delete it.
const string existing = """{"agentUri":"http://old:5000","tags":[{"fullName":"x1"}]}""";
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
form.AgentUri = "http://new:5000";
var o = Emit(form, existing);
o["agentUri"]!.GetValue<string>().ShouldBe("http://new:5000");
o["tags"]!.AsArray().Count.ShouldBe(1);
}
[Fact]
public void A_previously_written_key_is_removed_when_its_value_becomes_non_positive()
{
const string existing = """{"agentUri":"http://a:5000","requestTimeoutMs":9000}""";
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
form.RequestTimeoutMs = 0;
// Not merely "not written" — the stale 9000 must not survive either, or the UI would show 0
// while the stored config still said 9000.
Emit(form, existing).ContainsKey("requestTimeoutMs").ShouldBeFalse();
}
// ---- the shape the driver actually binds ---------------------------------------------------
[Fact]
public void Emitted_config_binds_to_the_drivers_wire_dto_shape()
{
var json = MTConnectDriverForm.BuildConfigJson(
new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://agent:5000",
DeviceName = "VMC-1",
RequestTimeoutMs = 7000,
},
null);
// Mirror of MTConnectDriver.MTConnectDriverConfigDto (private there), asserting the emitted
// document binds field-for-field — a rename on either side breaks this.
var dto = JsonSerializer.Deserialize<WireDto>(json, _opts);
dto.ShouldNotBeNull();
dto!.AgentUri.ShouldBe("http://agent:5000");
dto.DeviceName.ShouldBe("VMC-1");
dto.RequestTimeoutMs.ShouldBe(7000);
dto.SampleIntervalMs.ShouldBe(1000);
dto.SampleCount.ShouldBe(1000);
dto.HeartbeatMs.ShouldBe(10000);
dto.Probe.ShouldNotBeNull();
dto.Probe!.Enabled.ShouldBe(true);
dto.Probe.IntervalMs.ShouldBe(5000);
dto.Probe.TimeoutMs.ShouldBe(2000);
dto.Reconnect.ShouldNotBeNull();
dto.Reconnect!.MinBackoffMs.ShouldBe(0);
dto.Reconnect.MaxBackoffMs.ShouldBe(30000);
dto.Reconnect.BackoffMultiplier.ShouldBe(2.0);
}
private sealed class WireDto
{
public string? AgentUri { get; init; }
public string? DeviceName { get; init; }
public int? RequestTimeoutMs { get; init; }
public int? SampleIntervalMs { get; init; }
public int? SampleCount { get; init; }
public int? HeartbeatMs { get; init; }
public WireProbe? Probe { get; init; }
public WireReconnect? Reconnect { get; init; }
}
private sealed class WireProbe
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
}
private sealed class WireReconnect
{
public int? MinBackoffMs { get; init; }
public int? MaxBackoffMs { get; init; }
public double? BackoffMultiplier { get; init; }
}
}
@@ -0,0 +1,102 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Pins the browse-picker → typed-editor round trip for MTConnect, the driver's <b>primary</b>
/// authoring path (a tag is committed from the universal browser, then confirmed/overridden in the
/// typed editor).
/// </summary>
/// <remarks>
/// <para>
/// The defect this guards: <see cref="RawBrowseCommitMapper.BuildTagConfig"/> had no MTConnect
/// branch, so a committed tag landed as the generic <c>{"address": "&lt;dataItemId&gt;"}</c>
/// while <see cref="MTConnectTagConfigModel"/> read only <c>fullName</c>. The data plane kept
/// working (the driver accepts all three id spellings), so the ONLY symptom was an empty
/// DataItem-id field in the editor — and saving from that state stamped <c>fullName: ""</c>
/// over the tag. Razor is exactly what this repo cannot unit-test, so both halves of the fix
/// are pinned here in plain C#.
/// </para>
/// <para>
/// Both halves are asserted: the mapper now writes the canonical <c>fullName</c> (fixes new
/// commits), and the model accepts <c>dataItemId</c>/<c>address</c> as fallbacks (fixes tags
/// already committed before the fix).
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MTConnectBrowseCommitRoundTripTests
{
[Fact]
public void BuildTagConfig_writes_the_canonical_fullName_key_for_MTConnect()
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "avail");
var o = JsonNode.Parse(json)!.AsObject();
o["fullName"]!.GetValue<string>().ShouldBe("avail");
o.ContainsKey("address").ShouldBeFalse(
"the generic fallback key must not be used now that MTConnect has a typed editor");
}
[Fact]
public void A_browse_committed_tag_round_trips_through_the_typed_editor_model()
{
var committed = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "Xact");
var model = MTConnectTagConfigModel.FromJson(committed);
model.FullName.ShouldBe("Xact");
model.Validate().ShouldBeNull("a browse-committed tag must open in the editor already valid");
MTConnectTagConfigModel.FromJson(model.ToJson()).FullName.ShouldBe("Xact");
}
// ---- legacy blobs committed before the mapper fix -------------------------------------------
[Theory]
[InlineData("address")]
[InlineData("dataItemId")]
public void FromJson_accepts_the_drivers_alternate_id_spellings(string key)
{
var model = MTConnectTagConfigModel.FromJson($$"""{"{{key}}":"Ypos"}""");
model.FullName.ShouldBe("Ypos");
model.Validate().ShouldBeNull();
}
[Fact]
public void FullName_wins_over_dataItemId_which_wins_over_address()
{
MTConnectTagConfigModel
.FromJson("""{"fullName":"a","dataItemId":"b","address":"c"}""")
.FullName.ShouldBe("a");
MTConnectTagConfigModel
.FromJson("""{"dataItemId":"b","address":"c"}""")
.FullName.ShouldBe("b");
}
[Fact]
public void A_blank_alias_does_not_shadow_a_populated_one()
=> MTConnectTagConfigModel
.FromJson("""{"fullName":" ","address":"c"}""")
.FullName.ShouldBe("c");
[Fact]
public void Saving_a_legacy_blob_normalises_it_onto_fullName_and_drops_the_aliases()
{
var model = MTConnectTagConfigModel.FromJson("""{"address":"Zpos","units":"MILLIMETER"}""");
var o = JsonNode.Parse(model.ToJson())!.AsObject();
o["fullName"]!.GetValue<string>().ShouldBe("Zpos");
o.ContainsKey("address").ShouldBeFalse(
"leaving a second id spelling behind lets the two drift apart on the next edit");
o.ContainsKey("dataItemId").ShouldBeFalse();
// Unrelated keys are still preserved — normalisation is not a purge.
o["units"]!.GetValue<string>().ShouldBe("MILLIMETER");
}
}
@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class MTConnectTagConfigModelTests
{
[Fact]
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
{
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
var m = MTConnectTagConfigModel.FromJson(json);
var outJson = m.ToJson();
outJson.ShouldContain("\"somethingUnknown\":42");
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
}
[Fact]
public void Validate_blocks_blank_fullname()
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = MTConnectTagConfigModel.FromJson(json);
m.FullName.ShouldBe("");
m.DataType.ShouldBe(DriverDataType.String);
m.MtCategory.ShouldBeNull();
m.MtType.ShouldBeNull();
m.MtSubType.ShouldBeNull();
m.Units.ShouldBeNull();
m.MtDevice.ShouldBeNull();
m.MtComponent.ShouldBeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_blocks_missing_and_whitespace_fullname(string? fullName)
{
var m = new MTConnectTagConfigModel { FullName = fullName! };
m.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_for_a_valid_model()
{
var m = new MTConnectTagConfigModel { FullName = "dev1_pos", DataType = DriverDataType.Float64 };
m.Validate().ShouldBeNull();
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_name_and_all_fields()
{
var m = new MTConnectTagConfigModel
{
FullName = "dev1_partcount",
DataType = DriverDataType.Int64,
MtCategory = "EVENT",
MtType = "PART_COUNT",
MtSubType = "ACTUAL",
Units = "COUNT",
MtDevice = "Mill-01",
MtComponent = "Controller",
};
var json = m.ToJson();
json.ShouldContain("\"fullName\":\"dev1_partcount\"");
json.ShouldContain("\"dataType\":\"Int64\"");
json.ShouldContain("\"mtCategory\":\"EVENT\"");
json.ShouldContain("\"mtType\":\"PART_COUNT\"");
json.ShouldContain("\"mtSubType\":\"ACTUAL\"");
json.ShouldContain("\"units\":\"COUNT\"");
json.ShouldContain("\"mtDevice\":\"Mill-01\"");
json.ShouldContain("\"mtComponent\":\"Controller\"");
}
[Fact]
public void ToJson_never_emits_a_bare_numeric_dataType()
{
foreach (DriverDataType value in Enum.GetValues<DriverDataType>())
{
var json = new MTConnectTagConfigModel { FullName = "x", DataType = value }.ToJson();
// The enum-serialization trap: "dataType":8 would fault the driver at deploy (string-typed
// factory DTOs). Assert the value after the key is a quoted name, not a bare digit.
json.ShouldNotContain($"\"dataType\":{(int)value}");
json.ShouldContain($"\"dataType\":\"{value}\"");
}
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys_including_nested_object_and_isHistorized()
{
var json = """
{
"fullName": "dev1_pos",
"dataType": "Float64",
"isHistorized": true,
"historianTagname": "Mill01.Position",
"nested": { "a": 1, "b": [1, 2, 3] }
}
""";
var roundTripped = MTConnectTagConfigModel.FromJson(json).ToJson();
roundTripped.ShouldContain("\"isHistorized\":true");
roundTripped.ShouldContain("\"historianTagname\":\"Mill01.Position\"");
roundTripped.ShouldContain("\"nested\":{\"a\":1,\"b\":[1,2,3]}");
// and the modelled fields still round-trip alongside the preserved keys
roundTripped.ShouldContain("\"fullName\":\"dev1_pos\"");
roundTripped.ShouldContain("\"dataType\":\"Float64\"");
}
[Fact]
public void FromJson_of_malformed_json_falls_back_to_defaults_rather_than_throwing()
{
var m = MTConnectTagConfigModel.FromJson("{not valid json");
m.FullName.ShouldBe("");
m.DataType.ShouldBe(DriverDataType.String);
}
[Fact]
public void Round_trip_is_stable_for_a_fully_populated_model()
{
var m = new MTConnectTagConfigModel
{
FullName = "dev1_feedrate",
DataType = DriverDataType.Float64,
MtCategory = "SAMPLE",
MtType = "PATH_FEEDRATE",
MtSubType = "PROGRAMMED",
Units = "MILLIMETER/SECOND",
MtDevice = "Mill-01",
MtComponent = "Path",
};
var m2 = MTConnectTagConfigModel.FromJson(m.ToJson());
m2.FullName.ShouldBe(m.FullName);
m2.DataType.ShouldBe(m.DataType);
m2.MtCategory.ShouldBe(m.MtCategory);
m2.MtType.ShouldBe(m.MtType);
m2.MtSubType.ShouldBe(m.MtSubType);
m2.Units.ShouldBe(m.Units);
m2.MtDevice.ShouldBe(m.MtDevice);
m2.MtComponent.ShouldBe(m.MtComponent);
}
[Fact]
public void Optional_metadata_keys_are_omitted_when_blank_rather_than_persisted_empty()
{
var json = new MTConnectTagConfigModel { FullName = "dev1_pos", MtCategory = " " }.ToJson();
json.ShouldNotContain("mtCategory");
}
[Fact]
public void Validate_rejects_a_dataType_that_was_stored_as_a_bare_number()
{
// Enum.TryParse (which TagConfigJson.GetEnum uses under the hood) silently accepts numeric text,
// so a config that was ever hand-authored/migrated with a quoted-number dataType would otherwise
// load into a plausible-looking enum value with no signal. Guard it explicitly.
var m = MTConnectTagConfigModel.FromJson("{\"fullName\":\"dev1_pos\",\"dataType\":\"8\"}");
m.DataType.ShouldBe(DriverDataType.Float64); // 8 == DriverDataType.Float64 — parsed "successfully"
m.Validate().ShouldNotBeNull();
}
}
@@ -0,0 +1,59 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Coverage guard for the <c>/raw</c> "New driver" dialog. <see cref="RawDriverTypeDialog"/> is the
/// ONLY surface that creates a <c>DriverInstance</c>, and its offered types are a hand-authored array
/// — so a driver whose factory is registered but whose entry was never added here is
/// <b>un-creatable in the AdminUI</b> with nothing failing anywhere (the MTConnect gap Task 16 found).
/// This test resolves through <see cref="DriverTypeNames.All"/>, so a newly registered driver type
/// fails here until the dialog offers it.
/// </summary>
/// <remarks>
/// There is no bUnit in this project, so the dialog's markup cannot be rendered. The offered set is
/// read by reflection off the private static <c>Types</c> table the markup enumerates — the same
/// substitute-for-a-component-test approach <c>DriverPageJsonConverterTests</c> uses.
/// </remarks>
public sealed class RawDriverTypeDialogCoverageTests
{
private static IReadOnlyList<string> OfferedDriverTypes()
{
var field = typeof(RawDriverTypeDialog)
.GetField("Types", BindingFlags.NonPublic | BindingFlags.Static);
field.ShouldNotBeNull("RawDriverTypeDialog must expose its offered driver types as a static 'Types' table.");
var table = (ValueTuple<string, string>[])field!.GetValue(null)!;
return [.. table.Select(t => t.Item2)];
}
[Theory]
[MemberData(nameof(AllDriverTypes))]
public void New_driver_dialog_offers_every_registered_driver_type(string driverType)
=> OfferedDriverTypes().ShouldContain(
driverType,
$"RawDriverTypeDialog must offer DriverTypeNames.{driverType} — otherwise a driver of that " +
"type can never be created in /raw, which is the only creation surface.");
/// <summary>xUnit theory source over every registered driver-type constant.</summary>
public static TheoryData<string> AllDriverTypes()
{
var data = new TheoryData<string>();
foreach (var t in DriverTypeNames.All)
data.Add(t);
return data;
}
[Fact]
public void Offered_driver_types_are_canonical_constants_and_unique()
{
var offered = OfferedDriverTypes();
offered.Distinct(StringComparer.Ordinal).Count().ShouldBe(offered.Count, "duplicate driver-type entries");
foreach (var t in offered)
DriverTypeNames.All.ShouldContain(t, $"'{t}' is not a canonical DriverTypeNames constant.");
}
}
@@ -27,6 +27,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient,
DriverTypeNames.Calculation,
DriverTypeNames.MTConnect,
];
[Theory]
@@ -46,6 +47,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
DriverTypeNames.TwinCAT,
DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient,
DriverTypeNames.MTConnect,
];
[Theory]
@@ -32,6 +32,7 @@ public sealed class DriverProbeRegistrationTests
"OpcUaClient",
"GalaxyMxGateway",
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
"MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe
];
[Fact]