The fixture Agent flips to AllowPut = true (its comment rewritten: writes are in
scope now that the driver implements IWritable) and the suite grows three
env-gated write legs, all still Assert.Skip-clean with no Agent reachable.
Devices.xml gains a DataItem, and that is the deliberate part. The write target
has to be one the SHDR adapter NEVER feeds, or the assertion races the stream --
but it must NOT be the existing never-fed fixture_block: the first successful
write takes an item off UNAVAILABLE for the life of the container, which would
silently rob Read_maps_an_unavailable_observation_to_BadNoCommunication of its
subject. So fixture_writeback (free-text EVENT, PROGRAM_COMMENT) is a separate,
dedicated target, overridable via MTCONNECT_WRITE_TARGET for a run pointed at a
real machine tool.
The three legs:
* Round trip -- driver write accepted, then the injected value read back off
/current over plain HTTP (not through the parser under test) AND through the
driver's own ReadAsync. The value carries a TickCount64 suffix: the Agent
keeps an injected observation for the container's life, so a constant would
be satisfied by a previous run's leftover.
* Agent refusal -> BadCommunicationError. The design doc doubted this leg was
constructible ("an id in the probe but refused by the agent"); it is, and the
mechanism is BuildWriteTargets. Write targets are derived from the tags the
BINDING serves, with the device resolved from the probe model and falling
back to the configured deviceName -- so an authored tag naming an id the
Agent never declared, on a driver whose deviceName IS set, resolves to a real
URL and is POSTed. That is exactly the shape a stale tag has after a DataItem
is renamed. The second half of the test is the falsifiability control: the
SAME unknown id with NO deviceName has no URL and must be refused locally as
BadNodeIdUnknown. Two different codes is what proves the first write actually
reached the Agent rather than being short-circuited into a look-alike.
* Disabled gate -- write.enabled absent returns BadNotWritable AND /current is
unchanged from the snapshot taken just before, which is what distinguishes
"refused" from "reported refused and sent anyway".
RAN LIVE, not just compiled: the fixture was brought up locally (OrbStack,
MTCONNECT_AGENT_HOST_PORT=5555 -- macOS AirPlay squats :5000) and the whole
suite passed 15/15, 0 skipped. Evidence beyond the green: the Agent reports
<ProgramComment dataItemId="fixture_writeback">OTOPCUA-WRITE-2CBCB530</...> and
its log carries "Error processing request:
/OtFixtureCnc?otopcua_no_such_data_item_2CBCB3D9=refuse-me" -- the refusal leg
demonstrably went to the wire. Offline (no Agent) the suite is 15 skipped, 0
failed; build 0 warnings / 0 errors.
Note for anyone reading agent.cfg: BufferSize = 14 is an EXPONENT (2^14 = 16384
observations), not a 14-deep ring, and /current is answered from the Agent's
per-DataItem checkpoint anyway -- so eviction is not a hazard for a write
read-back even on this busy fixture stream.
LiveOptions/ConfigJson/LiveDriverAsync gained writeEnabled + deviceName
parameters, both defaulted off/null so every pre-existing test still runs
against the shipped read-only driver whatever the Agent's AllowPut says. The
config JSON sources write.enabled FROM the options object, so the parsed config
can never disagree with the options a test thinks it built.
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, notmtconnect/cppagent.cppagentis the name of the source project on GitHub; there is no Docker Hub repository under that name.
| File | Purpose |
|---|---|
docker-compose.yml |
Two services: agent (published on :5000) and adapter (internal, :7878) |
agent.cfg |
Agent configuration, bind-mounted at /mtconnect/config/agent.cfg (the image's CMD path) |
Devices.xml |
The canned device model the Agent serves from /probe |
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):
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"):
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:
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_ENDPOINTto point at a real Agent on a machine tool. MTCONNECT_AGENT_HOST_PORTchanges 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)
agent.cfgmust be pure ASCII. A single non-ASCII byte — even inside a comment — makes the config parser reject the whole file with a bareFailed / Stopped at line: Nand exit.sampleCountis not a DataItem attribute. It belongs to a TIME_SERIES observation. An Agent that meets it on a declaration logsThe following keys were present and not expected: sampleCountfollowed byDataItems: Invalid element 'DataItem'and drops the entire data item from the device model.MTConnectDataTypeInferencetherefore only ever seessampleCount = nullfrom a real Agent, so a live TIME_SERIES tag is always a variable-length array.