Import LinuxCNC source tree
This commit is contained in:
196
lib/python/mtc/shdr_agent.py
Normal file
196
lib/python/mtc/shdr_agent.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# Copyright (C) 2026 LinuxCNC contributors
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# SHDR adapter for an external MTConnect agent (e.g. the reference cppagent).
|
||||
#
|
||||
# SHDR is the MTConnect adapter protocol: a line-oriented TCP stream where the
|
||||
# adapter (this class) pushes data and the agent connects as a client. Each
|
||||
# data line is
|
||||
# <timestamp>|<dataItemId>|<value>[|<dataItemId>|<value>...]
|
||||
# with an ISO-8601 UTC-millis timestamp (see agent.now_iso()). On connect the
|
||||
# adapter sends the current value of every data item; thereafter only changed
|
||||
# values are sent. A client heartbeat line "* PING" is answered "* PONG 10000".
|
||||
#
|
||||
# The device MODEL is NOT transmitted over SHDR: the external agent is
|
||||
# configured separately with a Devices.xml, which this agent already produces
|
||||
# via `mtconnect-agent --dump-probe`. The dataItem ids emitted here are the
|
||||
# DataItemDef.id values (the same registry the probe/streams use) so they line
|
||||
# up with that Devices.xml.
|
||||
#
|
||||
# Runs a ThreadingTCPServer in a background daemon thread, mirroring HttpAgent.
|
||||
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
from .agent import now_iso
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
# SHDR is a line-oriented, pipe-delimited protocol with no escaping mechanism,
|
||||
# so a '|', tab, CR or LF inside a value (e.g. a program named 'a|b.ngc') would
|
||||
# corrupt field or line framing. Replace those characters with spaces.
|
||||
_SHDR_UNSAFE = {ord(c): " " for c in "|\t\r\n"}
|
||||
|
||||
|
||||
def _fmt_value(value):
|
||||
# SHDR values are plain text; scalars stringify directly. (Structured
|
||||
# TABLE / data-set values are filtered out before we get here.)
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value).translate(_SHDR_UNSAFE)
|
||||
|
||||
|
||||
def _format_line(pairs):
|
||||
"""Build one SHDR line: '<ts>|id|value|id|value...\\n' for (id, value) pairs."""
|
||||
parts = [now_iso()]
|
||||
for did, value in pairs:
|
||||
parts.append(str(did))
|
||||
parts.append(_fmt_value(value))
|
||||
return "|".join(parts) + "\n"
|
||||
|
||||
|
||||
def _scalar_pairs(values):
|
||||
"""(id, value) pairs for scalar values only; skip None and structured values.
|
||||
|
||||
TABLE / data-set data items (dict/list values, e.g. work/tool offsets) are
|
||||
skipped: the SHDR data-set syntax ("id|k1=v1 k2=v2 ...") is a follow-up, and
|
||||
emitting them as plain scalars would produce malformed lines.
|
||||
"""
|
||||
out = []
|
||||
for did, value in values.items():
|
||||
if value is None or isinstance(value, (dict, list)):
|
||||
continue
|
||||
out.append((did, value))
|
||||
return out
|
||||
|
||||
|
||||
def _make_handler(shdr):
|
||||
class _Handler(socketserver.StreamRequestHandler):
|
||||
# Reads are line-buffered via self.rfile; all writes go through the raw
|
||||
# socket (self.request) so the broadcast thread and this thread never
|
||||
# share a buffered writer.
|
||||
def handle(self):
|
||||
sock = self.request
|
||||
shdr._add_client(sock)
|
||||
try:
|
||||
for raw in self.rfile:
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if line == "* PING":
|
||||
try:
|
||||
sock.sendall(b"* PONG 10000\n")
|
||||
except OSError:
|
||||
break
|
||||
# Any other inbound line is ignored (adapters are push-only).
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
shdr._remove_client(sock)
|
||||
|
||||
return _Handler
|
||||
|
||||
|
||||
class _Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class ShdrAgent:
|
||||
# SHDR is an explicit opt-in (INI [MTCONNECT]TRANSPORT=shdr) whose entire
|
||||
# purpose is to be reached by an external agent, commonly on another host,
|
||||
# so it binds all interfaces by default.
|
||||
def __init__(self, agent, port=7878, host="0.0.0.0"):
|
||||
self.agent = agent
|
||||
self._clients = set()
|
||||
self._lock = threading.Lock()
|
||||
self._last_sent = {} # id -> last value broadcast (change detect)
|
||||
self.server = _Server((host, port), _make_handler(self))
|
||||
self.thread = threading.Thread(target=self.server.serve_forever,
|
||||
name="mtconnect-shdr", daemon=True)
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self.server.server_address[1]
|
||||
|
||||
def start(self):
|
||||
self.thread.start()
|
||||
|
||||
# -- client registry -----------------------------------------------------
|
||||
|
||||
def _add_client(self, sock):
|
||||
# Send the full current snapshot, then register, all under the lock so a
|
||||
# concurrent publish_changes() can't interleave a partial update ahead of
|
||||
# this client's initial dump.
|
||||
with self._lock:
|
||||
pairs = _scalar_pairs(self.agent.latest_values())
|
||||
if pairs:
|
||||
try:
|
||||
sock.sendall(_format_line(pairs).encode("utf-8"))
|
||||
except OSError:
|
||||
return
|
||||
self._clients.add(sock)
|
||||
|
||||
def _remove_client(self, sock):
|
||||
with self._lock:
|
||||
self._clients.discard(sock)
|
||||
|
||||
# -- broadcast -----------------------------------------------------------
|
||||
|
||||
def publish_changes(self):
|
||||
"""Diff the latest values against what was last sent and push changes.
|
||||
|
||||
Called by the entry once per poll. Only changed scalar items are sent;
|
||||
structured (TABLE / data-set) items and None are skipped.
|
||||
"""
|
||||
pairs = []
|
||||
for did, value in _scalar_pairs(self.agent.latest_values()):
|
||||
if self._last_sent.get(did, _MISSING) == value:
|
||||
continue
|
||||
self._last_sent[did] = value
|
||||
pairs.append((did, value))
|
||||
if not pairs:
|
||||
return
|
||||
self._broadcast(_format_line(pairs).encode("utf-8"))
|
||||
|
||||
def _broadcast(self, data):
|
||||
with self._lock:
|
||||
dead = []
|
||||
for sock in self._clients:
|
||||
try:
|
||||
sock.sendall(data)
|
||||
except OSError: # broken pipe / reset: drop the client
|
||||
dead.append(sock)
|
||||
for sock in dead:
|
||||
self._clients.discard(sock)
|
||||
|
||||
# -- shutdown ------------------------------------------------------------
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
clients = list(self._clients)
|
||||
self._clients.clear()
|
||||
for sock in clients:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
Reference in New Issue
Block a user