Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py
T
Joseph Doherty cf9ce91e51 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.
2026-07-27 13:58:54 -04:00

230 lines
9.1 KiB
Python

#!/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())