feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate (#506)
This commit was merged in pull request #506.
This commit is contained in:
@@ -84,6 +84,11 @@
|
|||||||
ships net6.0/net8.0 only.
|
ships net6.0/net8.0 only.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
<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="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||||
|
|||||||
@@ -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/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.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.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/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.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" />
|
<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.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.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.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.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.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" />
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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 |
|
| [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 |
|
| [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) |
|
| [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).) |
|
| [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
|
## 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
|
- [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
|
- [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.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).)
|
- [../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`
|
- [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
|
- [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)
|
- [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
|
## 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** | S–M | none (retrofits shipped drivers) |
|
| **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** | S–M | 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** | 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) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
| **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) | S–M | **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) | S–M | 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) | S–M | 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 0–26) | M–L | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, live at `10.100.0.35:8883` |
|
| **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 0–26) | M–L | 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,
|
> 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.
|
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)
|
- **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.
|
- **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.**
|
||||||
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
|
- **Driver guide:** [`docs/drivers/MTConnect.md`](../drivers/MTConnect.md) — config keys, capability
|
||||||
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
|
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
|
||||||
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
|
limitations** section.
|
||||||
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
|
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
|
||||||
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
|
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
|
||||||
- **Effort:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 wk hand-rolled).
|
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.3–2.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:** S–M, 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)
|
### 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)
|
- **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
|
## 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
|
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)
|
📝 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).
|
table (subagent-driven-development in a git worktree).
|
||||||
3. **Build Wave 2** — MTConnect's browse leg wants #468 closed first. **MQTT / Sparkplug B is
|
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
|
||||||
COMPLETE** (P1 + P2, both live-gated); its only remaining work is optional — P3 write-through
|
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
|
||||||
(`IWritable`: NCMD/DCMD + plain publish) and the two browse-UI gaps recorded above.
|
|
||||||
|
|
||||||
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
||||||
|
|||||||
@@ -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)"
|
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 6–13 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
|
## 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)"
|
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`
|
## 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
|
## Task 22: Docs + deferred-writeback note; update the tracking doc
|
||||||
|
|
||||||
**Classification:** trivial
|
**Classification:** trivial
|
||||||
|
|||||||
+10
-2
@@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{
|
|||||||
| Stack dir | Purpose |
|
| Stack dir | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/opt/otopcua-mssql` | central SQL (always-on) |
|
| `/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-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) |
|
||||||
| `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 |
|
| `~/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`) |
|
| 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 | `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 |
|
| 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:
|
**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` ·
|
`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
|
> **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),
|
> 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`.
|
> 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).
|
> 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`
|
> **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:
|
> 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`.
|
> `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>
|
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
|
||||||
public const string Mqtt = "Mqtt";
|
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>
|
/// <summary>
|
||||||
/// Every driver-type string declared above, for callers that need to enumerate
|
/// Every driver-type string declared above, for callers that need to enumerate
|
||||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||||
@@ -75,5 +78,6 @@ public static class DriverTypeNames
|
|||||||
Calculation,
|
Calculation,
|
||||||
Sql,
|
Sql,
|
||||||
Mqtt,
|
Mqtt,
|
||||||
|
MTConnect,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+266
@@ -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);
|
||||||
+17
@@ -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 6–13) 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<MTConnectDriverOptions, IMTConnectAgentClient></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><Entry key="…"></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><Unavailable/></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><Entry key=…></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><Unavailable/></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><Condition></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 "xml"" (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><Device><DataItems></c>,
|
||||||
|
/// outside any component — a walker that only recurses <c><Components></c> silently
|
||||||
|
/// drops them.
|
||||||
|
/// (2) The children of <c><Components></c> are named for the component <i>type</i>
|
||||||
|
/// (<c><Controller></c>, <c><Path></c>, <c><Axes></c>, …) — there is no
|
||||||
|
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||||
|
/// <c><Components></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><Components></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><Samples></c>,
|
||||||
|
/// <c><Events></c> and <c><Condition></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><Normal/></c> / <c><Warning/></c> / <c><Fault/></c> /
|
||||||
|
/// <c><Unavailable/></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><Unavailable/></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><Unavailable/></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><Header></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><Streams></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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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:
|
case DriverTypeNames.Mqtt:
|
||||||
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
break;
|
break;
|
||||||
|
case DriverTypeNames.MTConnect:
|
||||||
|
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
break;
|
break;
|
||||||
|
|||||||
+406
@@ -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 > 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 > 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 > 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 > 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 > 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 > 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 > 0.</summary>
|
||||||
|
public int RequestTimeoutMs { get; set; } = 5_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms. Must be > 0.</summary>
|
||||||
|
public int SampleIntervalMs { get; set; } = 1_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>count</c> query parameter. Must be > 0.</summary>
|
||||||
|
public int SampleCount { get; set; } = 1_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms. Must be > 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 > 0 while <see cref="ProbeEnabled"/>.</summary>
|
||||||
|
public int ProbeIntervalMs { get; set; } = 5_000;
|
||||||
|
/// <summary>Probe request deadline in ms. Must be > 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -70,7 +70,8 @@
|
|||||||
("MQTT", DriverTypeNames.Mqtt),
|
("MQTT", DriverTypeNames.Mqtt),
|
||||||
("Galaxy", DriverTypeNames.Galaxy),
|
("Galaxy", DriverTypeNames.Galaxy),
|
||||||
("Sql", DriverTypeNames.Sql),
|
("Sql", DriverTypeNames.Sql),
|
||||||
("Calculation", "Calculation"),
|
("Calculation", DriverTypeNames.Calculation),
|
||||||
|
("MTConnect", DriverTypeNames.MTConnect),
|
||||||
];
|
];
|
||||||
|
|
||||||
private string _type = DriverTypeNames.Modbus;
|
private string _type = DriverTypeNames.Modbus;
|
||||||
|
|||||||
+118
@@ -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();
|
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||||
if (Is(driverType, DriverTypeNames.S7))
|
if (Is(driverType, DriverTypeNames.S7))
|
||||||
return new S7TagConfigModel { Address = address }.ToJson();
|
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))
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||||
return WriteSingleKey("attributeRef", address);
|
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.
|
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||||
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
[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>
|
/// <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.
|
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
||||||
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||||
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
||||||
|
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <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.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.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.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.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"/>
|
<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
|
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
|||||||
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||||
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
||||||
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
|
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
|
||||||
|
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
/// 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, CalculationProbe>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -149,6 +151,13 @@ public static class DriverFactoryBootstrap
|
|||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.Mqtt.MqttDriverFactoryExtensions.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.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||||
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
|
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.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.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.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.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.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||||
|
|||||||
+1
@@ -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.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.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.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
|
<!-- 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
|
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||||
puts it in StatusCodeParityTests' scope. -->
|
puts it in StatusCodeParityTests' scope. -->
|
||||||
|
|||||||
+110
@@ -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
|
||||||
|
}
|
||||||
+79
@@ -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
|
||||||
+190
@@ -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><Compositions></c> element rather
|
||||||
|
/// than under a component's own <c><DataItems></c>. The driver's discovery walk recurses
|
||||||
|
/// <c><Components></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><Agent></c> self-model
|
||||||
|
/// (connection status, observation update rate, adapter URI, …) rather than to a
|
||||||
|
/// <c><Device></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";
|
||||||
|
}
|
||||||
+697
@@ -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><Agent></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><Device></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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);
|
||||||
+40
@@ -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>
|
||||||
+311
@@ -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><Device><DataItems></c>, outside every
|
||||||
|
/// Component. A walker that only recursed <c><Components></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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+734
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+676
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+823
@@ -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><Unavailable/></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><Entry></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><Condition></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")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+458
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1345
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -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": "<dataItemId>"}</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.FOCAS,
|
||||||
DriverTypeNames.OpcUaClient,
|
DriverTypeNames.OpcUaClient,
|
||||||
DriverTypeNames.Calculation,
|
DriverTypeNames.Calculation,
|
||||||
|
DriverTypeNames.MTConnect,
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -46,6 +47,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
|
|||||||
DriverTypeNames.TwinCAT,
|
DriverTypeNames.TwinCAT,
|
||||||
DriverTypeNames.FOCAS,
|
DriverTypeNames.FOCAS,
|
||||||
DriverTypeNames.OpcUaClient,
|
DriverTypeNames.OpcUaClient,
|
||||||
|
DriverTypeNames.MTConnect,
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class DriverProbeRegistrationTests
|
|||||||
"OpcUaClient",
|
"OpcUaClient",
|
||||||
"GalaxyMxGateway",
|
"GalaxyMxGateway",
|
||||||
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
|
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
|
||||||
|
"MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user