cf9ce91e51
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.
230 lines
9.1 KiB
Python
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())
|