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,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