#!/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 `||[||...]`, where 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 ` 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 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: |||||.""" 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: ||| ... 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())