diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 94638260..4e577d11 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -102,6 +102,7 @@
+
diff --git a/infra/README.md b/infra/README.md
index 1edd576d..a22f6233 100644
--- a/infra/README.md
+++ b/infra/README.md
@@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{
| Stack dir | Purpose |
|---|---|
| `/opt/otopcua-mssql` | central SQL (always-on) |
- | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` | driver fixtures |
+ | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` | driver fixtures |
| `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) |
| `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 |
@@ -71,10 +71,11 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
| FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) |
| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD= docker compose up -d` (stack `/opt/otopcua-mqtt`) |
| MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth |
+| MTConnect | `mtconnect/agent:2.7.0.12` + `python:3.13-alpine` adapter | `http://10.100.0.35:5000` | `docker compose up -d --wait` |
**Endpoint overrides** point a suite at a real PLC instead of the sim:
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` ·
-`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT`.
+`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT`.
> **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous
> fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass),
@@ -84,6 +85,13 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
> copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`.
> It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md).
+> **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone
+> Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service
+> (a stdlib SHDR feeder, `Docker/adapter.py`) is the data source; the Agent dials out to it. Its
+> suite also probes over **HTTP**, not TCP — an Agent's port accepts connections before its device
+> model is parsed. Full detail + the seeded device model:
+> [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
+
> **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down`
> may leave a stale container bound. Force-remove before switching profiles:
> `docker rm -f $(docker ps -aq --filter name=otopcua-)` then `up --force-recreate`.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs
new file mode 100644
index 00000000..d40b4662
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs
@@ -0,0 +1,110 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
+
+///
+/// An that records the tree DiscoverAsync 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.
+///
+///
+/// A near-twin of the MTConnect unit suite's CapturingBuilder, restated here rather than
+/// shared: that type is internal to a different test assembly, and the two production
+/// capturing builders (Commons.Browsing / Runtime.Drivers) would drag the server
+/// stack into a driver integration project that deliberately references only the driver.
+///
+internal sealed class DiscoveryCapture : IAddressSpaceBuilder
+{
+ private readonly State _state;
+ private readonly string _path;
+
+ /// Creates the root scope a driver's DiscoverAsync is handed.
+ public DiscoveryCapture()
+ {
+ _state = new State();
+ _path = string.Empty;
+ }
+
+ private DiscoveryCapture(State state, string path)
+ {
+ _state = state;
+ _path = path;
+ }
+
+ /// Every folder streamed, with the slash-joined path it landed at.
+ public IReadOnlyList Folders => _state.Folders;
+
+ /// Every variable streamed, with the folder path it landed under.
+ public IReadOnlyList Variables => _state.Variables;
+
+ ///
+ 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);
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ 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 Folders { get; } = [];
+
+ public List 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.
+ }
+ }
+ }
+}
+
+/// One folder captured from a discovery stream.
+/// Slash-joined path of the folder itself.
+/// Slash-joined path of the scope it was added to (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
+
+/// One variable captured from a discovery stream.
+/// Slash-joined path of the folder it landed under.
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+/// The driver-side attribute metadata the driver stamped on it.
+internal sealed record CapturedVariable(
+ string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
+{
+ /// Alarm conditions the driver marked this variable with, if any.
+ public List AlarmConditions { get; } = [];
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml
new file mode 100644
index 00000000..19979d1a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+ MTConnect integration-test fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md
new file mode 100644
index 00000000..d9fa1e8c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md
@@ -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 `` 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.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py
new file mode 100644
index 00000000..06019cc3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py
@@ -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 `||[||...]`, where 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 ` 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 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: |||||."""
+ 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: ||| ...
+ 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())
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg
new file mode 100644
index 00000000..bbc22692
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg
@@ -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
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml
new file mode 100644
index 00000000..547861dc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml
@@ -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
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs
new file mode 100644
index 00000000..dad972f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs
@@ -0,0 +1,190 @@
+using System.Net.Http;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
+
+///
+/// Reachability probe for a live MTConnect Agent (the mtconnect/agent container in
+/// Docker/docker-compose.yml, or a real Agent on a machine tool). Reads
+/// MTCONNECT_AGENT_ENDPOINT (default http://10.100.0.35:5000 — the shared Docker
+/// host) and issues ONE GET {endpoint}/probe at fixture construction. Each test checks
+/// and calls Assert.Skip when the Agent was unreachable, so a
+/// dev box with no fixture running still passes dotnet test cleanly — the same pattern
+/// as ModbusSimulatorFixture / S7SimulatorFixture.
+///
+///
+///
+/// The probe is an HTTP round-trip, not a TCP connect — 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 Devices.xml 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.
+///
+///
+/// The probe response is kept () and is the source of
+/// truth every assertion in this suite is written against. Nothing here may hard-code a
+/// DataItem id: the seeded Devices.xml 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).
+///
+///
+/// A collection fixture, so the probe runs once per session rather than once per
+/// test: against a firewalled endpoint each attempt costs the full timeout.
+///
+///
+public sealed class MTConnectAgentFixture : IAsyncDisposable
+{
+ /// The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.
+ private const string DefaultEndpoint = "http://10.100.0.35:5000";
+
+ private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";
+
+ ///
+ /// 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.
+ ///
+ 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.";
+
+ /// Gets the Agent's base URI, exactly as an authored agentUri would carry it.
+ public string AgentUri { get; }
+
+ /// Gets the skip reason when the Agent is unreachable or unusable; otherwise null.
+ public string? SkipReason { get; }
+
+ ///
+ /// Gets the Agent's own /probe response, parsed as XML — null exactly when
+ /// is set. Tests cross-reference discovery output against this so
+ /// their assertions describe the Agent's declared model rather than a hard-coded fixture.
+ ///
+ public XDocument? ProbeDocument { get; }
+
+ /// Initializes a new instance of the class.
+ 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 . {HowToStart}";
+
+ return;
+ }
+
+ ProbeDocument = document;
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
+ }
+ }
+
+ ///
+ /// Gets every DataItem the Agent declares, flattened out of
+ /// with the attributes the type inference consumes.
+ ///
+ ///
+ /// Read straight off the XML with LINQ-to-XML rather than through the driver's own
+ /// MTConnectProbeParser: 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.
+ ///
+ public IReadOnlyList 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)];
+
+ ///
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+///
+/// One DataItem as the live Agent declares it in its /probe response — the
+/// expectation side of every discovery assertion in this suite.
+///
+/// The id attribute: the observation correlation key, and what discovery must stamp as FullName.
+/// The name attribute when present: cosmetic, and what discovery must use as the browse name.
+/// The category attribute — SAMPLE, EVENT, or CONDITION.
+/// The type attribute in the probe document's UPPER_SNAKE spelling (PART_COUNT, not PartCount).
+/// The units attribute when present.
+/// The representation attribute when present (TIME_SERIES, DATA_SET, …).
+///
+/// true when this DataItem is declared under a <Compositions> element rather
+/// than under a component's own <DataItems>. The driver's discovery walk recurses
+/// <Components> 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.
+///
+///
+/// true when this DataItem belongs to the Agent's own <Agent> self-model
+/// (connection status, observation update rate, adapter URI, …) rather than to a
+/// <Device>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
+/// move continuously whether or not any adapter is attached — 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.
+///
+public sealed record DeclaredDataItem(
+ string Id,
+ string? Name,
+ string Category,
+ string Type,
+ string? Units,
+ string? Representation,
+ bool InComposition,
+ bool InAgentSelfModel);
+
+/// Collection definition so the reachability probe runs once per test session.
+[Xunit.CollectionDefinition(Name)]
+public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture
+{
+ /// The collection name every test class in this suite joins.
+ public const string Name = "MTConnectAgent";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs
new file mode 100644
index 00000000..2e08a622
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs
@@ -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;
+
+///
+/// The MTConnect driver against a real MTConnect Agent (the mtconnect/agent
+/// fixture in Docker/, or a live Agent on a machine tool via
+/// MTCONNECT_AGENT_ENDPOINT). Everything else in the driver's test surface runs on canned
+/// XML; this suite is the only thing that touches a socket.
+///
+///
+///
+/// What only a live Agent can prove, and therefore what every test here is aimed at:
+///
+///
+/// -
+///
+/// UPPER_SNAKE type spellings. A /probe document writes
+/// DataItem@type as PART_COUNT; a streams document names the same
+/// concept PartCount. An earlier revision of the inference matched only the
+/// PascalCase spelling, typed every real Agent's part counter as
+/// , and passed every unit test — because the
+/// hand-authored fixtures used the streams spelling in the probe document too.
+///
+///
+/// -
+///
+/// name differs from id. 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.
+///
+///
+/// -
+///
+/// A real multipart/x-mixed-replace stream. 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.
+///
+///
+///
+///
+/// Assertions are structural, never by literal id. Every expectation is computed from
+/// the Agent's OWN /probe response (),
+/// so the suite survives an edit to the seeded Devices.xml and can be pointed at a
+/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
+/// PART_COUNT) Assert.Skips rather than passing vacuously.
+///
+///
+/// Two things this suite deliberately does NOT cover, with reasons.
+/// (a) MTConnectError under HTTP 200. The driver handles it and canned tests
+/// pin it, but a modern Agent cannot be made to produce it: mtconnect/agent 2.7
+/// answers an unknown device with 404 and an out-of-range sequence with 400,
+/// each carrying a well-formed MTConnectError body (verified live). The under-200
+/// shape belongs to older/third-party Agents and stays canned-only.
+/// (b) Writes. MTConnect is a read-only source protocol and the driver implements no
+/// IWritable; the fixture Agent runs AllowPut = false to match.
+///
+///
+[Collection(MTConnectAgentCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Driver", "MTConnect")]
+public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
+{
+ ///
+ /// 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.
+ ///
+ private const int TestTimeoutMs = 90_000;
+
+ /// Canonical Opc.Ua.StatusCodes numerics, restated so the assertion names the wire value a client sees.
+ private const uint Good = 0x00000000u;
+
+ private const uint BadNoCommunication = 0x80310000u;
+
+ /// How long a subscription test waits for the live /sample stream to move a value.
+ private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ // ---- reachability ----
+
+ ///
+ /// The AdminUI Test Connect path against a live Agent:
+ /// must go green and report a latency.
+ ///
+ [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();
+ }
+
+ ///
+ /// Falsifiability control for : the same
+ /// probe against a port nothing is listening on must go RED. Without this, a probe that
+ /// returned Ok = true unconditionally would satisfy the green test forever.
+ ///
+ [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 ----
+
+ ///
+ /// DiscoverAsync 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.
+ ///
+ [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 , 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");
+ }
+
+ ///
+ /// The regression this whole fixture exists for. An EVENT whose probe-document
+ /// type is the UPPER_SNAKE PART_COUNT / LINE_NUMBER must infer
+ /// . Matching only the streams document's PascalCase
+ /// PartCount types it — green in every unit test,
+ /// wrong on every real Agent.
+ ///
+ [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();
+ }
+ }
+
+ ///
+ /// A SAMPLE is a measured quantity by definition of the category, so it must infer
+ /// — scalar for an ordinary sample, an array for a
+ /// TIME_SERIES.
+ ///
+ [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 ?? ""})");
+
+ 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");
+ }
+ }
+ }
+
+ ///
+ /// A CONDITION is a state word, never a number, whatever its type says — and it
+ /// is the one category discovery flags as an alarm.
+ ///
+ [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);
+ }
+ }
+
+ ///
+ /// The FullName-vs-browse-name split, on the shape that makes it visible. The
+ /// driver-side reference must be the DataItem's id (the observation correlation key)
+ /// while the browse name is its name. 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.
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ [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 ----
+
+ ///
+ /// ReadAsync returns live, coerced values off the Agent's /current snapshot for
+ /// the numeric samples it is currently reporting.
+ ///
+ [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);
+ }
+
+ ///
+ /// The Agent's literal UNAVAILABLE must map to BadNoCommunication — not to a
+ /// Good empty string, and not to the "no value yet" code, which means something different to
+ /// an operator.
+ ///
+ [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 ----
+
+ ///
+ /// The live streaming proof. A real multipart/x-mixed-replace body, with real
+ /// boundaries and real heartbeats, must drive OnDataChange — and must drive it with a
+ /// value that has actually MOVED since the primed /current 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.
+ ///
+ [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();
+ var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var baseline = new ConcurrentDictionary(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 ----
+
+ ///
+ /// The shape a deployed driver actually serves: the artifact injects RawTags
+ /// (RawPath + a TagConfig blob naming the DataItem id), and the driver must read and
+ /// publish by RawPath. A driver that answered under its own DataItem id would be
+ /// dropped silently by DriverHostActor's RawPath-keyed routing table, with every unit
+ /// test still green.
+ ///
+ [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();
+ 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);
+
+ /// The minimal Test Connect config: the probe reads nothing but agentUri.
+ private static string ProbeConfig(string agentUri) =>
+ JsonSerializer.Serialize(new { agentUri });
+
+ ///
+ /// 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.
+ ///
+ 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? 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);
+
+ /// Initializes a driver against the live Agent with the supplied authored tags.
+ private async Task LiveDriverAsync(IEnumerable tags)
+ {
+ var options = LiveOptions();
+ var driver = new MTConnectDriver(options, "mtconnect-live");
+ await driver.InitializeAsync(ConfigJson(options, tags), Ct);
+
+ return new LiveDriver(driver);
+ }
+
+ /// Initializes a driver and captures one full DiscoverAsync pass.
+ private async Task DiscoverAsync()
+ {
+ await using var live = await LiveDriverAsync([]);
+
+ var capture = new DiscoveryCapture();
+ await live.Driver.DiscoverAsync(capture, Ct);
+
+ return capture;
+ }
+
+ /// The single captured variable for a DataItem id, asserting it was streamed exactly once.
+ 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];
+ }
+
+ /// The Agent's /current snapshot, fetched straight over HTTP.
+ ///
+ /// Read outside the driver on purpose: several assertions need to know what the Agent is
+ /// reporting right now in order to build their expectation, and computing that with
+ /// the parser under test would only prove the code agrees with itself.
+ ///
+ private async Task CurrentAsync()
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
+ var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);
+
+ return XDocument.Parse(body);
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// The Agent's own self-model is excluded, and that exclusion is load-bearing. Every
+ /// MTConnect 2.x Agent publishes an <Agent> device whose
+ /// OBSERVATION_UPDATE_RATE / ASSET_UPDATE_RATE SAMPLEs tick continuously
+ /// regardless of whether any adapter is attached. Including them made
+ /// pass with the
+ /// fixture's data source deliberately stopped (verified) — it was proving the Agent's own
+ /// heartbeat, not the device stream. Restricted to <Device>-owned samples, an
+ /// Agent with no live source yields an empty set and the test skips with an actionable
+ /// message instead.
+ ///
+ private async Task> 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)];
+ }
+
+ ///
+ /// Scope guard: is not disposable (its teardown is
+ /// ShutdownAsync), and a test that failed mid-way would otherwise leave a live
+ /// /sample long poll running against the fixture for the rest of the session.
+ ///
+ private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
+ {
+ public MTConnectDriver Driver { get; } = driver;
+
+ public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj
new file mode 100644
index 00000000..0499fd05
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj
@@ -0,0 +1,41 @@
+
+
+
+
+ $(NoWarn);OTOPCUA0001
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+