test(mtconnect): live-Agent docker fixture + integration suite (Tasks 19+20)

The first and only thing in the MTConnect workstream that exercises the driver
against a real Agent; everything else runs on canned XML.

Fixture (Docker/): two services, both stock images with this folder bind-mounted.
- `agent`   mtconnect/agent:2.7.0.12 on :5000. NOTE the repo name: the source
            project is mtconnect/cppagent but the PUBLISHED image is
            mtconnect/agent; mtconnect/cppagent does not exist on Docker Hub.
- `adapter` a stdlib SHDR feeder. Required, not optional: the agent image ships
            only the binary + schemas/styles, so a lone Agent answers /probe and
            then reports every observation UNAVAILABLE forever, which can prove
            nothing about reads or streaming.

Devices.xml seeds one DataItem per inference branch (SAMPLE+units, TIME_SERIES,
PART_COUNT/LINE_NUMBER, controlled vocab, DATA_SET, CONDITION), gives every named
item `name != id` (the inverse of the hand-authored unit fixtures), and leaves one
EVENT permanently unfed so UNAVAILABLE -> BadNoCommunication has a live subject.

Suite (12 tests) asserts STRUCTURALLY against the Agent's own /probe response, never
by literal id, so it survives an edit to Devices.xml and can be pointed at a real
machine tool via MTCONNECT_AGENT_ENDPOINT. It skips cleanly (12/12) when no Agent
answers, and each test carries a hard [Fact(Timeout)] so a half-up fixture cannot
wedge a build.

Three findings from bringing the fixture up, each now pinned in a comment:
- agent.cfg must be pure ASCII; one non-ASCII byte in a COMMENT makes the config
  parser reject the whole file with a bare "Failed / Stopped at line: N" and exit.
- `sampleCount` is an attribute of a TIME_SERIES observation, not of a DataItem
  declaration. A real Agent meeting one on a declaration DROPS THE ENTIRE DATA ITEM
  from the device model, so the inference only ever sees null and a live TIME_SERIES
  tag is always variable-length. Asserted, not ignored.
- The Agent's own <Agent> self-model publishes update-rate SAMPLEs that tick whether
  or not any adapter is attached. Excluding them is load-bearing: with them included
  the "live stream delivered a changed value" test passed with the fixture's data
  source deliberately stopped.

MTConnectError-under-HTTP-200 is NOT covered live and cannot be: cppagent 2.7 answers
an unknown device 404 and an out-of-range sequence 400, each with a well-formed error
body. That shape stays canned-only, and the suite says so.
This commit is contained in:
Joseph Doherty
2026-07-24 18:33:46 -04:00
parent 9227d03ca8
commit cf9ce91e51
11 changed files with 1624 additions and 2 deletions
@@ -0,0 +1,110 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that records the tree <c>DiscoverAsync</c> streams into
/// it, so discovery against a live Agent can be asserted on shape (which leaf landed under which
/// folder, with which driver-side metadata) rather than on a flat list.
/// </summary>
/// <remarks>
/// A near-twin of the MTConnect unit suite's <c>CapturingBuilder</c>, restated here rather than
/// shared: that type is <c>internal</c> to a different test assembly, and the two production
/// capturing builders (<c>Commons.Browsing</c> / <c>Runtime.Drivers</c>) would drag the server
/// stack into a driver integration project that deliberately references only the driver.
/// </remarks>
internal sealed class DiscoveryCapture : IAddressSpaceBuilder
{
private readonly State _state;
private readonly string _path;
/// <summary>Creates the root scope a driver's <c>DiscoverAsync</c> is handed.</summary>
public DiscoveryCapture()
{
_state = new State();
_path = string.Empty;
}
private DiscoveryCapture(State state, string path)
{
_state = state;
_path = path;
}
/// <summary>Every folder streamed, with the slash-joined path it landed at.</summary>
public IReadOnlyList<CapturedFolder> Folders => _state.Folders;
/// <summary>Every variable streamed, with the folder path it landed under.</summary>
public IReadOnlyList<CapturedVariable> Variables => _state.Variables;
/// <inheritdoc/>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
_state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
return new DiscoveryCapture(_state, path);
}
/// <inheritdoc/>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
_state.Variables.Add(captured);
return new Handle(captured);
}
/// <inheritdoc/>
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
// Recorded nowhere: the MTConnect driver streams no node properties, and a capture that
// threw here would fail a driver that legitimately started to.
}
private sealed class State
{
public List<CapturedFolder> Folders { get; } = [];
public List<CapturedVariable> Variables { get; } = [];
}
private sealed class Handle(CapturedVariable variable) : IVariableHandle
{
public string FullReference => variable.Attr.FullName;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
variable.AlarmConditions.Add(info);
return new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
// No test here drives an alarm transition through discovery.
}
}
}
}
/// <summary>One folder captured from a discovery stream.</summary>
/// <param name="Path">Slash-joined path of the folder itself.</param>
/// <param name="ParentPath">Slash-joined path of the scope it was added to (empty at the root).</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
/// <summary>One variable captured from a discovery stream.</summary>
/// <param name="ParentPath">Slash-joined path of the folder it landed under.</param>
/// <param name="BrowseName">The browse name the driver supplied.</param>
/// <param name="DisplayName">The display name the driver supplied.</param>
/// <param name="Attr">The driver-side attribute metadata the driver stamped on it.</param>
internal sealed record CapturedVariable(
string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
{
/// <summary>Alarm conditions the driver marked this variable with, if any.</summary>
public List<AlarmConditionInfo> AlarmConditions { get; } = [];
}
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
MTConnect integration-test device model — the canned Devices.xml the fixture Agent
(mtconnect/agent) serves from /probe.
This model is authored to exercise every branch of MTConnectDataTypeInference.Infer,
and to cover the two things the driver's canned unit-test fixtures structurally CANNOT:
1. UPPER_SNAKE type spellings. A probe document writes DataItem@type in UPPER_SNAKE
(PART_COUNT, ROTARY_VELOCITY); a streams document names the same concept in
PascalCase (PartCount, RotaryVelocity). The inference is separator-insensitive to
bridge the two, and a real Agent is the only thing that proves it — an earlier
revision typed PART_COUNT as String and passed every unit test.
2. name != id on every named DataItem. The hand-authored unit fixtures use ids like
"dev1_pos" that double as browse names; on a real machine tool the two always
differ, and DriverAttributeInfo.FullName must be the *id* (the observation
correlation key) while the browse name is the *name*. Every DataItem below that
carries a name deliberately gives it a value unequal to its id.
Inference coverage map (category / type / representation -> expected DriverDataType):
SAMPLE, units -> Float64 fixture_x_pos, fixture_x_load, fixture_c_speed
SAMPLE, TIME_SERIES, sampleCount -> Float64[] array fixture_c_temp_series
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
EVENT, free text -> String fixture_program, fixture_block (never fed)
EVENT, DATA_SET representation -> String fixture_varset
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
-->
<MTConnectDevices
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mtconnect.org:MTConnectDevices:2.0 http://schemas.mtconnect.org/schemas/MTConnectDevices_2.0.xsd">
<Header creationTime="2026-07-24T00:00:00Z" sender="otopcua-fixture" instanceId="0" version="2.0" assetBufferSize="128" assetCount="0" bufferSize="16384"/>
<Devices>
<!--
The Adapters block in agent.cfg is keyed by this Device's NAME (OtFixtureCnc). Rename
one and you must rename the other, or the Agent starts with no adapter attached and
every observation stays UNAVAILABLE.
-->
<Device id="otfixture" name="OtFixtureCnc" uuid="otopcua-mtconnect-fixture">
<Description manufacturer="OtOpcUa" model="IntegrationFixture" serialNumber="fixture-001">MTConnect integration-test fixture</Description>
<DataItems>
<!-- Device-level data items, declared OUTSIDE any component: the walker must not drop these. -->
<DataItem category="EVENT" id="fixture_avail" name="Favail" type="AVAILABILITY"/>
<DataItem category="EVENT" id="fixture_asset_changed" type="ASSET_CHANGED"/>
<DataItem category="EVENT" id="fixture_asset_removed" type="ASSET_REMOVED"/>
</DataItems>
<Components>
<Axes id="fixture_axes" name="Axes">
<Components>
<Linear id="fixture_x" name="X">
<DataItems>
<!-- SAMPLE + units -> Float64. Driven as a sinusoid, so its value CHANGES between reads. -->
<DataItem category="SAMPLE" id="fixture_x_pos" name="Xact" type="POSITION" subType="ACTUAL" units="MILLIMETER" nativeUnits="MILLIMETER"/>
<DataItem category="SAMPLE" id="fixture_x_load" name="Xload" type="LOAD" units="PERCENT" nativeUnits="PERCENT"/>
<!-- CONDITION -> String + IsAlarm, whatever its type says. -->
<DataItem category="CONDITION" id="fixture_x_travel" name="Xtravel" type="POSITION"/>
</DataItems>
</Linear>
<Rotary id="fixture_c" name="C">
<DataItems>
<DataItem category="SAMPLE" id="fixture_c_speed" name="Cspeed" type="ROTARY_VELOCITY" subType="ACTUAL" units="REVOLUTION/MINUTE" nativeUnits="REVOLUTION/MINUTE"/>
<!--
TIME_SERIES -> Float64 ARRAY, with ArrayDim NULL (variable length).
There is deliberately no sampleCount attribute here, and there cannot be:
`sampleCount` is an attribute of a TIME_SERIES *observation*, not of a
DataItem declaration. A real Agent rejects it outright: it logs
"The following keys were present and not expected: sampleCount" followed by
"DataItems: Invalid element 'DataItem'" and DROPS THE ENTIRE DATA ITEM from
the device model, so the tag simply vanishes from /probe (verified live
against mtconnect/agent 2.7.0.12). MTConnectDataTypeInference therefore only
ever sees sampleCount = null from a real Agent, and stamps ArrayDim = null.
`sampleRate` IS a valid DataItem attribute and is kept.
-->
<DataItem category="SAMPLE" id="fixture_c_temp_series" name="Ctemps" type="TEMPERATURE" units="CELSIUS" nativeUnits="CELSIUS" representation="TIME_SERIES" sampleRate="100"/>
</DataItems>
</Rotary>
</Components>
</Axes>
<Controller id="fixture_controller" name="Controller">
<DataItems>
<!-- Controlled vocabulary EVENT -> String. -->
<DataItem category="EVENT" id="fixture_mode" name="Cmode" type="CONTROLLER_MODE"/>
</DataItems>
<Components>
<Path id="fixture_path" name="Path">
<DataItems>
<DataItem category="EVENT" id="fixture_execution" name="Pexec" type="EXECUTION"/>
<!-- THE regression case: UPPER_SNAKE PART_COUNT must infer Int64, not String. -->
<DataItem category="EVENT" id="fixture_partcount" name="Pcount" type="PART_COUNT"/>
<DataItem category="EVENT" id="fixture_linenumber" name="Pline" type="LINE_NUMBER"/>
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
</DataItems>
</Path>
</Components>
</Controller>
</Components>
</Device>
</Devices>
</MTConnectDevices>
@@ -0,0 +1,105 @@
# MTConnect integration-test fixture — the official C++ Agent + an SHDR data source
The MTConnect C++ Agent (`mtconnect/agent`, pinned) serving a canned device model, fed live
data by a standard-library SHDR adapter. No image build step: both services run stock images
with this folder's files bind-mounted.
> **The published image is `mtconnect/agent`, not `mtconnect/cppagent`.** `cppagent` is the
> name of the source project on GitHub; there is no Docker Hub repository under that name.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Two services: `agent` (published on :5000) and `adapter` (internal, :7878) |
| [`agent.cfg`](agent.cfg) | Agent configuration, bind-mounted at `/mtconnect/config/agent.cfg` (the image's CMD path) |
| [`Devices.xml`](Devices.xml) | The canned device model the Agent serves from `/probe` |
| [`adapter.py`](adapter.py) | SHDR feeder — the data source. Pure stdlib, runs on a stock `python:*-alpine` |
## Why there is an adapter service
The `mtconnect/agent` image ships **only** the agent binary plus its schemas and styles — there
is no bundled simulator. An Agent with no adapter answers `/probe` correctly and then reports
**every observation `UNAVAILABLE` forever**, which cannot prove that reads return real values or
that the `/sample` long poll delivers anything. `adapter.py` is what makes the fixture live.
The Agent dials **out** to the adapter (see the `Adapters` block in `agent.cfg`); the adapter is
not published to the host.
## Run
From the shared Docker host (stack dir `/opt/otopcua-mtconnect`):
```bash
docker compose up -d --wait
docker compose logs -f agent
docker compose down
```
From a dev box via the helper (see CLAUDE.md "Docker Workflow"):
```powershell
lmxopcua-fix sync mtconnect # push this folder to /opt/otopcua-mtconnect/
lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
lmxopcua-fix logs mtconnect
lmxopcua-fix down mtconnect
```
### Running it on a Mac
macOS **squats port 5000** — AirPlay Receiver (ControlCenter) binds `*:5000` and wins the race,
so `docker port` reports a healthy publish while every request answers `403 Forbidden` with
`Server: AirTunes`. Use the host-port override:
```bash
MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests
```
## Endpoint
- Default: `http://10.100.0.35:5000` (the shared Docker host; 5000 is the Agent's own default port).
- Override with `MTCONNECT_AGENT_ENDPOINT` to point at a real Agent on a machine tool.
- `MTCONNECT_AGENT_HOST_PORT` changes only the **published host port** of the fixture container.
`MTConnectAgentFixture` issues one `GET {endpoint}/probe` at collection init and records a
`SkipReason` when it fails, so the suite skips cleanly on a box with no fixture running.
## The seeded device model
Every named DataItem deliberately has `name != id` — the inverse of the driver's hand-authored
unit fixtures, and the only arrangement under which confusing the browse name with the
observation correlation key is visible.
| DataItem `id` | `name` | Category | Type | Repr. | Inferred type |
|---|---|---|---|---|---|
| `fixture_avail` | `Favail` | EVENT | AVAILABILITY | | String |
| `fixture_x_pos` | `Xact` | SAMPLE | POSITION (ACTUAL, MILLIMETER) | | Float64 — **moves** |
| `fixture_x_load` | `Xload` | SAMPLE | LOAD (PERCENT) | | Float64 — **moves** |
| `fixture_x_travel` | `Xtravel` | CONDITION | POSITION | | String + IsAlarm |
| `fixture_c_speed` | `Cspeed` | SAMPLE | ROTARY_VELOCITY (REVOLUTION/MINUTE) | | Float64 — **moves** |
| `fixture_c_temp_series` | `Ctemps` | SAMPLE | TEMPERATURE (CELSIUS) | TIME_SERIES | Float64 **array**, ArrayDim `null` |
| `fixture_mode` | `Cmode` | EVENT | CONTROLLER_MODE | | String |
| `fixture_execution` | `Pexec` | EVENT | EXECUTION | | String |
| `fixture_partcount` | `Pcount` | EVENT | **PART_COUNT** | | **Int64** — the regression case |
| `fixture_linenumber` | `Pline` | EVENT | LINE_NUMBER | | Int64 |
| `fixture_program` | `Pprogram` | EVENT | PROGRAM | | String |
| `fixture_block` | `Pblock` | EVENT | BLOCK | | String — **never fed ⇒ UNAVAILABLE** |
| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String (structured ⇒ BadNotSupported) |
| `fixture_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm |
| `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String |
The Agent additionally injects **its own `<Agent>` self-model device** (connection status,
observation update rate, adapter URI). That is normal for every MTConnect 2.x Agent and the test
suite excludes it from value-plane assertions — its update-rate samples tick whether or not any
adapter is attached, so including them would let "the stream delivered a changed value" pass
against an Agent with no data source at all.
## Two things a real Agent taught us (both cost a fixture restart to find)
1. **`agent.cfg` must be pure ASCII.** A single non-ASCII byte — even inside a comment — makes
the config parser reject the whole file with a bare `Failed / Stopped at line: N` and exit.
2. **`sampleCount` is not a DataItem attribute.** It belongs to a TIME_SERIES *observation*. An
Agent that meets it on a declaration logs
`The following keys were present and not expected: sampleCount` followed by
`DataItems: Invalid element 'DataItem'` and **drops the entire data item** from the device
model. `MTConnectDataTypeInference` therefore only ever sees `sampleCount = null` from a real
Agent, so a live TIME_SERIES tag is always a variable-length array.
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""SHDR adapter feeding the OtOpcUa MTConnect integration-test fixture live data.
The mtconnect/agent image ships *only* the agent binary plus its schemas and styles --
there is no bundled simulator. Without an adapter the Agent answers /probe correctly but
reports every observation UNAVAILABLE forever, which cannot prove the two things this
fixture exists for: that ReadAsync returns real coerced values, and that the driver's
/sample long poll delivers OnDataChange from a genuinely moving stream.
So this is the data source. It speaks SHDR (the Agent's plain-text adapter protocol) over
TCP: the Agent dials IN as the client (see agent.cfg's Adapters block), and every line we
write is `<timestamp>|<key>|<value>[|<key>|<value>...]`, where <key> matches a DataItem's
`name` attribute in Devices.xml (falling back to its `id` for the unnamed ones).
Deliberate behaviours -- each one is asserted on by the integration suite:
* Xact / Xload / Cspeed move on EVERY tick. "The value changed between two reads" is the
only assertion that distinguishes a live stream from a cached /current snapshot.
* Pcount (PART_COUNT) increments on a slower cadence, so it is both a *changing* value
and an integer -- the live half of the UPPER_SNAKE PART_COUNT -> Int64 inference.
* Pblock is NEVER written. The Agent therefore reports it UNAVAILABLE for the life of
the fixture, which is what exercises UNAVAILABLE -> BadNoCommunication.
* Conditions are re-asserted periodically rather than once at connect, so a driver that
attaches mid-run still sees them.
Pure standard library on purpose: the container is a stock `python:*-alpine` with this
file bind-mounted, so the fixture needs no image build step at all (unlike the Modbus /
S7 fixtures, whose simulators do).
"""
from __future__ import annotations
import argparse
import datetime
import math
import selectors
import socket
import sys
import threading
import time
# How often the periodic feed writes a batch of observations. Comfortably faster than the
# driver's default SampleIntervalMs (1000) so a subscription sees several distinct chunks
# inside a short test timeout.
TICK_SECONDS = 0.25
# The Agent's PING/PONG watchdog: it sends `* PING` and expects `* PONG <ms>` back. The
# number is how long the Agent should wait before declaring us dead.
PONG_TIMEOUT_MS = 10000
EXECUTION_STATES = ("ACTIVE", "READY", "INTERRUPTED", "ACTIVE", "STOPPED")
CONTROLLER_MODES = ("AUTOMATIC", "MANUAL", "AUTOMATIC", "SEMI_AUTOMATIC")
def timestamp() -> str:
"""UTC in the ISO-8601 form the Agent expects (milliseconds, trailing Z)."""
now = datetime.datetime.now(datetime.timezone.utc)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
class Feed:
"""One connected Agent. Owns the socket for the life of that connection."""
def __init__(self, conn: socket.socket, peer: str) -> None:
self._conn = conn
self._peer = peer
self._started = time.monotonic()
self._ticks = 0
# ---- wire ----
def send(self, line: str) -> None:
self._conn.sendall((line + "\n").encode("utf-8"))
def send_observations(self, pairs: list[tuple[str, str]]) -> None:
"""One SHDR line carrying several key/value pairs, all sharing one timestamp."""
if not pairs:
return
body = "|".join(f"{key}|{value}" for key, value in pairs)
self.send(f"{timestamp()}|{body}")
# ---- content ----
def send_initial_state(self) -> None:
"""Everything that is not periodic, sent once as soon as the Agent attaches."""
self.send_observations(
[
("Favail", "AVAILABLE"),
("Pprogram", "OTOPCUA-FIXTURE.NC"),
# DATA_SET representation: the Agent renders these as <Entry key=...> children,
# which is precisely the wire shape the driver demotes to String / BadNotSupported.
("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"),
]
)
self.send_conditions()
def send_conditions(self) -> None:
"""SHDR condition form: <key>|<level>|<nativeCode>|<nativeSeverity>|<qualifier>|<text>."""
stamp = timestamp()
self.send(f"{stamp}|Xtravel|normal||||")
self.send(f"{stamp}|Plogic|normal||||")
def tick(self) -> None:
"""One periodic batch. Called every TICK_SECONDS."""
self._ticks += 1
elapsed = time.monotonic() - self._started
# Continuously-moving SAMPLEs. Rounded to 4 dp so the value is a clean double on
# the wire and still differs between any two consecutive ticks.
position = round(120.0 + 40.0 * math.sin(elapsed / 2.0), 4)
load = round(45.0 + 15.0 * math.sin(elapsed / 3.7), 4)
speed = round(1500.0 + 250.0 * math.sin(elapsed / 5.0), 4)
pairs: list[tuple[str, str]] = [
("Xact", f"{position}"),
("Xload", f"{load}"),
("Cspeed", f"{speed}"),
]
# PART_COUNT: an integer EVENT that also moves. Slower than the samples so it reads
# like a real counter rather than a signal.
pairs.append(("Pcount", str(100 + int(elapsed // 5))))
pairs.append(("Pline", str(10 + (self._ticks % 90))))
self.send_observations(pairs)
# TIME_SERIES form: <key>|<sampleCount>|<sampleRate>|<v1> <v2> ...
series = " ".join(
f"{round(21.5 + 0.5 * math.sin(elapsed + i / 4.0), 3)}" for i in range(8)
)
self.send(f"{timestamp()}|Ctemps|8|100|{series}")
# Controlled-vocabulary EVENTs, on their own slow cadences.
if self._ticks % 28 == 1:
self.send_observations(
[("Pexec", EXECUTION_STATES[(self._ticks // 28) % len(EXECUTION_STATES)])]
)
if self._ticks % 44 == 1:
self.send_observations(
[("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])]
)
# Re-assert conditions occasionally so a late-attaching driver still sees them.
if self._ticks % 60 == 0:
self.send_conditions()
# NOTE: Pblock is never written, on purpose. See the module docstring.
def handle(conn: socket.socket, peer: str) -> None:
feed = Feed(conn, peer)
print(f"[adapter] agent connected from {peer}", flush=True)
selector = selectors.DefaultSelector()
selector.register(conn, selectors.EVENT_READ)
pending = b""
next_tick = time.monotonic()
try:
feed.send_initial_state()
while True:
now = time.monotonic()
if now >= next_tick:
feed.tick()
# Absolute schedule, not `now + TICK`: drift-free, and a slow tick cannot
# compound into an ever-widening gap the Agent reads as a stall.
next_tick += TICK_SECONDS
if next_tick < now:
next_tick = now + TICK_SECONDS
for _ in selector.select(timeout=max(0.0, next_tick - time.monotonic())):
chunk = conn.recv(4096)
if not chunk:
print(f"[adapter] agent {peer} closed the connection", flush=True)
return
pending += chunk
while b"\n" in pending:
line, pending = pending.split(b"\n", 1)
text = line.decode("utf-8", errors="replace").strip()
# The Agent's heartbeat. Answering is mandatory: an Agent that gets no
# PONG tears the connection down and every observation goes UNAVAILABLE.
if text.startswith("* PING"):
feed.send(f"* PONG {PONG_TIMEOUT_MS}")
except (BrokenPipeError, ConnectionResetError, OSError) as ex:
print(f"[adapter] connection to {peer} ended: {ex}", flush=True)
finally:
selector.close()
try:
conn.close()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser(description="SHDR adapter for the OtOpcUa MTConnect fixture.")
parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: all interfaces).")
parser.add_argument("--port", type=int, default=7878, help="SHDR listen port (default: 7878).")
args = parser.parse_args()
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((args.host, args.port))
listener.listen(8)
print(f"[adapter] listening on {args.host}:{args.port}", flush=True)
try:
while True:
conn, addr = listener.accept()
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# One thread per Agent connection. The Agent reconnects after any restart, and
# a serialized accept loop would leave the new connection unserved while the
# dead one's socket was still being reaped.
threading.Thread(
target=handle, args=(conn, f"{addr[0]}:{addr[1]}"), daemon=True
).start()
except KeyboardInterrupt:
return 0
finally:
listener.close()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,50 @@
# MTConnect C++ Agent configuration for the OtOpcUa integration-test fixture.
#
# ASCII ONLY. The Agent's config parser rejects the whole file on a non-ASCII byte --
# even inside a comment -- with a bare "Failed / Stopped at line: N" and exits. An em
# dash in a comment is enough to make the fixture never start.
#
# The image's CMD is `/usr/bin/mtcagent run /mtconnect/config/agent.cfg`, so this file is
# bind-mounted at exactly that path (see docker-compose.yml). Paths below are ABSOLUTE on
# purpose: a relative `Devices` is resolved against the Agent's working directory
# (/home/agent), not against this file, and would silently start an Agent with no device
# model.
Devices = /mtconnect/config/Devices.xml
Port = 5000
ServiceName = OtOpcUaMTConnectFixture
# 2^14 = 16384 observations retained. Generous on purpose: the driver's /sample pump
# re-baselines through /current when its cursor falls out of the Agent's ring buffer, and
# a small buffer would make the fixture exercise that recovery path on every run instead
# of the steady-state streaming the suite is here to prove.
BufferSize = 14
CheckpointFrequency = 1000
MaxAssets = 128
# MTConnect is a read-only source for this driver; the Agent must not accept writes.
AllowPut = false
SchemaVersion = 2.0
MonitorConfigFiles = false
Pretty = true
# The block name must equal the Device name attribute in Devices.xml (OtFixtureCnc) --
# that is how the Agent binds an adapter connection to a device. "adapter" is the compose
# service name of the SHDR feeder; the Agent dials OUT to it.
Adapters {
OtFixtureCnc {
Host = adapter
Port = 7878
ReconnectInterval = 2000
}
}
# Log to stdout rather than a file: /mtconnect/log is an image-declared VOLUME that is
# created root-owned while the Agent runs as uid 1000, so file logging fails to open.
# stdout also puts adapter-connect / device-model errors straight into `docker logs`,
# which is how you diagnose a fixture that comes up but reports everything UNAVAILABLE.
logger_config {
logging_level = info
output = cout
}
@@ -0,0 +1,79 @@
# MTConnect integration-test fixture — the official MTConnect C++ Agent, fed live data by
# a standard-library SHDR adapter.
#
# TWO services, and both are required:
#
# agent mtconnect/agent (the cppagent image). Serves /probe, /current and /sample on
# :5000. NOTE the repository name — the source project is `mtconnect/cppagent`
# but the published image is `mtconnect/agent`; `mtconnect/cppagent` does not
# exist on Docker Hub.
# adapter The data source. The agent image ships only the agent binary plus schemas and
# styles — there is NO bundled simulator — so without this every observation is
# UNAVAILABLE forever and the suite could prove nothing about reads or streaming.
# The Agent dials OUT to it (agent.cfg's Adapters block); it is not published to
# the host.
#
# Why pinned: the `latest` tag moves and a fixture that silently changes device-model
# behaviour turns a driver regression into an unexplained red. Bump deliberately.
#
# Usage (from the docker host, stack dir /opt/otopcua-mtconnect):
# docker compose up -d --wait
# docker compose down
#
# Or from a dev box, via the helper (see CLAUDE.md "Docker Workflow"):
# lmxopcua-fix sync mtconnect
# lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
services:
agent:
image: mtconnect/agent:2.7.0.12
container_name: otopcua-mtconnect-agent
restart: "no"
labels:
project: lmxopcua
depends_on:
adapter:
condition: service_started
ports:
# Host port is overridable because macOS squats :5000 — AirPlay Receiver
# (ControlCenter) binds *:5000 and WINS the race, so `docker port` reports a healthy
# publish while every request answers "403 Forbidden / Server: AirTunes". On the
# Linux docker host the default is correct and needs no override. To run the fixture
# on a Mac:
# MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
# MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test ...
- "${MTCONNECT_AGENT_HOST_PORT:-5000}:5000"
volumes:
# Individual FILE binds, not a directory bind: /mtconnect/config is a VOLUME declared
# by the image, and mounting this whole folder over it would also drop docker-compose.yml
# and adapter.py into the Agent's config directory.
- ./agent.cfg:/mtconnect/config/agent.cfg:ro
- ./Devices.xml:/mtconnect/config/Devices.xml:ro
healthcheck:
# The image is alpine-based, so busybox wget is present (there is no curl). A 200 from
# /probe is the real readiness signal — the port accepts connections before the device
# model is parsed.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/probe || exit 1"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
adapter:
image: python:3.13-alpine
container_name: otopcua-mtconnect-adapter
restart: "no"
labels:
project: lmxopcua
volumes:
- ./adapter.py:/fixtures/adapter.py:ro
# Stock image + a bind-mounted script: this fixture needs no `build:` step at all, unlike
# the Modbus / S7 / AB fixtures whose simulators are built from a Dockerfile.
command: ["python", "-u", "/fixtures/adapter.py", "--host", "0.0.0.0", "--port", "7878"]
expose:
- "7878"
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket; socket.create_connection(('127.0.0.1', 7878), timeout=2).close()\" || exit 1"]
interval: 5s
timeout: 3s
retries: 6
start_period: 3s
@@ -0,0 +1,190 @@
using System.Net.Http;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// Reachability probe for a live MTConnect Agent (the <c>mtconnect/agent</c> container in
/// <c>Docker/docker-compose.yml</c>, or a real Agent on a machine tool). Reads
/// <c>MTCONNECT_AGENT_ENDPOINT</c> (default <c>http://10.100.0.35:5000</c> — the shared Docker
/// host) and issues ONE <c>GET {endpoint}/probe</c> at fixture construction. Each test checks
/// <see cref="SkipReason"/> and calls <c>Assert.Skip</c> when the Agent was unreachable, so a
/// dev box with no fixture running still passes <c>dotnet test</c> cleanly — the same pattern
/// as <c>ModbusSimulatorFixture</c> / <c>S7SimulatorFixture</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The probe is an HTTP round-trip, not a TCP connect</b> — unlike the Modbus and S7
/// fixtures, whose protocols have nothing cheaper. An Agent's port accepts connections
/// before its device model is parsed, and a mis-authored <c>Devices.xml</c> produces an
/// Agent that answers TCP and then fails every request. A TCP-only probe would let the
/// whole suite run and fail confusingly rather than skip with an actionable message.
/// </para>
/// <para>
/// <b>The probe response is kept</b> (<see cref="ProbeDocument"/>) and is the source of
/// truth every assertion in this suite is written against. Nothing here may hard-code a
/// DataItem id: the seeded <c>Devices.xml</c> is expected to change, and a real Agent on a
/// real machine is a completely different model. Tests therefore say "for every DataItem
/// the Agent declares with category=EVENT and type PART_COUNT, the discovered variable must
/// be Int64" — a statement that survives any device model, including one with zero such
/// items (which self-skips rather than passing vacuously).
/// </para>
/// <para>
/// <b>A collection fixture, so the probe runs once per session</b> rather than once per
/// test: against a firewalled endpoint each attempt costs the full timeout.
/// </para>
/// </remarks>
public sealed class MTConnectAgentFixture : IAsyncDisposable
{
/// <summary>The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.</summary>
private const string DefaultEndpoint = "http://10.100.0.35:5000";
private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";
/// <summary>
/// Bound on the one-shot reachability probe. Deliberately generous relative to a TCP probe:
/// a cold Agent parses its device model on the first request, and a large real-world model
/// can take a second or two to serialize.
/// </summary>
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);
private static readonly string HowToStart =
$"Start the fixture (docker compose -f Docker/docker-compose.yml up -d --wait) or point " +
$"{EndpointEnvVar} at a live Agent, then re-run.";
/// <summary>Gets the Agent's base URI, exactly as an authored <c>agentUri</c> would carry it.</summary>
public string AgentUri { get; }
/// <summary>Gets the skip reason when the Agent is unreachable or unusable; otherwise <c>null</c>.</summary>
public string? SkipReason { get; }
/// <summary>
/// Gets the Agent's own <c>/probe</c> response, parsed as XML — <c>null</c> exactly when
/// <see cref="SkipReason"/> is set. Tests cross-reference discovery output against this so
/// their assertions describe the Agent's declared model rather than a hard-coded fixture.
/// </summary>
public XDocument? ProbeDocument { get; }
/// <summary>Initializes a new instance of the <see cref="MTConnectAgentFixture"/> class.</summary>
public MTConnectAgentFixture()
{
AgentUri = (Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint).Trim().TrimEnd('/');
if (!Uri.TryCreate(AgentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
SkipReason = $"{EndpointEnvVar} ('{AgentUri}') is not an absolute http(s) URI. {HowToStart}";
return;
}
try
{
using var http = new HttpClient { Timeout = ProbeTimeout };
using var response = http.GetAsync($"{AgentUri}/probe").GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
SkipReason =
$"MTConnect Agent at {AgentUri} answered /probe with HTTP {(int)response.StatusCode} " +
$"({response.ReasonPhrase}). {HowToStart}";
return;
}
var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var document = XDocument.Parse(body);
// An MTConnectError document (or anything else) served under a 2xx must skip, not run.
// The suite's whole value is asserting against a real device model; running it against
// a document that is not one produces confusing red rather than an honest skip.
if (document.Root is null || document.Root.Name.LocalName != "MTConnectDevices")
{
SkipReason =
$"MTConnect Agent at {AgentUri} answered /probe with a <{document.Root?.Name.LocalName ?? "?"}> " +
$"document, not <MTConnectDevices>. {HowToStart}";
return;
}
ProbeDocument = document;
}
catch (Exception ex)
{
SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
}
}
/// <summary>
/// Gets every <c>DataItem</c> the Agent declares, flattened out of
/// <see cref="ProbeDocument"/> with the attributes the type inference consumes.
/// </summary>
/// <remarks>
/// Read straight off the XML with LINQ-to-XML rather than through the driver's own
/// <c>MTConnectProbeParser</c>: that parser is what several of these tests are checking, and
/// an assertion that ran the code under test to build its own expectation could only ever
/// prove the code agrees with itself. Matching is on local names because an MTConnect
/// document is namespaced and the namespace URI carries the schema version.
/// </remarks>
public IReadOnlyList<DeclaredDataItem> DeclaredDataItems =>
ProbeDocument is null
? []
: [.. ProbeDocument.Descendants()
.Where(e => e.Name.LocalName == "DataItem")
.Select(e => new DeclaredDataItem(
Id: (string?)e.Attribute("id") ?? string.Empty,
Name: (string?)e.Attribute("name"),
Category: (string?)e.Attribute("category") ?? string.Empty,
Type: (string?)e.Attribute("type") ?? string.Empty,
Units: (string?)e.Attribute("units"),
Representation: (string?)e.Attribute("representation"),
InComposition: e.Ancestors().Any(a => a.Name.LocalName == "Compositions"),
InAgentSelfModel: e.Ancestors().Any(a => a.Name.LocalName == "Agent")))
.Where(d => d.Id.Length > 0)];
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// One <c>DataItem</c> as the live Agent declares it in its <c>/probe</c> response — the
/// expectation side of every discovery assertion in this suite.
/// </summary>
/// <param name="Id">The <c>id</c> attribute: the observation correlation key, and what discovery must stamp as <c>FullName</c>.</param>
/// <param name="Name">The <c>name</c> attribute when present: cosmetic, and what discovery must use as the browse name.</param>
/// <param name="Category">The <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
/// <param name="Type">The <c>type</c> attribute in the probe document's UPPER_SNAKE spelling (<c>PART_COUNT</c>, not <c>PartCount</c>).</param>
/// <param name="Units">The <c>units</c> attribute when present.</param>
/// <param name="Representation">The <c>representation</c> attribute when present (<c>TIME_SERIES</c>, <c>DATA_SET</c>, …).</param>
/// <param name="InComposition">
/// <c>true</c> when this DataItem is declared under a <c>&lt;Compositions&gt;</c> element rather
/// than under a component's own <c>&lt;DataItems&gt;</c>. The driver's discovery walk recurses
/// <c>&lt;Components&gt;</c> only, so a composition-owned item is legitimately absent from the
/// browse tree; assertions about full coverage of the device model must exclude these or they
/// become a false red on any Agent whose model uses compositions.
/// </param>
/// <param name="InAgentSelfModel">
/// <c>true</c> when this DataItem belongs to the Agent's own <c>&lt;Agent&gt;</c> self-model
/// (connection status, observation update rate, adapter URI, …) rather than to a
/// <c>&lt;Device&gt;</c>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
/// move continuously <b>whether or not any adapter is attached</b> — so a "the stream delivered a
/// changed value" assertion that did not exclude these would pass against an Agent with no data
/// source at all, which is exactly the vacuous green this suite must not have.
/// </param>
public sealed record DeclaredDataItem(
string Id,
string? Name,
string Category,
string Type,
string? Units,
string? Representation,
bool InComposition,
bool InAgentSelfModel);
/// <summary>Collection definition so the reachability probe runs once per test session.</summary>
[Xunit.CollectionDefinition(Name)]
public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture<MTConnectAgentFixture>
{
/// <summary>The collection name every test class in this suite joins.</summary>
public const string Name = "MTConnectAgent";
}
@@ -0,0 +1,697 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using System.Xml.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// <summary>
/// The MTConnect driver against a <b>real</b> MTConnect Agent (the <c>mtconnect/agent</c>
/// fixture in <c>Docker/</c>, or a live Agent on a machine tool via
/// <c>MTCONNECT_AGENT_ENDPOINT</c>). Everything else in the driver's test surface runs on canned
/// XML; this suite is the only thing that touches a socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>What only a live Agent can prove</b>, and therefore what every test here is aimed at:
/// </para>
/// <list type="number">
/// <item>
/// <description>
/// <b>UPPER_SNAKE type spellings.</b> A <c>/probe</c> document writes
/// <c>DataItem@type</c> as <c>PART_COUNT</c>; a streams document names the same
/// concept <c>PartCount</c>. An earlier revision of the inference matched only the
/// PascalCase spelling, typed every real Agent's part counter as
/// <see cref="DriverDataType.String"/>, and passed every unit test — because the
/// hand-authored fixtures used the streams spelling in the probe document too.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b><c>name</c> differs from <c>id</c>.</b> On a real Agent most DataItems carry
/// both, and they differ. The canned fixtures use ids that double as names, which is
/// the one arrangement under which confusing the two is invisible.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>A real <c>multipart/x-mixed-replace</c> stream.</b> The canned subscribe tests
/// drive a fake pump that hands the driver pre-parsed chunks; real boundaries, real
/// chunked transfer-encoding, and real heartbeats are a different thing entirely.
/// </description>
/// </item>
/// </list>
/// <para>
/// <b>Assertions are structural, never by literal id.</b> Every expectation is computed from
/// the Agent's OWN <c>/probe</c> response (<see cref="MTConnectAgentFixture.ProbeDocument"/>),
/// so the suite survives an edit to the seeded <c>Devices.xml</c> and can be pointed at a
/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
/// </para>
/// <para>
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
/// shape belongs to older/third-party Agents and stays canned-only.
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
/// </para>
/// </remarks>
[Collection(MTConnectAgentCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Driver", "MTConnect")]
public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
{
/// <summary>
/// Hard per-test ceiling, in milliseconds. Every test also bounds its own awaits, but a
/// live-integration suite must never be able to wedge a build against a half-up fixture —
/// an Agent that completes its TCP handshake and then never answers would otherwise hang
/// inside the driver's own retry loop.
/// </summary>
private const int TestTimeoutMs = 90_000;
/// <summary>Canonical <c>Opc.Ua.StatusCodes</c> numerics, restated so the assertion names the wire value a client sees.</summary>
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---- reachability ----
/// <summary>
/// The AdminUI Test Connect path against a live Agent: <see cref="MTConnectDriverProbe"/>
/// must go green and report a latency.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Probe_reports_the_live_agent_reachable()
{
SkipIfDown();
var result = await new MTConnectDriverProbe()
.ProbeAsync(ProbeConfig(agent.AgentUri), TimeSpan.FromSeconds(15), Ct);
result.Ok.ShouldBeTrue($"Test Connect must go green against the live Agent. Message: {result.Message}");
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("/probe");
result.Latency.ShouldNotBeNull();
}
/// <summary>
/// Falsifiability control for <see cref="Probe_reports_the_live_agent_reachable"/>: the same
/// probe against a port nothing is listening on must go RED. Without this, a probe that
/// returned <c>Ok = true</c> unconditionally would satisfy the green test forever.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Probe_reports_a_dead_endpoint_as_unreachable()
{
SkipIfDown();
var result = await new MTConnectDriverProbe()
.ProbeAsync(ProbeConfig($"http://127.0.0.1:{ClosedLoopbackPort()}"), TimeSpan.FromSeconds(5), Ct);
result.Ok.ShouldBeFalse("a probe against a closed port must fail, or the green probe proves nothing");
result.Latency.ShouldBeNull();
}
// ---- discovery: the /probe device model ----
/// <summary>
/// <c>DiscoverAsync</c> streams a non-empty, genuinely nested tree, and every leaf in it is
/// a DataItem the Agent actually declares — no invented references, and nothing dropped
/// except the composition-owned items the walk is documented not to visit.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_streams_the_agents_declared_device_model()
{
SkipIfDown();
var capture = await DiscoverAsync();
capture.Variables.ShouldNotBeEmpty("a live Agent declares data items; an empty browse tree is the #485 shape");
capture.Folders.ShouldNotBeEmpty();
capture.Folders.ShouldContain(
f => f.ParentPath.Length > 0,
"the walk must recurse <Components>, not just emit one folder per device");
var declared = agent.DeclaredDataItems;
var declaredIds = declared.Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
var discoveredIds = capture.Variables.Select(v => v.Attr.FullName).ToHashSet(StringComparer.Ordinal);
discoveredIds.Except(declaredIds).ShouldBeEmpty("discovery must not invent references the Agent never declared");
var expected = declared.Where(d => !d.InComposition).Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
expected.Except(discoveredIds).ShouldBeEmpty("every component-owned DataItem the Agent declares must be browsable");
}
/// <summary>
/// <b>The regression this whole fixture exists for.</b> An <c>EVENT</c> whose probe-document
/// type is the UPPER_SNAKE <c>PART_COUNT</c> / <c>LINE_NUMBER</c> must infer
/// <see cref="DriverDataType.Int64"/>. Matching only the streams document's PascalCase
/// <c>PartCount</c> types it <see cref="DriverDataType.String"/> — green in every unit test,
/// wrong on every real Agent.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_an_upper_snake_numeric_event_as_Int64()
{
SkipIfDown();
var numericEvents = agent.DeclaredDataItems
.Where(d => Is(d.Category, "EVENT"))
.Where(d => Is(d.Type, "PART_COUNT") || Is(d.Type, "LINE_NUMBER") || Is(d.Type, "LINE"))
.ToList();
if (numericEvents.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} declares no PART_COUNT / LINE_NUMBER EVENT, so the " +
"UPPER_SNAKE numeric-event inference has nothing to assert against here.");
}
var capture = await DiscoverAsync();
foreach (var item in numericEvents)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(
DriverDataType.Int64,
$"DataItem '{item.Id}' (category={item.Category}, type={item.Type}) is an integer EVENT; " +
"typing it String is the UPPER_SNAKE-vs-PascalCase defect this suite exists to catch");
variable.Attr.IsArray.ShouldBeFalse();
}
}
/// <summary>
/// A <c>SAMPLE</c> is a measured quantity by definition of the category, so it must infer
/// <see cref="DriverDataType.Float64"/> — scalar for an ordinary sample, an array for a
/// <c>TIME_SERIES</c>.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_samples_as_Float64_and_time_series_as_a_float_array()
{
SkipIfDown();
var samples = agent.DeclaredDataItems
.Where(d => Is(d.Category, "SAMPLE") && !d.InComposition)
.Where(d => !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
.ToList();
if (samples.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} declares no scalar/time-series SAMPLE data items.");
}
var capture = await DiscoverAsync();
foreach (var item in samples)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(
DriverDataType.Float64,
$"DataItem '{item.Id}' is category=SAMPLE (type={item.Type}, units={item.Units ?? "<none>"})");
if (Is(item.Representation, "TIME_SERIES"))
{
variable.Attr.IsArray.ShouldBeTrue($"'{item.Id}' declares representation=TIME_SERIES");
// Not a bug and not an oversight: `sampleCount` is an attribute of a TIME_SERIES
// *observation*, not of a DataItem declaration. A real Agent REJECTS the whole
// DataItem if the declaration carries one ("The following keys were present and not
// expected: sampleCount" -> "Invalid element 'DataItem'"), so the inference can only
// ever see null here and a live TIME_SERIES tag is always variable-length. Asserted
// rather than ignored so a future change that starts fabricating a dimension is loud.
variable.Attr.ArrayDim.ShouldBeNull(
$"'{item.Id}': a real Agent never carries sampleCount on a DataItem declaration");
}
else
{
variable.Attr.IsArray.ShouldBeFalse($"'{item.Id}' declares no TIME_SERIES representation");
}
}
}
/// <summary>
/// A <c>CONDITION</c> is a state word, never a number, whatever its <c>type</c> says — and it
/// is the one category discovery flags as an alarm.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_types_a_condition_as_a_string_alarm()
{
SkipIfDown();
var conditions = agent.DeclaredDataItems
.Where(d => Is(d.Category, "CONDITION") && !d.InComposition)
.ToList();
if (conditions.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} declares no CONDITION data items.");
}
var capture = await DiscoverAsync();
foreach (var item in conditions)
{
var variable = Leaf(capture, item.Id);
variable.Attr.DriverDataType.ShouldBe(DriverDataType.String, $"CONDITION '{item.Id}' reports a state word");
variable.Attr.IsAlarm.ShouldBeTrue($"CONDITION '{item.Id}' is an alarm-bearing data item");
variable.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
}
/// <summary>
/// <b>The <c>FullName</c>-vs-browse-name split, on the shape that makes it visible.</b> The
/// driver-side reference must be the DataItem's <c>id</c> (the observation correlation key)
/// while the browse name is its <c>name</c>. Committing the name instead produces a picker
/// that looks perfect and authors tags that can never report a value — and it is invisible on
/// the canned fixtures, whose ids double as names.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs()
{
SkipIfDown();
var renamed = agent.DeclaredDataItems
.Where(d => !d.InComposition)
.Where(d => !string.IsNullOrEmpty(d.Name) && !string.Equals(d.Name, d.Id, StringComparison.Ordinal))
.ToList();
if (renamed.Count == 0)
{
Assert.Skip(
$"Every DataItem the Agent at {agent.AgentUri} declares has name == id (or no name), so " +
"the id-vs-name split cannot be observed against this device model.");
}
var capture = await DiscoverAsync();
foreach (var item in renamed)
{
var variable = Leaf(capture, item.Id);
variable.Attr.FullName.ShouldBe(item.Id, "the driver-side reference must be the DataItem id");
variable.BrowseName.ShouldBe(item.Name, "the browse name must prefer the DataItem name");
}
}
/// <summary>
/// A device scope that matches nothing must fail loudly. It must never degrade to an
/// empty-but-successful browse — indistinguishable from a working connection to a machine
/// that declares nothing (#485).
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Discovery_scoped_to_an_unknown_device_fails_rather_than_browsing_empty()
{
SkipIfDown();
var driver = new MTConnectDriver(
LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}"), "mtconnect-live-unknown-device");
var capture = new DiscoveryCapture();
try
{
// Either leg may be the one that fails — the Agent answers /probe and /current for an
// unknown device with 404 + an MTConnectError body — so the assertion is that ONE of
// them did, and that nothing was streamed.
var failed = false;
try
{
await driver.InitializeAsync(ConfigJson(LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}")), Ct);
await driver.DiscoverAsync(capture, Ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failed = true;
}
failed.ShouldBeTrue("an unknown device scope must surface as a failure, never as an empty browse");
capture.Variables.ShouldBeEmpty();
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
// ---- reads: the /current snapshot ----
/// <summary>
/// <c>ReadAsync</c> returns live, coerced values off the Agent's <c>/current</c> snapshot for
/// the numeric samples it is currently reporting.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_returns_live_values_for_the_agents_numeric_samples()
{
SkipIfDown();
var ids = await NumericSampleIdsAsync();
if (ids.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value " +
"(every sample is UNAVAILABLE). Is the fixture's adapter service running?");
}
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
var results = await live.Driver.ReadAsync([.. ids], Ct);
results.Count.ShouldBe(ids.Count);
var good = results.Where(r => r.StatusCode == Good).ToList();
good.ShouldNotBeEmpty("at least one numeric SAMPLE the Agent reports a value for must read Good");
good.ShouldAllBe(r => r.Value is double);
good.ShouldAllBe(r => r.SourceTimestampUtc != null && r.SourceTimestampUtc.Value.Kind == DateTimeKind.Utc);
}
/// <summary>
/// The Agent's literal <c>UNAVAILABLE</c> must map to <c>BadNoCommunication</c> — not to a
/// Good empty string, and not to the "no value yet" code, which means something different to
/// an operator.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_maps_an_unavailable_observation_to_BadNoCommunication()
{
SkipIfDown();
var unavailable = (await CurrentAsync())
.Descendants()
.Where(e => (string?)e.Attribute("dataItemId") is { Length: > 0 })
.Where(e => !e.HasElements && string.Equals(e.Value.Trim(), "UNAVAILABLE", StringComparison.Ordinal))
.Select(e => (string)e.Attribute("dataItemId")!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (unavailable.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no UNAVAILABLE observation, so the " +
"UNAVAILABLE -> BadNoCommunication mapping has nothing live to assert against.");
}
await using var live = await LiveDriverAsync(unavailable.Select(id => Tag(id, DriverDataType.String)));
var results = await live.Driver.ReadAsync([.. unavailable], Ct);
results.Count.ShouldBe(unavailable.Count);
results.ShouldAllBe(r => r.StatusCode == BadNoCommunication);
results.ShouldAllBe(r => r.Value == null);
}
// ---- subscriptions: the /sample long poll ----
/// <summary>
/// <b>The live streaming proof.</b> A real <c>multipart/x-mixed-replace</c> body, with real
/// boundaries and real heartbeats, must drive <c>OnDataChange</c> — and must drive it with a
/// value that has actually MOVED since the primed <c>/current</c> baseline. Asserting merely
/// that a callback arrived would be satisfied by the priming callback alone, which is
/// answered out of the snapshot and proves nothing about the stream.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Subscribe_delivers_a_changed_value_from_the_live_sample_stream()
{
SkipIfDown();
var ids = await NumericSampleIdsAsync();
if (ids.Count == 0)
{
Assert.Skip(
$"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value, so no " +
"observation can be expected to move. Is the fixture's adapter service running?");
}
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
var seen = new ConcurrentQueue<DataChangeEventArgs>();
var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var baseline = new ConcurrentDictionary<string, double>(StringComparer.Ordinal);
live.Driver.OnDataChange += (_, e) =>
{
seen.Enqueue(e);
if (e.Snapshot.StatusCode != Good || e.Snapshot.Value is not double value)
{
return;
}
// First Good value per reference is the baseline (the priming callback); a later one
// that differs is the stream doing its job.
if (!baseline.TryAdd(e.FullReference, value) &&
baseline.TryGetValue(e.FullReference, out var first) &&
Math.Abs(first - value) > 1e-9)
{
moved.TrySetResult();
}
};
var handle = await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
try
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
deadline.CancelAfter(StreamWait);
var finished = await Task.WhenAny(moved.Task, Task.Delay(Timeout.Infinite, deadline.Token));
(finished == moved.Task).ShouldBeTrue(
$"the live /sample stream must deliver a CHANGED value within {StreamWait.TotalSeconds:F0}s. " +
$"Callbacks seen: {seen.Count}; references with a Good baseline: {baseline.Count}.");
seen.Count.ShouldBeGreaterThan(ids.Count, "more callbacks than the one priming callback per reference");
}
finally
{
await live.Driver.UnsubscribeAsync(handle, CancellationToken.None);
}
}
// ---- the v3 production shape: RawPath identity ----
/// <summary>
/// The shape a deployed driver actually serves: the artifact injects <c>RawTags</c>
/// (RawPath + a <c>TagConfig</c> blob naming the DataItem id), and the driver must read and
/// publish <b>by RawPath</b>. A driver that answered under its own DataItem id would be
/// dropped silently by <c>DriverHostActor</c>'s RawPath-keyed routing table, with every unit
/// test still green.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags()
{
SkipIfDown();
var ids = (await NumericSampleIdsAsync()).Take(4).ToList();
if (ids.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
}
var rawPaths = ids.ToDictionary(id => id, id => $"Plant/MTConnect/live/{id}", StringComparer.Ordinal);
// The real merge DeploymentArtifact.TryReadSpec runs, not a hand-written JSON literal: this
// proves the driver binds the shape the deploy artifact actually produces.
var merged = DriverDeviceConfigMerger.Merge(
ConfigJson(LiveOptions()),
[new DriverDeviceConfigMerger.DeviceRow("live", "{}")],
[.. ids.Select(id => new RawTagEntry(
rawPaths[id],
$$"""{"fullName":"{{id}}","driverDataType":"Float64"}""",
WriteIdempotent: false,
DeviceName: "live"))]);
var driver = new MTConnectDriver(LiveOptions(), "mtconnect-live-rawtags");
try
{
await driver.InitializeAsync(merged, Ct);
var byRawPath = await driver.ReadAsync([.. rawPaths.Values], Ct);
byRawPath.Count.ShouldBe(ids.Count);
byRawPath.ShouldContain(r => r.StatusCode == Good && r.Value is double, "reads must resolve by RawPath");
var seen = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => seen.Enqueue(e);
var handle = await driver.SubscribeAsync([.. rawPaths.Values], TimeSpan.FromMilliseconds(200), Ct);
try
{
seen.ShouldNotBeEmpty("subscribe primes every reference from the /current snapshot");
seen.Select(e => e.FullReference)
.ShouldAllBe(reference => rawPaths.Values.Contains(reference));
}
finally
{
await driver.UnsubscribeAsync(handle, CancellationToken.None);
}
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
// ---- helpers ----
private void SkipIfDown()
{
if (agent.SkipReason is not null)
{
Assert.Skip(agent.SkipReason);
}
}
private static bool Is(string? value, string expected) =>
string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase);
/// <summary>The minimal Test Connect config: the probe reads nothing but <c>agentUri</c>.</summary>
private static string ProbeConfig(string agentUri) =>
JsonSerializer.Serialize(new { agentUri });
/// <summary>
/// A loopback port with nothing listening on it: bound to :0 to let the OS pick a free one,
/// then released. Racy in principle, effectively certain in practice, and far more reliable
/// than guessing an unused constant.
/// </summary>
private static int ClosedLoopbackPort()
{
var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
new()
{
AgentUri = agent.AgentUri,
DeviceName = deviceName,
RequestTimeoutMs = 15_000,
// Faster than the 1000 ms default so a subscription sees several distinct chunks inside
// StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
SampleIntervalMs = 200,
SampleCount = 500,
HeartbeatMs = 5_000,
// The background connectivity probe is off: it dials the Agent on its own cadence and
// would interleave request logs with the ones a failing test needs to be read from.
Probe = new MTConnectProbeOptions { Enabled = false },
};
private static string ConfigJson(MTConnectDriverOptions options, IEnumerable<MTConnectTagDefinition>? tags = null) =>
JsonSerializer.Serialize(new
{
agentUri = options.AgentUri,
deviceName = options.DeviceName,
requestTimeoutMs = options.RequestTimeoutMs,
sampleIntervalMs = options.SampleIntervalMs,
sampleCount = options.SampleCount,
heartbeatMs = options.HeartbeatMs,
probe = new { enabled = false },
tags = (tags ?? []).Select(t => new
{
fullName = t.FullName,
driverDataType = t.DriverDataType.ToString(),
}),
});
private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
{
var options = LiveOptions();
var driver = new MTConnectDriver(options, "mtconnect-live");
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
return new LiveDriver(driver);
}
/// <summary>Initializes a driver and captures one full <c>DiscoverAsync</c> pass.</summary>
private async Task<DiscoveryCapture> DiscoverAsync()
{
await using var live = await LiveDriverAsync([]);
var capture = new DiscoveryCapture();
await live.Driver.DiscoverAsync(capture, Ct);
return capture;
}
/// <summary>The single captured variable for a DataItem id, asserting it was streamed exactly once.</summary>
private static CapturedVariable Leaf(DiscoveryCapture capture, string dataItemId)
{
var matches = capture.Variables.Where(v => v.Attr.FullName == dataItemId).ToList();
matches.Count.ShouldBe(1, $"DataItem '{dataItemId}' must be streamed exactly once by discovery");
return matches[0];
}
/// <summary>The Agent's <c>/current</c> snapshot, fetched straight over HTTP.</summary>
/// <remarks>
/// Read outside the driver on purpose: several assertions need to know what the Agent is
/// reporting <i>right now</i> in order to build their expectation, and computing that with
/// the parser under test would only prove the code agrees with itself.
/// </remarks>
private async Task<XDocument> CurrentAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);
return XDocument.Parse(body);
}
/// <summary>
/// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
/// a read or subscription can make a non-vacuous statement about.
/// </summary>
/// <remarks>
/// <b>The Agent's own self-model is excluded, and that exclusion is load-bearing.</b> Every
/// MTConnect 2.x Agent publishes an <c>&lt;Agent&gt;</c> device whose
/// <c>OBSERVATION_UPDATE_RATE</c> / <c>ASSET_UPDATE_RATE</c> SAMPLEs tick continuously
/// regardless of whether any adapter is attached. Including them made
/// <see cref="Subscribe_delivers_a_changed_value_from_the_live_sample_stream"/> pass with the
/// fixture's data source deliberately stopped (verified) — it was proving the Agent's own
/// heartbeat, not the device stream. Restricted to <c>&lt;Device&gt;</c>-owned samples, an
/// Agent with no live source yields an empty set and the test skips with an actionable
/// message instead.
/// </remarks>
private async Task<IReadOnlyList<string>> NumericSampleIdsAsync()
{
var declaredSamples = agent.DeclaredDataItems
.Where(d => Is(d.Category, "SAMPLE") && !d.InAgentSelfModel && !d.InComposition)
.Where(d => !Is(d.Representation, "TIME_SERIES") && !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
.Select(d => d.Id)
.ToHashSet(StringComparer.Ordinal);
return [.. (await CurrentAsync())
.Descendants()
.Where(e => !e.HasElements)
.Select(e => ((string?)e.Attribute("dataItemId"), e.Value.Trim()))
.Where(pair => pair.Item1 is { Length: > 0 } && declaredSamples.Contains(pair.Item1))
.Where(pair => double.TryParse(pair.Item2, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
.Select(pair => pair.Item1!)
.Distinct(StringComparer.Ordinal)];
}
/// <summary>
/// Scope guard: <see cref="MTConnectDriver"/> is not disposable (its teardown is
/// <c>ShutdownAsync</c>), and a test that failed mid-way would otherwise leave a live
/// <c>/sample</c> long poll running against the fixture for the rest of the session.
/// </summary>
private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
{
public MTConnectDriver Driver { get; } = driver;
public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise wire-level behavior against a live Agent — the analyzer's documented intentional
case. Mirrors the NoWarn the MTConnect unit suite carries for the same reason. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
</ItemGroup>
<!-- The fixture (compose + agent.cfg + Devices.xml + adapter.py) beside the test binaries, so
the skip message can point at a compose file that is actually next to the runner — the
same convention the Modbus / S7 / AB fixtures use. -->
<ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>