Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker
Joseph Doherty b4480bf45a test(mtconnect): live write legs against the AllowPut fixture
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.
2026-08-05 06:56:16 -04:00
..

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 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_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.