Table" element name
+ # (e.g. WORK_OFFSET -> WorkOffsetTable).
+ elname = obs.di.element + ("Table" if obs.di.representation == "TABLE" else "")
+ el = ET.SubElement(grp, prefix + elname, attrs)
+ if obs.di.representation == "TABLE":
+ if isinstance(obs.value, dict):
+ _emit_table(el, obs.value, prefix)
+ else: # UNAVAILABLE (or not-yet-set): count is required, 0 entries
+ el.set("count", "0")
+ el.text = str(obs.value)
+ else:
+ el.text = str(obs.value)
+
+
+def _emit_table(el, table, prefix=""):
+ """Emit a TABLE observation as | v | ...."""
+ el.set("count", str(len(table)))
+ for entry_key, cells in table.items():
+ entry = ET.SubElement(el, prefix + "Entry", {"key": entry_key})
+ for cell_key, value in cells.items():
+ cell = ET.SubElement(entry, prefix + "Cell", {"key": cell_key})
+ cell.text = "%g" % value if isinstance(value, (int, float)) else str(value)
+
+
+def current_xml(buffer, config, timestamp):
+ root = _streams_doc(buffer.current(), config, timestamp, buffer)
+ return _serialize(root)
+
+
+def sample_xml(buffer, config, timestamp, from_seq, count):
+ root = _streams_doc(buffer.sample(from_seq, count), config, timestamp, buffer)
+ return _serialize(root)
+
+
+# -- assets XML --------------------------------------------------------------
+
+def _as(tag):
+ return tag
+
+
+def assets_xml(tool_assets, config, timestamp):
+ root = ET.Element("MTConnectAssets", {"xmlns": ASSETS_NS})
+ ET.SubElement(root, _as("Header"), {
+ "creationTime": timestamp,
+ "sender": "linuxcnc-mtconnect",
+ "instanceId": config.instance_id,
+ "version": SCHEMA_VERSION,
+ "deviceModelChangeTime": timestamp,
+ "assetBufferSize": "1024",
+ "assetCount": str(len(tool_assets)),
+ })
+ assets = ET.SubElement(root, _as("Assets"))
+ for tool in tool_assets:
+ _cutting_tool(assets, tool, config, timestamp)
+ return _serialize(root)
+
+
+def _cutting_tool(parent, tool, config, timestamp):
+ # serialNumber is required; LinuxCNC has no real serial, so echo the stable
+ # assetId. All measurements are emitted in millimetres (MTConnect canonical).
+ ct = ET.SubElement(parent, _as("CuttingTool"), {
+ "assetId": tool.asset_id,
+ "serialNumber": tool.asset_id,
+ "toolId": str(tool.tool_no),
+ "deviceUuid": config.uuid,
+ "timestamp": timestamp,
+ })
+ if tool.comment and tool.comment.strip():
+ ET.SubElement(ct, _as("Description")).text = tool.comment.strip()
+ lifecycle = ET.SubElement(ct, _as("CuttingToolLifeCycle"))
+ status = ET.SubElement(lifecycle, _as("CutterStatus"))
+ ET.SubElement(status, _as("Status")).text = "USED" if tool.in_spindle else "AVAILABLE"
+
+ location_type = "SPINDLE" if tool.in_spindle else "POT"
+ ET.SubElement(lifecycle, _as("Location"), {
+ "type": location_type,
+ "positiveOverlap": "0",
+ "negativeOverlap": "0",
+ }).text = str(tool.pocket)
+
+ # Tool-assembly measurements: the gauge-line-to-tip length is FunctionalLength
+ # (LF), not BodyLengthMax (LBX). Emitted only when non-zero.
+ if tool.length_z:
+ measurements = ET.SubElement(lifecycle, _as("Measurements"))
+ ET.SubElement(measurements, _as("FunctionalLength"), {
+ "units": "MILLIMETER", "code": "LF",
+ }).text = "%g" % tool.length_z
+
+ # CuttingDiameter is a cutting-item measurement, so it lives under a
+ # CuttingItem, not the tool-level Measurements block.
+ if tool.diameter:
+ items = ET.SubElement(lifecycle, _as("CuttingItems"), {"count": "1"})
+ item = ET.SubElement(items, _as("CuttingItem"), {"indices": "1"})
+ m = ET.SubElement(item, _as("Measurements"))
+ ET.SubElement(m, _as("CuttingDiameter"), {
+ "units": "MILLIMETER", "code": "DC",
+ }).text = "%g" % tool.diameter
+
+
+def _serialize(root):
+ ET.indent(root, space=" ")
+ body = ET.tostring(root, encoding="unicode")
+ return '\n' + body + "\n"
diff --git a/share/linuxcnc/mtconnect/mtconnect-linuxcnc-1.xsd b/share/linuxcnc/mtconnect/mtconnect-linuxcnc-1.xsd
new file mode 100644
index 00000000000..af000f8f219
--- /dev/null
+++ b/share/linuxcnc/mtconnect/mtconnect-linuxcnc-1.xsd
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/share/linuxcnc/mtconnect/twin.html b/share/linuxcnc/mtconnect/twin.html
new file mode 100644
index 00000000000..67192ca4575
--- /dev/null
+++ b/share/linuxcnc/mtconnect/twin.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+LinuxCNC MTConnect Twin
+
+
+
+
+
+
LinuxCNC · MTConnect Twin
+
+
+
+drag = orbit · scroll = zoom · right-drag = pan
+
+
+
diff --git a/src/Makefile b/src/Makefile
index 69952ddeac9..895298aa3fe 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -746,6 +746,7 @@ install-kernel-indep: install-dirs
$(FILE) ../configs/common/linuxcnc_big.nml $(DESTDIR)$(prefix)/share/linuxcnc
$(FILE) ../src/emc/usr_intf/pncconf/pncconf-help/*.txt $(DESTDIR)$(prefix)/share/linuxcnc/pncconf/pncconf-help
$(FILE) ../src/emc/usr_intf/pncconf/pncconf-help/*.png $(DESTDIR)$(prefix)/share/linuxcnc/pncconf/pncconf-help
+ $(TREE) ../share/linuxcnc/mtconnect $(DESTDIR)$(prefix)/share/linuxcnc
$(FILE) ../lib/python/gladevcp/hal_python.xml $(DESTDIR)$(datadir)/glade/catalogs/
$(FILE) ../share/glade/pixmaps/hicolor/22x22/actions/widget*.png $(DESTDIR)$(datadir)/glade/pixmaps/hicolor/22x22/actions/
@@ -757,6 +758,7 @@ install-kernel-indep: install-dirs
install-kernel-indep: install-python
install-python: install-dirs
$(DIR) $(DESTDIR)$(SITEPY) $(DESTDIR)$(SITEPY)/rs274
+ $(DIR) $(DESTDIR)$(SITEPY)/mtc
$(DIR) $(DESTDIR)$(SITEPY)/common
$(DIR) $(DESTDIR)$(SITEPY)/touchy
$(DIR) $(DESTDIR)$(SITEPY)/gscreen
@@ -782,6 +784,7 @@ install-python: install-dirs
$(FILE) ../lib/python/*.py ../lib/python/*.so $(DESTDIR)$(SITEPY)
$(FILE) ../lib/python/common/*.py $(DESTDIR)$(SITEPY)/common
$(FILE) ../lib/python/rs274/*.py $(DESTDIR)$(SITEPY)/rs274
+ $(FILE) ../lib/python/mtc/*.py $(DESTDIR)$(SITEPY)/mtc
$(FILE) ../lib/python/touchy/*.py $(DESTDIR)$(SITEPY)/touchy
$(FILE) ../lib/python/gscreen/*.py $(DESTDIR)$(SITEPY)/gscreen
$(FILE) ../lib/python/qtvcp/*.{py,ui,txt} $(DESTDIR)$(SITEPY)/qtvcp
@@ -803,6 +806,7 @@ install-python: install-dirs
$(EXE) ../bin/hal_bridge $(DESTDIR)$(bindir)
$(EXE) ../bin/mitsub_vfd $(DESTDIR)$(bindir)
$(EXE) ../bin/mqtt-publisher $(DESTDIR)$(bindir)
+ $(EXE) ../bin/mtconnect-agent $(DESTDIR)$(bindir)
$(EXE) ../bin/z_level_compensation $(DESTDIR)$(bindir)
$(EXE) ../bin/pmx485 $(DESTDIR)$(bindir)
$(EXE) ../bin/sim-torch $(DESTDIR)$(bindir)
diff --git a/src/hal/user_comps/Submakefile b/src/hal/user_comps/Submakefile
index 3dd1fe725b9..df838005817 100644
--- a/src/hal/user_comps/Submakefile
+++ b/src/hal/user_comps/Submakefile
@@ -1,4 +1,4 @@
-USER_COMP_PY = pyvcp hal_input gladevcp scorbot-er-3 mitsub_vfd pmx485 sim-torch z_level_compensation mqtt-publisher hal_bridge
+USER_COMP_PY = pyvcp hal_input gladevcp scorbot-er-3 mitsub_vfd pmx485 sim-torch z_level_compensation mqtt-publisher hal_bridge mtconnect-agent
USER_COMPS := $(sort $(wildcard hal/user_comps/*.comp))
USER_COMP_BINS := $(patsubst hal/user_comps/%.comp, ../bin/%, $(USER_COMPS))
diff --git a/src/hal/user_comps/mtconnect-agent.py b/src/hal/user_comps/mtconnect-agent.py
new file mode 100755
index 00000000000..845c2646c3a
--- /dev/null
+++ b/src/hal/user_comps/mtconnect-agent.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+#
+# 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
+#
+# MTConnect agent for LinuxCNC.
+#
+# A userspace, non-realtime component that reads machine status, kinematics and
+# tool data via the linuxcnc Python module and exposes them over MTConnect: an
+# embedded HTTP agent (/probe /current /sample /assets) and/or the standard
+# MTConnect MQTT binding. Configured from the [MTCONNECT] section of the INI.
+#
+# Typical use, from a config's HAL file:
+# loadusr -W mtconnect-agent
+# or from the INI:
+# [APPLICATIONS]
+# APP = mtconnect-agent
+
+import argparse
+import os
+import signal
+import sys
+import time
+
+# The mtc package installs to $EMC2_HOME/lib/python (on PYTHONPATH under a normal
+# LinuxCNC environment); fall back to it explicitly for a bare invocation.
+try:
+ from mtc.agent import AgentState
+ from mtc.http_agent import HttpAgent
+except ModuleNotFoundError:
+ _home = os.environ.get("EMC2_HOME")
+ if _home:
+ sys.path.insert(0, os.path.join(_home, "lib", "python"))
+ from mtc.agent import AgentState
+ from mtc.http_agent import HttpAgent
+
+
+def read_options(ini):
+ def first_token(v, default):
+ # LinuxCNC keeps everything after '=', so tolerate a stray inline
+ # comment / trailing text by taking the first whitespace-delimited token.
+ tok = (str(v).strip().split() or [default])[0]
+ return tok.split(";")[0].split("#")[0]
+
+ def truthy(v):
+ return first_token(v, "0").lower() in ("1", "true", "yes", "on")
+
+ def transports(v):
+ # Comma- (or space-) separated list: http, mqtt, shdr. Legacy "both"
+ # expands to http+mqtt. Tolerates inline comments per token.
+ raw = str(v if v is not None else "http").lower().replace(",", " ")
+ out = []
+ for tok in raw.split():
+ tok = tok.split(";")[0].split("#")[0]
+ if tok == "both":
+ out += ["http", "mqtt"]
+ elif tok in ("http", "mqtt", "shdr") and tok not in out:
+ out.append(tok)
+ return out or ["http"]
+ return {
+ "enable": truthy(ini.find("MTCONNECT", "ENABLE", "1")),
+ "http_port": ini.find_int("MTCONNECT", "HTTP_PORT", 5000),
+ "http_bind": first_token(ini.find("MTCONNECT", "HTTP_BIND", "127.0.0.1"),
+ "127.0.0.1"),
+ "enable_twin": truthy(ini.find("MTCONNECT", "ENABLE_TWIN", "0")),
+ "transports": transports(ini.find("MTCONNECT", "TRANSPORT", "http")),
+ "shdr_port": ini.find_int("MTCONNECT", "SHDR_PORT", 7878),
+ "sample_hz": ini.find_float("MTCONNECT", "SAMPLE_HZ", 10.0) or 10.0,
+ "mqtt_broker": ini.find("MTCONNECT", "MQTT_BROKER", "localhost"),
+ "mqtt_port": ini.find_int("MTCONNECT", "MQTT_PORT", 1883),
+ "mqtt_prefix": ini.find("MTCONNECT", "MQTT_PREFIX", "MTConnect"),
+ "mqtt_username": (ini.find("MTCONNECT", "MQTT_USERNAME")
+ or ini.find("MTCONNECT", "MQTT_USER")),
+ "mqtt_password": (ini.find("MTCONNECT", "MQTT_PASSWORD")
+ or ini.find("MTCONNECT", "MQTT_PASS")),
+ }
+
+
+def make_hal_pins():
+ """Create the HAL component; returns it or None if HAL is unavailable."""
+ try:
+ import hal
+ except ImportError:
+ return None
+ comp = hal.component("mtconnect-agent")
+ comp.newpin("enable", hal.HAL_BIT, hal.HAL_IN)
+ comp["enable"] = True
+ comp.newpin("sample-hz", hal.HAL_U32, hal.HAL_IN)
+ comp.newpin("heartbeat", hal.HAL_U32, hal.HAL_OUT)
+ # Status outputs (linkable in the HAL file):
+ # active - agent is polling and serving
+ # connected - MQTT broker link is up (stays low without MQTT; HTTP/SHDR
+ # are stateless request/response and don't drive it)
+ comp.newpin("active", hal.HAL_BIT, hal.HAL_OUT)
+ comp.newpin("connected", hal.HAL_BIT, hal.HAL_OUT)
+ comp.ready()
+ return comp
+
+
+class Stopper:
+ """Latches on SIGTERM/SIGINT so the poll loop exits cleanly.
+
+ Launched with `loadusr -W`, the agent is signalled (SIGTERM) on unload /
+ machine shutdown -- this replaces the old halui-existence gate, which
+ exited prematurely when the agent was loaded before halui exists.
+ """
+
+ def __init__(self):
+ self.stop = False
+ signal.signal(signal.SIGTERM, self._handle)
+ signal.signal(signal.SIGINT, self._handle)
+
+ def _handle(self, signum, frame):
+ self.stop = True
+
+
+def main():
+ parser = argparse.ArgumentParser(description="MTConnect agent for LinuxCNC")
+ parser.add_argument("ini", nargs="?", default=os.environ.get("INI_FILE_NAME"),
+ help="LinuxCNC INI file (default: $INI_FILE_NAME)")
+ parser.add_argument("--dump-probe", action="store_true",
+ help="print the MTConnectDevices document and exit")
+ parser.add_argument("--port", type=int, help="override [MTCONNECT]HTTP_PORT")
+ args = parser.parse_args()
+
+ if not args.ini:
+ parser.error("no INI file (set INI_FILE_NAME or pass one)")
+
+ state = AgentState(args.ini)
+ opts = read_options(state.ini)
+ if args.port:
+ opts["http_port"] = args.port
+
+ if args.dump_probe:
+ sys.stdout.write(state.probe_document())
+ return 0
+
+ if not opts["enable"]:
+ print("info: [MTCONNECT]ENABLE is off; mtconnect-agent exiting.")
+ return 0
+
+ state.config.instance_id = str(int(time.time()))
+ comp = make_hal_pins()
+ stopper = Stopper()
+ state.poll_once() # prime the buffer so /current has data immediately
+
+ transports = opts["transports"]
+ http_agent = None
+ mqtt_agent = None
+ shdr_agent = None
+ if "http" in transports:
+ http_agent = HttpAgent(state, host=opts["http_bind"], port=opts["http_port"],
+ enable_twin=opts["enable_twin"])
+ http_agent.start()
+ print("info: MTConnect HTTP agent on %s:%d (/probe /current /sample /assets%s)"
+ % (opts["http_bind"], http_agent.port,
+ " /twin" if opts["enable_twin"] else ""))
+ if "mqtt" in transports:
+ from mtc.mqtt_agent import MqttAgent
+ mqtt_agent = MqttAgent(state, broker=opts["mqtt_broker"],
+ port=opts["mqtt_port"], prefix=opts["mqtt_prefix"],
+ username=opts["mqtt_username"],
+ password=opts["mqtt_password"])
+ print("info: MTConnect MQTT publishing to %s/*/%s"
+ % (opts["mqtt_prefix"], state.config.uuid))
+ if "shdr" in transports:
+ from mtc.shdr_agent import ShdrAgent
+ shdr_agent = ShdrAgent(state, port=opts["shdr_port"])
+ shdr_agent.start()
+ # The external agent (cppagent) is configured separately with a
+ # Devices.xml, produced by `mtconnect-agent --dump-probe`; the model is
+ # not sent over SHDR.
+ print("info: MTConnect SHDR adapter on :%d (feed an external agent's Devices.xml)"
+ % shdr_agent.port)
+
+ if comp is not None:
+ comp["active"] = True
+ beat = 0
+ try:
+ while not stopper.stop:
+ hz = opts["sample_hz"]
+ if comp is not None:
+ if not comp["enable"]:
+ comp["active"] = False
+ time.sleep(0.2)
+ continue
+ comp["active"] = True
+ if comp["sample-hz"]:
+ hz = comp["sample-hz"]
+ state.poll_once()
+ if mqtt_agent is not None:
+ mqtt_agent.publish_current()
+ mqtt_agent.publish_sample()
+ mqtt_agent.publish_assets()
+ if shdr_agent is not None:
+ shdr_agent.publish_changes()
+ beat = (beat + 1) & 0xFFFFFFFF
+ if comp is not None:
+ comp["heartbeat"] = beat
+ comp["connected"] = bool(mqtt_agent and mqtt_agent.connected)
+ time.sleep(1.0 / max(hz, 0.1))
+ except KeyboardInterrupt:
+ pass
+ finally:
+ if comp is not None:
+ comp["active"] = False
+ comp["connected"] = False
+ if http_agent is not None:
+ http_agent.stop()
+ if mqtt_agent is not None:
+ mqtt_agent.stop()
+ if shdr_agent is not None:
+ shdr_agent.stop()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())