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:
@@ -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
|
||||
}
|
||||
+79
@@ -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
|
||||
Reference in New Issue
Block a user