diff --git a/configs/sim/axis/mtconnect/.gitignore b/configs/sim/axis/mtconnect/.gitignore new file mode 100644 index 00000000000..7448b765126 --- /dev/null +++ b/configs/sim/axis/mtconnect/.gitignore @@ -0,0 +1,8 @@ +# LinuxCNC runtime state (created when the sim runs) +sim.var +sim.var.bak +position.txt + +# 3D model assets are user-supplied. The example uses MODEL_AUTO (generated box +# geometry) and needs no mesh files, so the whole directory stays untracked. +models/ diff --git a/configs/sim/axis/mtconnect/README.md b/configs/sim/axis/mtconnect/README.md new file mode 100644 index 00000000000..70cb9c96c7b --- /dev/null +++ b/configs/sim/axis/mtconnect/README.md @@ -0,0 +1,262 @@ +# MTConnect agent for LinuxCNC — demo config + +First-class MTConnect support for LinuxCNC: a userspace, non-realtime agent that +exposes machine **status**, a rich **kinematic description**, and **tool data** +over MTConnect. It ships an embedded HTTP agent (no external dependency) and can +optionally publish over the standard MTConnect **MQTT** binding. The `/probe` +response carries enough kinematic detail for an external tool (e.g. a FreeCAD +Path plugin) to auto-configure a machine. + +This directory is a demo sim config. The agent itself installs with LinuxCNC +(`bin/mtconnect-agent`, package `lib/python/mtc`, assets under +`share/linuxcnc/mtconnect`). For the full reference see the `mtconnect-agent(1)` +man page and the `[MTCONNECT]` section of the INI configuration chapter. + +## Quick start + +```sh +# From this directory: +linuxcnc example.ini # launches the sim + the MTConnect agent + +# In another terminal: +curl http://localhost:5000/probe # MTConnectDevices (structure + kinematics) +curl http://localhost:5000/current # latest value of every DataItem +curl http://localhost:5000/sample?from=1&count=100 +curl http://localhost:5000/assets # CuttingTool assets (tool table) +``` + +You can inspect the generated device model without launching LinuxCNC (after +sourcing `scripts/rip-environment` in a RIP build, or with LinuxCNC installed): + +```sh +mtconnect-agent --dump-probe example.ini +mtconnect-agent --dump-probe ../vismach/5axis/table-rotary-tilting/xyzac-trt.ini +``` + +## Enabling it in your own config + +Add a few lines to your INI. The device model is generated automatically from +`[TRAJ]`, `[KINS]`, `[AXIS_*]` and `[JOINT_*]`. + +```ini +[MTCONNECT] +ENABLE = 1 +DEVICE_NAME = my_mill +UUID = linuxcnc-my-mill-0001 +HTTP_PORT = 5000 +# HTTP_BIND defaults to 127.0.0.1 (loopback); set 0.0.0.0 to expose on the LAN. +# HTTP_BIND = 127.0.0.1 +# TRANSPORT: comma list of http, mqtt, shdr (no inline comments in INI values) +TRANSPORT = http +SAMPLE_HZ = 10 +# MQTT_BROKER = localhost +# MQTT_PORT = 1883 +# MQTT_PREFIX = MTConnect +``` + +Load the agent from a HAL file (see `mtconnect.hal`): + +``` +loadusr -W mtconnect-agent +``` + +`-W` waits until the component is ready; the INI comes from `$INI_FILE_NAME`. +Loading it in the HAL file (rather than `[APPLICATIONS]`) makes the status pins +(`active`, `connected`) available for linking. + +## What is exposed + +| Endpoint | Content | +|------------|---------| +| `/probe` | Device structure: Controller/Path, Axes (Linear/Rotary + Motion), spindle, and the `x:Kinematics` extension | +| `/current` | Latest value of every DataItem (execution, mode, positions, spindle, feed, tool) | +| `/sample` | Sequence-numbered observation history (`?from=&count=`) | +| `/assets` | Tool table as `CuttingTool` assets (location, diameter, length offsets) | + +### LinuxCNC → MTConnect mapping (`linuxcnc.stat()`) + +| MTConnect DataItem | Source | +|---|---| +| `EMERGENCY_STOP` | `task_state` | +| `CONTROLLER_MODE` | `task_mode` | +| `EXECUTION` | `interp_state`, `task_paused` | +| `PROGRAM`, `LINE_NUMBER` | `file`, `motion_line` (executing line, not interp read-ahead) | +| `PATH_FEEDRATE` (+ OVERRIDE) | `current_vel`, `feedrate` | +| `POSITION` / `ANGLE` (ACTUAL/COMMANDED) | `actual_position`, `position` | +| `ROTARY_VELOCITY`, `ROTARY_MODE`, `DIRECTION` | `spindle[0]` | +| `TOOL_NUMBER`, `TOOL_ASSET_ID` | `tool_in_spindle` | +| `ASSET_CHANGED` | tool change detection | +| `CuttingTool` assets | `tool_table` | + +## The kinematic description (`x:Kinematics`) + +Standard MTConnect models axes as `Linear`/`Rotary` components with `Motion` +elements (`PRISMATIC`/`REVOLUTE`, direction vector). LinuxCNC specifics that do +not map cleanly are carried in a versioned extension namespace +`urn:linuxcnc:mtconnect:1` — the primary contract for auto-configuration: + +```xml + + + + + + + + + + +``` + +A FreeCAD plugin reads the standard `Axes`/`Motion` tree, or the compact +`x:Kinematics` block, to create/configure a machine: axis list and type, +travel limits, home positions, kinematics module/type, and the joint↔axis map. + +## Digital twin (solid models) + +An MTConnect twin (e.g. the viewer at demo.mtconnect.org/twin) renders a machine +from `/probe` alone: geometry is **referenced, never streamed**. The device model +carries `` elements pointing to external mesh files, plus +`` and a `` chain; the viewer fetches each mesh once +and animates it using the streamed positions. + +The bundled viewer at `/twin` is **off by default**; enable it with +`ENABLE_TWIN = 1`. Its 3D rendering uses three.js from the distribution's +`libjs-three` package (a Debian *Recommends*), which the agent serves from +`/three/` — LinuxCNC vendors no third-party JavaScript, and the twin still works +fully offline once that package is installed. + +### Zero-config geometry (`MODEL_AUTO`) + +```ini +[MTCONNECT] +MODEL_AUTO = 1 +``` + +The agent generates simple placeholder **box** meshes for the base and each axis +straight from the travel limits and serves them from memory — any machine gets a +functional twin with **no mesh files**. This is what the example config uses. + +### Real geometry (per-link meshes) + +Supply your own meshes (STL / OBJ / glTF) for fidelity: + +```ini +[MTCONNECT] +MODEL_DIR = models ; base dir for relative paths (default: INI dir) +MODEL_BASE = frame.stl ; static frame / column (device-level SolidModel) +MODEL_X = x_table.stl ; link that moves with X +MODEL_Y = y_saddle.stl +MODEL_Z = spindle.stl +MODEL_SPINDLE = spindle.stl +``` + +Author each mesh in **millimetres** (MTConnect's canonical unit — the SolidModel +element carries no per-mesh unit) in the machine frame at the all-axes-zero pose. +An explicit +`MODEL_` overrides the generated box for that link, so files and +`MODEL_AUTO` can be mixed. + +### Topology and direction (both modes) + +These describe the mechanics and are **not** derivable from a trivkins INI: + +```ini +MODEL_CHAIN = Y X Z ; nesting order of the moving links (base -> tip) +MODEL_PARENT_Z = BASE ; branch: Z (quill/tool) hangs off the base, not X/Y +MODEL_INVERT = X Y ; work-carrying links move opposite the reported coord +``` + +The agent then serves each mesh at `GET /models/`, emits a device-level +`` for the base and a per-axis `` in each axis's +``, and emits `` (WORLD → MACHINE) plus a +`` chain (`parentIdRef`) so the twin nests transforms correctly. + +## Using the official cppagent instead of the embedded agent + +Put `shdr` in `TRANSPORT` and the agent acts as an SHDR adapter (default port +7878), streaming `|id|value` lines to an external cppagent. Configure that +cppagent with a `Devices.xml`, which the generated `/probe` document doubles as: + +```sh +mtconnect-agent --dump-probe example.ini > Devices.xml +``` + +The `dataItemId`s in the SHDR stream match the ids in that `Devices.xml`. + +## MQTT + +With `mqtt` in `TRANSPORT` and `python3-paho-mqtt` installed, the agent publishes +the standard (vendor-neutral) MTConnect MQTT topics: +`/Probe/` (retained), `/Current/`, +`/Sample/`, `/Asset//`. A consumer discovers +the whole device from the retained Probe topic. + +**Home Assistant** is not part of the core agent (its MQTT Discovery format is +HA-specific). An optional bridge, `contrib/mtconnect-ha-bridge`, reuses the +device model to publish HA discovery; configure it with arguments so no HA key +touches the machine INI: + +``` +loadusr -W mtconnect-ha-bridge --broker=[HA]BROKER --username=[HA]USER --password=[HA]PASSWORD +``` + +## Layout (installed) + +``` +src/hal/user_comps/mtconnect-agent.py entry point -> bin/mtconnect-agent +lib/python/mtc/ the agent package: + ini_reader.py INI access (linuxcnc.ini, with offline fallback) + kinematics.py build the kinematic model from the INI + observations.py shared DataItem registry (keeps probe and streams in sync) + device_model.py /probe MTConnectDevices document (+ CLI --dump-probe) + lcnc_source.py live status + tool table from linuxcnc.stat() + models.py solid-model config + served mesh registry (digital twin) + streams.py /current, /sample buffer + XML; /assets XML + agent.py transport-agnostic agent core (document builders) + http_agent.py embedded HTTP server + mqtt_agent.py optional MQTT publisher (vendor-neutral MTConnect binding) +share/linuxcnc/mtconnect/twin.html digital-twin viewer +share/linuxcnc/mtconnect/mtconnect-linuxcnc-1.xsd extension schema +configs/sim/axis/mtconnect/ this demo config + tests + contrib/mtconnect-ha-bridge opt-in Home Assistant MQTT-discovery bridge + contrib/ha.py HA discovery helper (used by the bridge) +``` + +## Testing + +```sh +. scripts/rip-environment # so mtc is importable and bin/ is on PATH +python3 test_mtc.py # offline suite (no running LinuxCNC required) +python3 validate_schemas.py # validate the documents against the MTConnect XSDs + # (needs the 'xmlschema' package) +``` + +`test_mtc.py` covers kinematics mapping, probe↔registry consistency (3- and +5-axis), the observation buffer + streams, assets, the live HTTP endpoints, and +(when `xmlschema` is available) schema validation of all four documents. + +## Custom HAL pins + +Expose any HAL pin/signal as a data item with `HAL_ITEM` lines (read via +`hal.get_value`, no HAL wiring): + +```ini +[MTCONNECT] +HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle +HAL_ITEM = pin=hm2_5i25.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS +``` + +Standard MTConnect SAMPLE types only (LOAD, TEMPERATURE, PRESSURE, VOLTAGE, +AMPERAGE, FREQUENCY, ANGLE, VELOCITY, TORQUE, …). `component=` hosts it under a +generic `Sensor` (default) or an existing component (spindle/controller/path/an +axis). Unsupported types are skipped with a warning. See `mtconnect-agent(1)`. + +## Status + +Complete and packaged for the build: the HTTP transport, HAL component and +schema-valid documents; the vendor-neutral MQTT binding plus the optional +Home Assistant bridge contrib; the opt-in offline digital twin (three.js from +the distribution's `libjs-three`); and the SHDR adapter for an external +cppagent. Realtime servo-rate data is explicitly out of scope. diff --git a/configs/sim/axis/mtconnect/contrib/ha.py b/configs/sim/axis/mtconnect/contrib/ha.py new file mode 100644 index 00000000000..e4a3374787c --- /dev/null +++ b/configs/sim/axis/mtconnect/contrib/ha.py @@ -0,0 +1,94 @@ +# 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 + +# Home Assistant MQTT Discovery helper for the mtconnect-ha-bridge contrib. +# +# Home Assistant's MQTT Discovery is an HA-specific convention (its topic layout, +# JSON payload schema and Jinja value_template are defined by Home Assistant, not +# an open standard), so it is NOT part of the core mtconnect-agent. This helper +# builds, for the optional bridge: +# * retained discovery configs under /sensor///config so +# HA auto-creates a Device with one sensor per value (no YAML needed), and +# * a small flat JSON state document the sensors read via value_json. + +import json + +_LIN_UNIT = {"MILLIMETER": "mm", "INCH": "in", "CENTIMETER": "cm"} + + +def _lin(config): + return _LIN_UNIT.get(config.linear_units, "mm") + + +def build_sensors(model, config): + """Curated, demo-friendly sensor set derived from the machine model.""" + lin = _lin(config) + sensors = [ + {"key": "execution", "name": "Execution", "icon": "mdi:cog-play"}, + {"key": "mode", "name": "Mode", "icon": "mdi:tune"}, + {"key": "estop", "name": "E-Stop", "icon": "mdi:alert-octagon"}, + {"key": "program", "name": "Program", "icon": "mdi:file-document-outline"}, + {"key": "toolnum", "name": "Tool", "icon": "mdi:screwdriver"}, + {"key": "pathfeed", "name": "Feed rate", "unit": lin + "/s", + "icon": "mdi:speedometer", "num": True}, + {"key": "spdl_speed", "name": "Spindle", "unit": "RPM", + "icon": "mdi:fan", "num": True}, + ] + for a in model.axes: + low = a.letter.lower() + icon = "mdi:axis-%s-arrow" % low if low in ("x", "y", "z") else "mdi:axis-arrow" + sensors.append({"key": "pos_%s" % low, "name": "%s position" % a.letter, + "unit": lin if a.kind == "LINEAR" else "°", + "icon": icon, "num": True}) + return sensors + + +def node_id(config): + return "".join(c if (c.isalnum() or c in "-_") else "_" for c in config.uuid) + + +def discovery_payload(sensor, config, state_topic, avail_topic): + payload = { + "name": sensor["name"], + "unique_id": "%s_%s" % (config.uuid, sensor["key"]), + "state_topic": state_topic, + "value_template": "{{ value_json.%s }}" % sensor["key"], + "availability_topic": avail_topic, + "device": { + "identifiers": [config.uuid], + "name": config.name, + "manufacturer": "LinuxCNC", + "model": "MTConnect", + }, + } + if sensor.get("icon"): + payload["icon"] = sensor["icon"] + if sensor.get("unit"): + payload["unit_of_measurement"] = sensor["unit"] + if sensor.get("num"): + payload["state_class"] = "measurement" + return payload + + +def state_json(values, sensors): + """Flat JSON of the curated keys; skip missing/UNAVAILABLE/structured.""" + out = {} + for s in sensors: + v = values.get(s["key"]) + if v is None or v == "UNAVAILABLE" or isinstance(v, (dict, list)): + continue + out[s["key"]] = v + return json.dumps(out) diff --git a/configs/sim/axis/mtconnect/contrib/mtconnect-ha-bridge b/configs/sim/axis/mtconnect/contrib/mtconnect-ha-bridge new file mode 100755 index 00000000000..b821039f15e --- /dev/null +++ b/configs/sim/axis/mtconnect/contrib/mtconnect-ha-bridge @@ -0,0 +1,160 @@ +#!/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 +# +# Home Assistant bridge for the LinuxCNC MTConnect agent (OPTIONAL CONTRIB). +# +# Publishes LinuxCNC machine state as Home Assistant MQTT Discovery, reusing the +# MTConnect agent's auto-generated device model. This is deliberately NOT part +# of the core mtconnect-agent: Home Assistant's discovery format is HA-specific, +# so it lives here as an opt-in. The core agent publishes only the vendor-neutral +# MTConnect MQTT binding. +# +# All Home Assistant / broker settings are passed as arguments, so no HA-specific +# key is needed in the machine INI. Load it from a HAL file alongside the agent, +# wiring the broker details from any INI section you like: +# +# loadusr -W mtconnect-ha-bridge \ +# --broker=[HA]BROKER --username=[HA]USER --password=[HA]PASSWORD +# +# The machine INI comes from $INI_FILE_NAME (only the device model is read from +# it; nothing HA-specific). + +import argparse +import json +import os +import signal +import sys +import time + +# Local HA helper (ha.py in this directory) + the installed mtc package. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_EMC2_HOME = os.environ.get("EMC2_HOME") +if _EMC2_HOME: + sys.path.insert(0, os.path.join(_EMC2_HOME, "lib", "python")) + +import ha as ha_mod # noqa: E402 +from mtc.agent import AgentState # noqa: E402 + + +def make_hal_pins(): + """Minimal HAL component so 'loadusr -W' can wait for us; else None.""" + try: + import hal + except ImportError: + return None + comp = hal.component("mtconnect-ha-bridge") + comp.newpin("enable", hal.HAL_BIT, hal.HAL_IN) + comp["enable"] = True + comp.newpin("connected", hal.HAL_BIT, hal.HAL_OUT) + comp.ready() + return comp + + +def main(): + p = argparse.ArgumentParser(description="Home Assistant bridge for the " + "LinuxCNC MTConnect agent") + p.add_argument("ini", nargs="?", default=os.environ.get("INI_FILE_NAME"), + help="LinuxCNC INI file (default: $INI_FILE_NAME)") + p.add_argument("--broker", default="localhost") + p.add_argument("--port", type=int, default=1883) + p.add_argument("--username") + p.add_argument("--password") + p.add_argument("--ha-prefix", default="homeassistant", + help="Home Assistant discovery prefix (default homeassistant)") + p.add_argument("--mqtt-prefix", default="MTConnect", + help="namespace for the bridge state/availability topics") + p.add_argument("--sample-hz", type=float, default=2.0) + args = p.parse_args() + if not args.ini: + p.error("no INI file (set INI_FILE_NAME or pass one)") + + try: + import paho.mqtt.client as mqtt + except ModuleNotFoundError: + print("error: Missing Python module paho.mqtt " + "(Debian: 'sudo apt install python3-paho-mqtt').") + return 2 + + state = AgentState(args.ini) + sensors = ha_mod.build_sensors(state.model, state.config) + uuid = state.config.uuid + prefix = args.mqtt_prefix.rstrip("/") + state_topic = "%s/ha/%s/state" % (prefix, uuid) + avail_topic = "%s/ha/%s/availability" % (prefix, uuid) + node = ha_mod.node_id(state.config) + + comp = make_hal_pins() + stop = {"v": False} + signal.signal(signal.SIGTERM, lambda *a: stop.__setitem__("v", True)) + signal.signal(signal.SIGINT, lambda *a: stop.__setitem__("v", True)) + + def on_connect(client, userdata, flags, rc, *a): + if comp is not None: + comp["connected"] = (rc == 0) + if rc == 0: + for s in sensors: + topic = "%s/sensor/%s/%s/config" % (args.ha_prefix, node, s["key"]) + payload = ha_mod.discovery_payload(s, state.config, + state_topic, avail_topic) + client.publish(topic, json.dumps(payload), retain=True) + client.publish(avail_topic, "online", retain=True) + print("info: HA discovery published under %s/sensor/%s/*" + % (args.ha_prefix, node)) + else: + print("error: MQTT connect failed (rc=%s)" % rc) + + def on_disconnect(client, userdata, rc, *a): + if comp is not None: + comp["connected"] = False + + try: + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, + client_id="linuxcnc-mtconnect-ha") + except AttributeError: + client = mqtt.Client(client_id="linuxcnc-mtconnect-ha") + if args.username: + client.username_pw_set(args.username, args.password) + client.will_set(avail_topic, "offline", retain=True) + client.on_connect = on_connect + client.on_disconnect = on_disconnect + client.connect_async(args.broker, args.port, keepalive=60) + client.loop_start() + print("info: mtconnect-ha-bridge -> %s:%d (HA prefix '%s')" + % (args.broker, args.port, args.ha_prefix)) + + try: + while not stop["v"]: + if comp is not None and not comp["enable"]: + time.sleep(0.2) + continue + state.poll_once() + client.publish(state_topic, + ha_mod.state_json(state.latest_values(), sensors), + retain=True) + time.sleep(1.0 / max(args.sample_hz, 0.1)) + except KeyboardInterrupt: + pass + finally: + client.publish(avail_topic, "offline", retain=True) + client.loop_stop() + client.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/configs/sim/axis/mtconnect/example.ini b/configs/sim/axis/mtconnect/example.ini new file mode 100644 index 00000000000..98c6fb1db2d --- /dev/null +++ b/configs/sim/axis/mtconnect/example.ini @@ -0,0 +1,201 @@ +# Simulated 3-axis mill demonstrating first-class MTConnect support. +# +# This is the standard sim/axis machine plus an [MTCONNECT] section and an +# [APPLICATIONS] entry that launches the MTConnect agent after the GUI starts. +# The core HAL files (core_sim.hal, ...) are found in the system HALLIB; only +# cooling.hal is local to the sibling sim/axis directory. +# +# Run: linuxcnc example.ini +# Then: curl http://localhost:5000/probe + +[EMC] +VERSION = 1.1 +MACHINE = LinuxCNC-MTConnect-Demo +DEBUG = 0 + +[DISPLAY] +DISPLAY = axis +CYCLE_TIME = 0.100 +POSITION_OFFSET = RELATIVE +POSITION_FEEDBACK = ACTUAL +MAX_FEED_OVERRIDE = 1.2 +MAX_SPINDLE_OVERRIDE = 1.0 +MAX_LINEAR_VELOCITY = 5 +DEFAULT_LINEAR_VELOCITY = .25 +DEFAULT_SPINDLE_SPEED = 200 +PROGRAM_PREFIX = ../../../nc_files/ +INTRO_GRAPHIC = linuxcnc.gif +INTRO_TIME = 5 +TOOL_EDITOR = tooledit + +# MTConnect feature ----------------------------------------------------------- +# A few lines enable the whole feature. The device model (/probe) is generated +# automatically from the sections below. +[MTCONNECT] +ENABLE = 1 +DEVICE_NAME = mtconnect_demo +UUID = linuxcnc-demo-0001 +HTTP_PORT = 5000 +# HTTP_BIND: interface to serve on. Default 127.0.0.1 (loopback only); set to +# 0.0.0.0 to expose the agent on the LAN. (INI values take no inline comments.) +# HTTP_BIND = 127.0.0.1 +# TRANSPORT: comma-separated list of http, mqtt, shdr (legacy "both" = http,mqtt) +TRANSPORT = http +SAMPLE_HZ = 10 +# MQTT_BROKER = localhost +# MQTT_PORT = 1883 +# MQTT_PREFIX = MTConnect + +# Custom HAL pins as MTConnect data items. Each HAL_ITEM reads a HAL pin/signal +# (via hal.get_value) into a standard MTConnect SAMPLE data item. component= +# names the host (default: a generic Sensor; or spindle/controller/path/an axis). +# Only standard MTConnect SAMPLE types are accepted (e.g. LOAD, TEMPERATURE, +# PRESSURE, VOLTAGE, AMPERAGE, FREQUENCY, ANGLE, VELOCITY, TORQUE, ...). +# HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle +# HAL_ITEM = pin=hm2_5i25.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS + +# Digital twin. Off by default; set ENABLE_TWIN = 1 to serve the browser viewer +# at /twin. Its 3D rendering uses the distribution's libjs-three package (a +# Debian Recommends), served by the agent from /three/ -- nothing is fetched +# from the internet. MODEL_AUTO generates simple placeholder box geometry for the +# base and each axis from the travel limits, so the twin works with no mesh +# files. To use real geometry instead, drop STL/OBJ/glTF files in models/ and +# set MODEL_BASE / MODEL_X / MODEL_Y / MODEL_Z. Meshes must be authored in +# millimetres (MTConnect's canonical unit; SolidModel carries no per-mesh unit). +ENABLE_TWIN = 1 +MODEL_AUTO = 1 +# Nesting order of the moving links. +MODEL_CHAIN = Y X Z +# Branched topology: the quill/tool (Z) hangs off the base, not the table. +MODEL_PARENT_Z = BASE +# Table/saddle physically move opposite the reported (tool-relative) coordinate. +MODEL_INVERT = X Y + +[TASK] +TASK = milltask +CYCLE_TIME = 0.001 + +[RS274NGC] +PARAMETER_FILE = sim.var + +[EMCMOT] +EMCMOT = motmod +COMM_TIMEOUT = 1.0 +BASE_PERIOD = 0 +SERVO_PERIOD = 1000000 + +[EMCIO] +TOOL_TABLE = ../sim.tbl +TOOL_CHANGE_POSITION = 0 0 0 +TOOL_CHANGE_QUILL_UP = 1 + +[HAL] +HALFILE = core_sim.hal +HALFILE = sim_spindle_encoder.hal +HALFILE = axis_manualtoolchange.hal +HALFILE = simulated_home.hal +HALFILE = check_xyz_constraints.hal +HALFILE = ../cooling.hal +# Load the MTConnect agent as a userspace HAL component (see mtconnect.hal). +HALFILE = mtconnect.hal +HALUI = halui + +[TRAJ] +COORDINATES = X Y Z +LINEAR_UNITS = inch +ANGULAR_UNITS = degree +MAX_LINEAR_VELOCITY = 4 +DEFAULT_LINEAR_ACCELERATION = 100 +MAX_LINEAR_ACCELERATION = 100 +POSITION_FILE = position.txt + +[KINS] +KINEMATICS = trivkins +JOINTS = 3 + +# Spindle 0 usable speed band (RPM) for a small VFD-driven knee mill. The agent +# maps the forward limits into /probe as both a spindle +# (Maximum/Minimum) under the Rotary "S" +# component and on the commanded-velocity DataItem. +[SPINDLE_0] +MIN_FORWARD_VELOCITY = 100 +MAX_FORWARD_VELOCITY = 6000 +# Reverse band + step size: not read by the agent, included for a realistic +# machine model (MAX_REVERSE defaults to MAX_FORWARD if omitted). +MAX_REVERSE_VELOCITY = 6000 +INCREMENT = 100 + +[AXIS_X] +MAX_VELOCITY = 4 +MAX_ACCELERATION = 100.0 +MIN_LIMIT = -10.0 +MAX_LIMIT = 10.0 + +[AXIS_Y] +MAX_VELOCITY = 4 +MAX_ACCELERATION = 100.0 +MIN_LIMIT = -10.0 +MAX_LIMIT = 10.0 + +[AXIS_Z] +MAX_VELOCITY = 4 +MAX_ACCELERATION = 100.0 +MIN_LIMIT = -8.0 +MAX_LIMIT = 0.12 + +[JOINT_0] +TYPE = LINEAR +HOME = 0.000 +MAX_VELOCITY = 5 +MAX_ACCELERATION = 50.0 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +FERROR = 0.050 +MIN_FERROR = 0.010 +MIN_LIMIT = -10.0 +MAX_LIMIT = 10.0 +HOME_OFFSET = 0.0 +HOME_SEARCH_VEL = 20.0 +HOME_LATCH_VEL = 20.0 +HOME_USE_INDEX = NO +HOME_IGNORE_LIMITS = NO +HOME_SEQUENCE = 1 + +[JOINT_1] +TYPE = LINEAR +HOME = 0.000 +MAX_VELOCITY = 5 +MAX_ACCELERATION = 50.0 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +FERROR = 0.050 +MIN_FERROR = 0.010 +MIN_LIMIT = -10.0 +MAX_LIMIT = 10.0 +HOME_OFFSET = 0.0 +HOME_SEARCH_VEL = 20.0 +HOME_LATCH_VEL = 20.0 +HOME_USE_INDEX = NO +HOME_IGNORE_LIMITS = NO +HOME_SEQUENCE = 1 + +[JOINT_2] +TYPE = LINEAR +HOME = 0.0 +MAX_VELOCITY = 5 +MAX_ACCELERATION = 50.0 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +MIN_LIMIT = -8.0 +MAX_LIMIT = 0.12 +FERROR = 0.050 +MIN_FERROR = 0.010 +HOME_OFFSET = 1.0 +HOME_SEARCH_VEL = 20.0 +HOME_LATCH_VEL = 20.0 +HOME_USE_INDEX = NO +HOME_IGNORE_LIMITS = NO +HOME_SEQUENCE = 0 diff --git a/configs/sim/axis/mtconnect/mtconnect.hal b/configs/sim/axis/mtconnect/mtconnect.hal new file mode 100644 index 00000000000..4a8050393af --- /dev/null +++ b/configs/sim/axis/mtconnect/mtconnect.hal @@ -0,0 +1,16 @@ +# Load the MTConnect agent as a userspace HAL component. +# +# -W waits until the component ("mtconnect-agent") is ready before HAL +# processing continues, so its pins exist for any links below. The machine INI +# is taken from $INI_FILE_NAME (set by LinuxCNC), so no argument is needed. +# +# Once loaded the component exposes: +# in : mtconnect-agent.enable (bit) gate polling/serving +# mtconnect-agent.sample-hz (u32) override the INI SAMPLE_HZ +# out: mtconnect-agent.active (bit) polling + serving +# mtconnect-agent.connected (bit) MQTT broker link up +# mtconnect-agent.heartbeat (u32) increments each poll +loadusr -W mtconnect-agent + +# Example: drive a panel lamp from agent state (uncomment + wire to your HAL): +# net mtc-up mtconnect-agent.active => some-lamp.in diff --git a/configs/sim/axis/mtconnect/test_mtc.py b/configs/sim/axis/mtconnect/test_mtc.py new file mode 100644 index 00000000000..4b15f3f5f60 --- /dev/null +++ b/configs/sim/axis/mtconnect/test_mtc.py @@ -0,0 +1,639 @@ +#!/usr/bin/env python3 +# Offline tests for the MTConnect agent prototype. +# +# Exercises the probe generator, the probe<->registry consistency, the +# observation buffer + streams, and the asset builder without a running +# LinuxCNC. Run: python3 test_mtc.py +import os +import socket +import subprocess +import sys +import time +import urllib.request +import xml.etree.ElementTree as ET + +# The mtc package installs to $EMC2_HOME/lib/python; make it importable when this +# is run from the config directory (rip-environment already puts it on the path). +_EMC2_HOME = os.environ.get("EMC2_HOME") +if _EMC2_HOME: + sys.path.insert(0, os.path.join(_EMC2_HOME, "lib", "python")) + +from mtc.ini_reader import IniReader +from mtc import kinematics as kin +from mtc.device_model import DeviceConfig, probe_xml, MTC_NS, EXT_NS +from mtc.observations import build_dataitems +from mtc.streams import (ObservationBuffer, current_xml, sample_xml, assets_xml, + STREAMS_NS) +from mtc.lcnc_source import ToolAsset, LcncSource +from mtc.agent import AgentState +from mtc.http_agent import HttpAgent +from mtc.shdr_agent import ShdrAgent + +# Resolve config paths relative to this file, not the current directory, so the +# suite runs from any CWD (e.g. when wired into tests/ and driven by runtests). +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _cfg(relpath): + return os.path.normpath(os.path.join(_HERE, relpath)) + + +CONFIGS = { + "3axis": _cfg("../axis.ini"), + "5axis": _cfg("../vismach/5axis/table-rotary-tilting/xyzac-trt.ini"), +} +TS = "2026-07-01T12:00:00.000Z" + + +def load(path): + ini = IniReader(path) + model = kin.build_model(ini) + config = DeviceConfig.from_ini(ini) + return ini, model, config + + +def test_kinematics(): + _, model, _ = load(CONFIGS["5axis"]) + assert model.kins_module == "xyzac-trt-kins", model.kins_module + assert model.coordinates == "XYZAC", model.coordinates + assert model.joints_count == 5 + jmap = model.joint_axis_map() + assert jmap == {0: "X", 1: "Y", 2: "Z", 3: "A", 4: "C"}, jmap + kinds = {a.letter: a.kind for a in model.axes} + assert kinds["A"] == "ANGULAR" and kinds["X"] == "LINEAR", kinds + # A rotates about X, C about Z + vecs = {a.letter: a.vector for a in model.axes} + assert vecs["A"] == (1.0, 0.0, 0.0) and vecs["C"] == (0.0, 0.0, 1.0), vecs + print("ok kinematics (5-axis XYZAC mapping + rotary vectors)") + + +def test_probe_registry_consistency(): + for label, path in CONFIGS.items(): + _, model, config = load(path) + xml = probe_xml(model, config, creation_time=TS) + root = ET.fromstring(xml) + probe_ids = {} + for di in root.iter("{%s}DataItem" % MTC_NS): + probe_ids[di.get("id")] = (di.get("category"), di.get("type"), + di.get("subType")) + for d in build_dataitems(model, config): + assert d.id in probe_ids, "%s: %s missing from probe" % (label, d.id) + cat, typ, sub = probe_ids[d.id] + expected_type = ("x:" + d.type) if d.ext else d.type + assert (cat, typ, sub) == (d.category, expected_type, d.subType), \ + "%s: %s attrs %s != %s" % (label, d.id, (cat, typ, sub), + (d.category, d.type, d.subType)) + # kinematics extension present + kext = root.find(".//{%s}Kinematics" % EXT_NS) + assert kext is not None, "%s: no kinematics extension" % label + assert kext.get("module") == model.kins_module + print("ok probe/registry consistency (%s, %d DataItems)" + % (label, len(probe_ids))) + + +def test_buffer_and_streams(): + _, model, config = load(CONFIGS["3axis"]) + dataitems = build_dataitems(model, config) + buf = ObservationBuffer(dataitems) + + n = buf.ingest({"execution": "READY", "pos_x": 1.0, "pos_y": 2.0}, TS) + assert n == 3, n + assert buf.ingest({"pos_x": 1.0}, TS) == 0, "unchanged value must not record" + assert buf.ingest({"pos_x": 5.0}, TS) == 1 + assert buf.last_sequence == 4, buf.last_sequence + + # /current: every DataItem present, unset ones UNAVAILABLE + cur = ET.fromstring(current_xml(buf, config, TS)) + observed = {e.get("dataItemId"): e.text + for e in cur.iter() if e.get("dataItemId")} + assert observed["pos_x"] == "5.0", observed.get("pos_x") + assert observed["pos_z"] == "UNAVAILABLE", observed.get("pos_z") + assert observed["execution"] == "READY" + + # /sample: sequence range 1..2 returns exactly the first two observations + smp = ET.fromstring(sample_xml(buf, config, TS, from_seq=1, count=2)) + seqs = sorted(int(e.get("sequence")) for e in smp.iter() + if e.get("sequence")) + assert seqs == [1, 2], seqs + # ComponentStream grouping + Samples/Events wrappers exist + assert cur.find(".//{%s}ComponentStream" % STREAMS_NS) is not None + print("ok buffer + current/sample streams") + + +def test_work_offset_table(): + _, model, config = load(CONFIGS["3axis"]) + buf = ObservationBuffer(build_dataitems(model, config)) + buf.ingest({"workoffset": {"G55": {"X": 1.5, "Y": -2.0, "Z": 0.0}}}, TS) + cur = ET.fromstring(current_xml(buf, config, TS)) + # WORK_OFFSET is a standard EVENT type; TABLE representation streams as + # (not ). + wo = cur.find(".//{%s}WorkOffsetTable" % STREAMS_NS) + assert wo is not None and wo.get("count") == "1", wo + entry = wo.find("{%s}Entry" % STREAMS_NS) + assert entry.get("key") == "G55", entry.attrib + cells = {c.get("key"): c.text for c in entry.findall("{%s}Cell" % STREAMS_NS)} + assert cells == {"X": "1.5", "Y": "-2", "Z": "0"}, cells + # probe advertises it as a standard WORK_OFFSET EVENT with TABLE representation + probe = ET.fromstring(probe_xml(model, config)) + di = [d for d in probe.iter("{%s}DataItem" % MTC_NS) + if d.get("id") == "workoffset"][0] + assert di.get("representation") == "TABLE" and di.get("type") == "WORK_OFFSET" + assert di.get("category") == "EVENT", di.get("category") + print("ok work offset TABLE (WorkOffsetTable Entry/Cell + probe representation)") + + +def test_tool_offset_and_rotation(): + _, model, config = load(CONFIGS["3axis"]) + buf = ObservationBuffer(build_dataitems(model, config)) + buf.ingest({"tooloffset": {"T3": {"X": 0.0, "Y": 0.0, "Z": 2.5}}, + "xyrotation": 30.0}, TS) + cur = ET.fromstring(current_xml(buf, config, TS)) + + # TOOL_OFFSET is a standard EVENT type -> in STREAMS_NS. + to = cur.find(".//{%s}ToolOffsetTable" % STREAMS_NS) + assert to is not None, "no ToolOffsetTable in stream" + entry = to.find("{%s}Entry" % STREAMS_NS) + assert entry.get("key") == "T3" + zc = [c for c in entry.findall("{%s}Cell" % STREAMS_NS) if c.get("key") == "Z"][0] + assert zc.text == "2.5", zc.text + # xyrotation has no standard MTConnect type, so it stays a LinuxCNC extension. + rot = cur.find(".//{%s}CoordinateRotation" % EXT_NS) + assert rot is not None and rot.text == "30.0", rot + + probe = ET.fromstring(probe_xml(model, config)) + types = {d.get("id"): d.get("type") for d in probe.iter("{%s}DataItem" % MTC_NS)} + assert types["tooloffset"] == "TOOL_OFFSET", types.get("tooloffset") # standard now + assert types["xyrotation"] == "x:COORDINATE_ROTATION", types.get("xyrotation") + print("ok tool offset (standard ToolOffsetTable) + XY rotation (x: extension)") + + +def test_assets(): + _, _, config = load(CONFIGS["3axis"]) + tools = [ + ToolAsset(tool_no=1, pocket=1, in_spindle=True, diameter=6.35, length_z=50.0, + comment="1/4 flat endmill "), # trailing space should be trimmed + ToolAsset(tool_no=2, pocket=5, in_spindle=False, diameter=3.0), + ] + root = ET.fromstring(assets_xml(tools, config, TS)) + ns = "{urn:mtconnect.org:MTConnectAssets:1.7}" + cts = root.findall(".//%sCuttingTool" % ns) + assert len(cts) == 2, len(cts) + assert cts[0].get("assetId") == "tool-1" + assert cts[0].get("serialNumber") == "tool-1", cts[0].attrib # required attr + loc = cts[0].find(".//%sLocation" % ns) + assert loc.get("type") == "SPINDLE" and loc.text == "1", loc.attrib + desc = cts[0].find("%sDescription" % ns) + assert desc is not None and desc.text == "1/4 flat endmill", repr(desc.text) + assert cts[1].find("%sDescription" % ns) is None # empty comment -> no element + # length offset is FunctionalLength (LF), not BodyLengthMax + lf = cts[0].find(".//%sFunctionalLength" % ns) + assert lf is not None and lf.get("code") == "LF" and lf.text == "50", ET.tostring(cts[0]) + assert cts[0].find(".//%sBodyLengthMax" % ns) is None + # diameter is a CuttingItem measurement, not a tool-level one + dia = cts[0].find(".//%sCuttingItems/%sCuttingItem/%sMeasurements/%sCuttingDiameter" + % (ns, ns, ns, ns)) + assert dia is not None and dia.text == "6.35" and dia.get("code") == "DC", ET.tostring(cts[0]) + print("ok assets (serialNumber + FunctionalLength + CuttingItem diameter + comment)") + + +def test_probe_standard_blocks(): + # spindle Specifications, Coolant system, and the kinematics extension living + # inside the (schema-valid) Description host. + _, model, config = load(_cfg("example.ini")) + root = ET.fromstring(probe_xml(model, config, creation_time=TS)) + ns = "{%s}" % MTC_NS + spec = root.find(".//%sRotary[@id='spindle']//%sSpecification" % (ns, ns)) + assert spec is not None and spec.get("type") == "ROTARY_VELOCITY", "no spindle spec" + assert spec.find("%sMaximum" % ns).text == "6000" + cool = root.find(".//%sCoolant" % ns) + assert cool is not None, "no Coolant component" + assert cool.find(".//%sDataItem[@id='coolant_flood']" % ns).get("type") == "x:FLOOD" + # x:Kinematics is nested in a Description (the lax xs:any extension point) + kin_ext = root.find(".//%sDescription/{%s}Kinematics" % (ns, EXT_NS)) + assert kin_ext is not None, "x:Kinematics not under Description" + assert kin_ext.get("nativeLinearUnits") == "INCH" + # Agent device is present (schema-required alongside the machine Device) + assert root.find(".//%sAgent" % ns) is not None, "no Agent device" + print("ok probe standard blocks (spindle Spec, Coolant, kinematics host, Agent)") + + +def test_spindle_constraints(): + ns = "{%s}" % MTC_NS + + def spdl_cmd(path): + _, model, config = load(path) + root = ET.fromstring(probe_xml(model, config, creation_time=TS)) + for di in root.iter("%sDataItem" % ns): + if di.get("id") == "spdl_speed_cmd": + return di + raise AssertionError("spdl_speed_cmd missing") + + # example.ini declares [SPINDLE_0] MIN/MAX_FORWARD_VELOCITY -> Constraints + di = spdl_cmd(_cfg("example.ini")) + con = di.find("%sConstraints" % ns) + assert con is not None, "no Constraints on spdl_speed_cmd" + assert con.find("%sMinimum" % ns).text == "100", ET.tostring(di) + assert con.find("%sMaximum" % ns).text == "6000", ET.tostring(di) + + # a config without spindle velocity limits emits no Constraints (no bogus + # ~2.1e9 default range fabricated). + assert spdl_cmd(CONFIGS["3axis"]).find("%sConstraints" % ns) is None + print("ok spindle speed range Constraints (from [SPINDLE_0], omitted when absent)") + + +def test_schema_validation(): + # Gated: needs the xmlschema package (XSD 1.1) and the official 1.7 XSDs + # (local dir via $MTC_XSD_DIR, else fetched). Skips cleanly otherwise so + # the core suite stays dependency- and network-free. + import os + try: + import io + import xmlschema + except ImportError: + print("skip schema validation (xmlschema not installed)") + return + from mtc.agent import asset_dir + xsd_dir = os.environ.get("MTC_XSD_DIR") + base = (lambda n: os.path.join(xsd_dir, n)) if xsd_dir else \ + (lambda n: "http://schemas.mtconnect.org/schemas/" + n) + ext = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd") + state = AgentState(_cfg("example.ini")) + state.poll_once() + checks = [("MTConnectDevices_1.7.xsd", state.probe_document()), + ("MTConnectAssets_1.7.xsd", state.assets_document()), + (ext, state.current_document())] + try: + for xsd, doc in checks: + loc = xsd if xsd == ext else base(xsd) + schema = xmlschema.XMLSchema11(loc, validation="skip") + errs = list(schema.iter_errors(io.StringIO(doc))) + assert not errs, "%s: %s" % (xsd, (errs[0].reason or errs[0].message)) + except (OSError, xmlschema.XMLSchemaException) as exc: + if xsd_dir: + raise + print("skip schema validation (schemas unreachable: %s)" % exc) + return + print("ok schema validation (probe/assets base, current base+extension)") + + +def test_source_offline(): + _, model, config = load(CONFIGS["3axis"]) + src = LcncSource(model, config) + # No linuxcnc extension / no running instance -> graceful empty results. + if not src.available(): + assert src.sample_values() == {} + assert src.tool_assets() == [] + print("ok lcnc_source offline (graceful, no stat)") + else: + print("ok lcnc_source live (stat available)") + + +def test_http_endpoints(): + state = AgentState(CONFIGS["3axis"]) + state.buffer.ingest({"execution": "ACTIVE", "pos_x": 3.5}, TS) + state._assets = [ToolAsset(tool_no=7, pocket=7, in_spindle=True, diameter=10.0)] + + http = HttpAgent(state, host="127.0.0.1", port=0) + http.start() + try: + base = "http://127.0.0.1:%d" % http.port + + def get(path): + with urllib.request.urlopen(base + path, timeout=5) as r: + return r.status, r.read().decode() + + st, body = get("/probe") + assert st == 200 and "MTConnectDevices" in body, st + st, body = get("/current") + assert st == 200 and "ACTIVE" in body, body[:200] + st, body = get("/sample?from=1&count=10") + assert st == 200 and 'sequence="1"' in body, body[:200] + st, body = get("/assets") + assert st == 200 and "tool-7" in body, body[:200] + + # Bad route and out-of-range sequence return MTConnectError docs. + try: + get("/nope") + assert False, "expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 and "MTConnectError" in e.read().decode() + try: + get("/sample?from=999999") + assert False, "expected 406" + except urllib.error.HTTPError as e: + assert e.code == 406 and "OUT_OF_RANGE" in e.read().decode() + print("ok embedded HTTP agent (probe/current/sample/assets + errors)") + finally: + http.stop() + + +def _is_iso_ts(field): + # e.g. 2026-07-28T12:00:00.000Z -- cheap structural check (no regex import). + return (len(field) >= 20 and field[4] == "-" and field[7] == "-" + and field[10] == "T" and field.endswith("Z")) + + +def test_shdr(): + # Offline SHDR adapter test: no LinuxCNC, values injected via _last_values + # (what AgentState.latest_values() returns), which is the poll snapshot the + # adapter diffs and streams. + state = AgentState(CONFIGS["3axis"]) + + shdr = ShdrAgent(state, port=0, host="127.0.0.1") + shdr.start() + client = None + try: + client = socket.create_connection(("127.0.0.1", shdr.port), timeout=2) + client.settimeout(2) + + # Wait until the server has registered the client (its handler thread ran + # _add_client) so the broadcast below is guaranteed to reach us. + deadline = time.time() + 2 + while time.time() < deadline: + with shdr._lock: + registered = len(shdr._clients) + if registered: + break + time.sleep(0.01) + assert registered == 1, registered + + buf = [b""] + + def recv_line(): + while b"\n" not in buf[0]: + chunk = client.recv(4096) + if not chunk: + raise AssertionError("connection closed before a full line") + buf[0] += chunk + line, _, rest = buf[0].partition(b"\n") + buf[0] = rest + return line.decode() + + # (a) ingest/poll some values, then stream the changes. A structured + # (TABLE) value must be skipped, never emitted as a malformed line. + state._last_values = {"execution": "ACTIVE", "pos_x": 3.5, + "workoffset": {"G54": {"X": 1.0}}} + shdr.publish_changes() + + line = recv_line() + fields = line.split("|") + assert len(fields) >= 3, "not a well-formed SHDR line: %r" % line + assert _is_iso_ts(fields[0]), "first field not ISO ts: %r" % fields[0] + ids = fields[1::2] + assert "execution" in ids and "pos_x" in ids, ids + assert "workoffset" not in line, "TABLE value must be skipped: %r" % line + + # unchanged values produce nothing; a real change is streamed + shdr.publish_changes() + state._last_values = {"execution": "READY", "pos_x": 3.5, + "workoffset": {"G54": {"X": 1.0}}} + shdr.publish_changes() + line2 = recv_line() + f2 = line2.split("|") + assert _is_iso_ts(f2[0]) and "execution" in f2[1::2], line2 + assert "READY" in line2, line2 + + # (b) heartbeat: '* PING' -> '* PONG 10000' + client.sendall(b"* PING\n") + pong = recv_line() + assert pong == "* PONG 10000", repr(pong) + + print("ok SHDR adapter (initial/changed ts|id|value lines, TABLE skipped, PING/PONG)") + finally: + if client is not None: + client.close() + shdr.stop() + + +def test_hal_items(): + import tempfile + from mtc.hal_items import HalSource + ini_text = ( + "[EMC]\nMACHINE = hal-item-demo\n" + "[TRAJ]\nCOORDINATES = X Y Z\nLINEAR_UNITS = inch\n" + "[KINS]\nKINEMATICS = trivkins\nJOINTS = 3\n" + "[AXIS_X]\nMIN_LIMIT = -10\nMAX_LIMIT = 10\n" + "[AXIS_Y]\nMIN_LIMIT = -6\nMAX_LIMIT = 6\n" + "[AXIS_Z]\nMIN_LIMIT = -8\nMAX_LIMIT = 0\n" + "[MTCONNECT]\n" + "HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle\n" + "HAL_ITEM = pin=hm2.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS\n" + "HAL_ITEM = pin=bad.one, id=bad_custom, type=WIDGET_COUNT\n" # rejected + "HAL_ITEM = pin=x, id=1nope, type=LOAD\n" # bad id, rejected + ) + d = tempfile.mkdtemp() + p = os.path.join(d, "hal.ini") + with open(p, "w") as fh: + fh.write(ini_text) + + ini = IniReader(p) + model = kin.build_model(ini) + config = DeviceConfig.from_ini(ini) + # only the two valid items survive parsing + assert [i.id for i in config.hal_items] == ["spindle_load", "board_temp"], \ + [i.id for i in config.hal_items] + + # they appear in the registry as SAMPLE items of the right type + reg = {d.id: d for d in build_dataitems(model, config)} + assert reg["spindle_load"].type == "LOAD" and reg["spindle_load"].category == "SAMPLE" + assert reg["spindle_load"].comp_id == "spindle" # targeted the spindle + assert reg["board_temp"].comp_id == "sensors" # default Sensor host + + # probe: generic item under Auxiliaries/Sensor, targeted item under the spindle + probe = ET.fromstring(probe_xml(model, config)) + ns = "{%s}" % MTC_NS + sensor = probe.find(".//%sAuxiliaries/%sComponents/%sSensor" % (ns, ns, ns)) + assert sensor is not None, "no Auxiliaries/Sensor" + sensor_ids = {di.get("id") for di in sensor.iter("%sDataItem" % ns)} + assert "board_temp" in sensor_ids and "spindle_load" not in sensor_ids, sensor_ids + sp = probe.find(".//%sRotary[@id='spindle']" % ns) + assert "spindle_load" in {di.get("id") for di in sp.iter("%sDataItem" % ns)} + + # offline HalSource (no hal extension) yields nothing, gracefully + assert HalSource(config.hal_items).sample_values() == {} + print("ok HAL items (parse/validate, placement, offline HalSource)") + + +def test_solid_models(): + import os + import tempfile + from mtc.models import MachineModels, MeshRef + + _, model, config = load(CONFIGS["3axis"]) + + # No models configured -> no SolidModel / Configuration emitted. + plain = ET.fromstring(probe_xml(model, config)) + assert plain.find(".//{%s}SolidModel" % MTC_NS) is None + + tmp = tempfile.mkdtemp() + path = os.path.join(tmp, "z_head.stl") + with open(path, "wb") as fh: + fh.write(b"solid dummy\nendsolid dummy\n") + ref = MeshRef("z_head.stl", path, "STL", "model/stl", True) + mm = MachineModels(units="MILLIMETER", base=ref, axis={"Z": ref}, + chain=["dev_base", "axis_x", "axis_y", "axis_z"], + parents={"axis_x": "dev_base", "axis_y": "axis_x", + "axis_z": "axis_y"}, + served={"z_head.stl": ref}) + + probe = ET.fromstring(probe_xml(model, config, models=mm)) + sms = probe.findall(".//{%s}SolidModel" % MTC_NS) + hrefs = {sm.get("href") for sm in sms} + assert "/models/z_head.stl" in hrefs, hrefs + assert probe.find(".//{%s}CoordinateSystem[@type='MACHINE']" % MTC_NS) is not None + # serial chain: Z link hangs off Y's motion + mz = [m for m in probe.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0] + assert mz.get("parentIdRef") == "motion_y", mz.attrib + + # branched: re-root Z at the base (knee-mill quill) -> no parentIdRef + mm.parents["axis_z"] = "dev_base" + probe2 = ET.fromstring(probe_xml(model, config, models=mm)) + mz2 = [m for m in probe2.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0] + assert mz2.get("parentIdRef") is None, mz2.attrib + + # inverted axis: Motion vector is negated (work-carrying link) + mm.invert = {"Z"} + probe3 = ET.fromstring(probe_xml(model, config, models=mm)) + mz3 = [m for m in probe3.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0] + assert mz3.find("{%s}Axis" % MTC_NS).text == "0 0 -1", mz3.find("{%s}Axis" % MTC_NS).text + + # /models route serves the file; unknown model -> 404 + state = AgentState(CONFIGS["3axis"]) + state.models = mm + http = HttpAgent(state, host="127.0.0.1", port=0) + http.start() + try: + with urllib.request.urlopen( + "http://127.0.0.1:%d/models/z_head.stl" % http.port, timeout=5) as r: + assert r.status == 200 and b"endsolid" in r.read() + try: + urllib.request.urlopen("http://127.0.0.1:%d/models/nope.stl" % http.port) + assert False, "expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 + print("ok solid models (SolidModel/CoordinateSystems/chain + /models route)") + finally: + http.stop() + + +def test_auto_geometry(): + import os + import tempfile + from mtc.models import build_models + + ini_text = ( + "[EMC]\nMACHINE = auto-demo\n" + "[TRAJ]\nCOORDINATES = X Y Z\nLINEAR_UNITS = inch\n" + "[KINS]\nKINEMATICS = trivkins\nJOINTS = 3\n" + "[AXIS_X]\nMIN_LIMIT = -10\nMAX_LIMIT = 10\n" + "[AXIS_Y]\nMIN_LIMIT = -6\nMAX_LIMIT = 6\n" + "[AXIS_Z]\nMIN_LIMIT = -8\nMAX_LIMIT = 0\n" + "[MTCONNECT]\nMODEL_AUTO = 1\nMODEL_PARENT_Z = BASE\nMODEL_INVERT = X Y\n" + ) + d = tempfile.mkdtemp() + p = os.path.join(d, "auto.ini") + with open(p, "w") as fh: + fh.write(ini_text) + + ini = IniReader(p) + model = kin.build_model(ini) + config = DeviceConfig.from_ini(ini) + mm = build_models(ini, model, config) + assert mm.enabled() and mm.base is not None + assert set(mm.axis) == {"X", "Y", "Z"}, set(mm.axis) + # Geometry is served in the MTConnect canonical unit (millimetre); the inch + # travel limits are scaled up by 25.4 in the generated boxes. + assert mm.units == "MILLIMETER", mm.units + assert all(v.startswith("solid") for v in mm.generated.values()) + # X box half-width tracks the 20" span * 25.4 -> ~500 mm scale, not ~20. + xs = [float(tok) for line in mm.generated["axis_x.stl"].splitlines() + if line.strip().startswith("vertex") for tok in [line.split()[1]]] + assert max(xs) > 100, max(xs) # would be < 20 if still in inches + + probe = ET.fromstring(probe_xml(model, config, models=mm)) + hrefs = {sm.get("href") for sm in probe.findall(".//{%s}SolidModel" % MTC_NS)} + assert {"/models/dev_base.stl", "/models/axis_x.stl", "/models/axis_z.stl"} <= hrefs, hrefs + + # the agent serves the generated STL bytes (no file on disk) + state = AgentState(p) + http = HttpAgent(state, host="127.0.0.1", port=0) + http.start() + try: + with urllib.request.urlopen( + "http://127.0.0.1:%d/models/axis_x.stl" % http.port, timeout=5) as r: + assert r.status == 200 and b"endsolid" in r.read() + print("ok auto geometry (generated boxes served + probe SolidModels)") + finally: + http.stop() + + +def test_ha_discovery(): + # ha.py is an optional contrib (Home Assistant is not part of the core agent), + # so skip cleanly if the contrib is not present. + import json + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "contrib")) + try: + import ha + except ImportError: + print("skip HA discovery (contrib not installed)") + return + + _, model, config = load(CONFIGS["3axis"]) + sensors = ha.build_sensors(model, config) + keys = {s["key"] for s in sensors} + assert {"execution", "spdl_speed", "pos_x", "pos_y", "pos_z"} <= keys, keys + + px = [s for s in sensors if s["key"] == "pos_x"][0] + d = ha.discovery_payload(px, config, "st/topic", "av/topic") + assert d["state_topic"] == "st/topic" + assert d["value_template"] == "{{ value_json.pos_x }}" + assert d["unit_of_measurement"] == "mm" # canonical (values are mm) + assert d["device"]["identifiers"] == [config.uuid] + assert d["unique_id"] == "%s_pos_x" % config.uuid + + state = json.loads(ha.state_json( + {"execution": "ACTIVE", "pos_x": 1.5, "pos_z": "UNAVAILABLE", + "workoffset": {"G54": {}}}, sensors)) + assert state["execution"] == "ACTIVE" and state["pos_x"] == 1.5 + assert "pos_z" not in state # UNAVAILABLE skipped + assert "workoffset" not in state # structured value skipped + print("ok HA discovery (sensors + discovery payload + state JSON)") + + +def test_entry_dump_probe(): + # The installed entry point (bin/mtconnect-agent, on PATH via rip-environment). + exe = os.path.join(_EMC2_HOME, "bin", "mtconnect-agent") if _EMC2_HOME \ + else "mtconnect-agent" + if not (os.path.isfile(exe) or _EMC2_HOME is None): + print("skip entry --dump-probe (mtconnect-agent not built/installed)") + return + out = subprocess.check_output([exe, "--dump-probe", CONFIGS["5axis"]], text=True) + assert "xyzac-trt-kins" in out and "MTConnectDevices" in out + print("ok entry --dump-probe") + + +def main(): + test_kinematics() + test_probe_registry_consistency() + test_buffer_and_streams() + test_work_offset_table() + test_tool_offset_and_rotation() + test_assets() + test_probe_standard_blocks() + test_spindle_constraints() + test_schema_validation() + test_source_offline() + test_http_endpoints() + test_shdr() + test_hal_items() + test_solid_models() + test_auto_geometry() + test_ha_discovery() + test_entry_dump_probe() + print("\nALL TESTS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/configs/sim/axis/mtconnect/validate_schemas.py b/configs/sim/axis/mtconnect/validate_schemas.py new file mode 100644 index 00000000000..9aa2ddada53 --- /dev/null +++ b/configs/sim/axis/mtconnect/validate_schemas.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Developer helper: validate the four MTConnect documents this agent produces +# against the official MTConnect 1.7 XSDs (plus the LinuxCNC extension schema for +# the streaming endpoints). +# +# Requires the `xmlschema` package (XSD 1.1; libxml2/xmllint cannot parse the +# 1.7 schema, which uses XSD-1.1 constructs). The official schemas are fetched +# from schemas.mtconnect.org unless a local directory is given via $MTC_XSD_DIR. +# +# pip install xmlschema +# ./validate_schemas.py [INI] # default: example.ini +# +# Exit status is non-zero if any document fails, so it can gate CI. + +import io +import os +import sys + +BASE = "http://schemas.mtconnect.org/schemas" + + +def _xsd(name): + d = os.environ.get("MTC_XSD_DIR") + return os.path.join(d, name) if d else "%s/%s" % (BASE, name) + + +def _bootstrap_mtc(): + # mtc installs to $EMC2_HOME/lib/python (on PYTHONPATH under rip-environment); + # add it explicitly so this runs without the full environment. + home = os.environ.get("EMC2_HOME") + if home: + sys.path.insert(0, os.path.join(home, "lib", "python")) + + +def main(argv): + ini_path = argv[1] if len(argv) > 1 else "example.ini" + try: + import xmlschema + except ImportError: + print("error: needs the 'xmlschema' package (pip install xmlschema)") + return 2 + + _bootstrap_mtc() + from mtc.agent import AgentState, asset_dir + EXT_XSD = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd") + state = AgentState(ini_path) + state.poll_once() + docs = { + "MTConnectDevices_1.7.xsd": ("/probe", state.probe_document()), + "MTConnectAssets_1.7.xsd": ("/assets", state.assets_document()), + # Streams validate against the extension schema (which imports the base). + EXT_XSD: ("/current", state.current_document()), + } + ok = True + for xsd, (label, doc) in docs.items(): + loc = xsd if xsd == EXT_XSD else _xsd(xsd) + schema = xmlschema.XMLSchema11(loc, validation="skip") + errors = list(schema.iter_errors(io.StringIO(doc))) + if errors: + ok = False + print("FAIL %s (%d errors)" % (label, len(errors))) + for e in errors[:8]: + print(" ", e.reason or e.message) + else: + base = "base" if xsd != EXT_XSD else "base+extension" + print("ok %s valid (%s)" % (label, base)) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/debian/control.main-pkg.in b/debian/control.main-pkg.in index 56653dc0827..1fa794e5827 100644 --- a/debian/control.main-pkg.in +++ b/debian/control.main-pkg.in @@ -35,6 +35,8 @@ Recommends: linuxcnc-doc-en | linuxcnc-doc, librsvg2-dev, x11-xserver-utils, + python3-paho-mqtt, + libjs-three, @EXTRA_RECOMMENDS@, @PYTHON_IMAGING@, @PYTHON_IMAGING_TK@ diff --git a/debian/linuxcnc.install.in b/debian/linuxcnc.install.in index daf590b15c1..d66045b43a5 100644 --- a/debian/linuxcnc.install.in +++ b/debian/linuxcnc.install.in @@ -60,6 +60,7 @@ usr/bin/milltask usr/bin/millturngui usr/bin/mitsub_vfd usr/bin/mqtt-publisher +usr/bin/mtconnect-agent usr/bin/z_level_compensation usr/bin/monitor-xhc-hb04 usr/bin/motion-logger @@ -136,6 +137,7 @@ usr/share/linuxcnc/linuxcnc.gif usr/share/linuxcnc/linuxcncicon.png usr/share/linuxcnc/linuxcnc.nml usr/share/linuxcnc/linuxcnc-wizard.gif +usr/share/linuxcnc/mtconnect/ usr/share/linuxcnc/ncfiles/ usr/share/linuxcnc/pncconf/ usr/share/linuxcnc/popupkeyboard.ui diff --git a/debian/linuxcnc.manpages.in b/debian/linuxcnc.manpages.in index a1b0bb3dc86..964814da5fa 100644 --- a/debian/linuxcnc.manpages.in +++ b/debian/linuxcnc.manpages.in @@ -64,6 +64,7 @@ usr/share/man/man1/monitor-xhc-hb04.1 usr/share/man/man1/motion-logger.1 usr/share/man/man1/moveoff_gui.1 usr/share/man/man1/mqtt-publisher.1 +usr/share/man/man1/mtconnect-agent.1 usr/share/man/man1/ngcgui.1 usr/share/man/man1/panelui.1 usr/share/man/man1/pi500_vfd.1 diff --git a/docs/po4a.cfg b/docs/po4a.cfg index 86d055eb521..2c2ced43c5e 100644 --- a/docs/po4a.cfg +++ b/docs/po4a.cfg @@ -49,6 +49,7 @@ [type: AsciiDoc_def] src/config/integrator-concepts.adoc $lang:build/adoc/$lang/config/integrator-concepts.adoc [type: AsciiDoc_def] src/config/lathe-config.adoc $lang:build/adoc/$lang/config/lathe-config.adoc [type: AsciiDoc_def] src/config/moveoff.adoc $lang:build/adoc/$lang/config/moveoff.adoc +[type: AsciiDoc_def] src/config/mtconnect.adoc $lang:build/adoc/$lang/config/mtconnect.adoc [type: AsciiDoc_def] src/config/pncconf.adoc $lang:build/adoc/$lang/config/pncconf.adoc [type: AsciiDoc_def] src/config/python-hal-interface.adoc $lang:build/adoc/$lang/config/python-hal-interface.adoc [type: AsciiDoc_def] src/config/python-lcnc_realtime.adoc $lang:build/adoc/$lang/config/python-lcnc_realtime.adoc @@ -208,6 +209,7 @@ [type: AsciiDoc_def] src/man/man1/monitor-xhc-hb04.1.adoc $lang:build/adoc/$lang/man/man1/monitor-xhc-hb04.1.adoc [type: AsciiDoc_def] src/man/man1/motion-logger.1.adoc $lang:build/adoc/$lang/man/man1/motion-logger.1.adoc [type: AsciiDoc_def] src/man/man1/moveoff_gui.1.adoc $lang:build/adoc/$lang/man/man1/moveoff_gui.1.adoc +[type: AsciiDoc_def] src/man/man1/mtconnect-agent.1.adoc $lang:build/adoc/$lang/man/man1/mtconnect-agent.1.adoc [type: AsciiDoc_def] src/man/man1/ngcgui.1.adoc $lang:build/adoc/$lang/man/man1/ngcgui.1.adoc [type: AsciiDoc_def] src/man/man1/panelui.1.adoc $lang:build/adoc/$lang/man/man1/panelui.1.adoc [type: AsciiDoc_def] src/man/man1/pmx485-test.1.adoc $lang:build/adoc/$lang/man/man1/pmx485-test.1.adoc diff --git a/docs/src/Master_Documentation.adoc b/docs/src/Master_Documentation.adoc index 76f89fb7eea..993f6a318bc 100644 --- a/docs/src/Master_Documentation.adoc +++ b/docs/src/Master_Documentation.adoc @@ -68,6 +68,8 @@ include::motion/tweaking-steppers.adoc[] include::config/ini-config.adoc[] +include::config/mtconnect.adoc[] + include::config/ini-homing.adoc[] include::config/lathe-config.adoc[] diff --git a/docs/src/Submakefile b/docs/src/Submakefile index 793fd0faeb5..82df50a2de5 100644 --- a/docs/src/Submakefile +++ b/docs/src/Submakefile @@ -138,6 +138,7 @@ DOC_SRCS_EN := \ common/overleaf.adoc \ config/core-components.adoc \ config/ini-config.adoc \ + config/mtconnect.adoc \ config/ini-homing.adoc \ config/integrator-concepts.adoc \ config/lathe-config.adoc \ diff --git a/docs/src/config/ini-config.adoc b/docs/src/config/ini-config.adoc index 1ad65f11fa8..672aafae163 100644 --- a/docs/src/config/ini-config.adoc +++ b/docs/src/config/ini-config.adoc @@ -1481,4 +1481,35 @@ Control screens can limit these settings further. This is for machines that cannot place the tool back into the pocket it came from. For example, machines that exchange the tool in the active pocket with the tool in the spindle. +[[sub:ini:sec:mtconnect]] +=== [MTCONNECT] Section(((INI File,Sections,[MTCONNECT] Section))) + +Optional. Configures the `mtconnect-agent`(1) userspace component, which +publishes machine status, kinematics and tool data using the MTConnect standard. +Load it from a HAL file with `loadusr -W mtconnect-agent`. The MTConnect device +model is generated automatically from the `[TRAJ]`, `[KINS]`, `[AXIS_]` +and `[JOINT_]` sections, so only the keys below are required. + +* `ENABLE = 1` - (bool) Enable the agent. Default 1. +* `DEVICE_NAME = my_mill` - (string) MTConnect device name. + Default: `[EMC]MACHINE`. +* `UUID = 0e2c...` - (string) MTConnect device uuid. +* `TRANSPORT = http` - (string) Comma-separated list of transports: + `http`, `mqtt`, `shdr`. Default `http`. +* `HTTP_PORT = 5000` - (int) Port for the embedded HTTP agent. +* `HTTP_BIND = 127.0.0.1` - (string) Interface to bind the HTTP agent to. + Default 127.0.0.1 (loopback only); set to `0.0.0.0` to expose on the network. +* `SAMPLE_HZ = 10` - (real) Poll and publish rate in Hz. +* `ENABLE_TWIN = 0` - (bool) Serve the browser digital-twin viewer at `/twin`. + Off by default. Its 3D rendering uses the distribution's `libjs-three` + package, served by the agent from `/three/`. +* `MQTT_BROKER`, `MQTT_PORT`, `MQTT_PREFIX`, `MQTT_USERNAME`, `MQTT_PASSWORD` - + Settings for the standard MTConnect MQTT binding. Requires the + `python3-paho-mqtt` package. + +The embedded HTTP agent serves `/probe`, `/current`, `/sample` and `/assets`, an +optional digital-twin viewer at `/twin`, and geometry under `/models/`. The spindle speed +band from `[SPINDLE_0]MIN_FORWARD_VELOCITY`/`MAX_FORWARD_VELOCITY` is advertised +as an MTConnect Specification. See `mtconnect-agent`(1) for the full reference. + // vim: set syntax=asciidoc: diff --git a/docs/src/config/mtconnect.adoc b/docs/src/config/mtconnect.adoc new file mode 100644 index 00000000000..2f9d79d0495 --- /dev/null +++ b/docs/src/config/mtconnect.adoc @@ -0,0 +1,191 @@ +:lang: en +[[cha:mtconnect]] += MTConnect + +// Copyright (C) 2026 LinuxCNC contributors. GNU GPL v2 or later; NO WARRANTY. + +== What is MTConnect? + +MTConnect is an open, read-only standard for getting data out of manufacturing +equipment. A machine (or an *agent* in front of it) publishes a structured, +XML-based description of itself and a live stream of its state, which any +compliant software can read: shop dashboards, machine monitoring and OEE tools, +digital twins, and CAD/CAM systems. It is read-only by design - nothing can +command the machine through it - which makes it safe to expose. + +An MTConnect agent answers a few standard requests: + +* *probe* - the device model: the machine's components, axes, kinematics and the + list of data items it reports. +* *current* - the latest value of every data item. +* *sample* - a history of values by sequence number. +* *assets* - richer objects such as cutting tools. + +LinuxCNC provides a native agent, `mtconnect-agent`(1), so no third-party +software is required. + +== Enabling the agent + +Add a `[MTCONNECT]` section to the INI (see +<> for all keys) and load the agent +from a HAL file: + +.INI file +---- +[MTCONNECT] +ENABLE = 1 +DEVICE_NAME = my_mill +TRANSPORT = http +HTTP_PORT = 5000 +---- + +.HAL file +---- +loadusr -W mtconnect-agent +---- + +`-W` waits until the component is ready before HAL processing continues. The INI +is taken from the `INI_FILE_NAME` environment variable, so no argument is needed. +The MTConnect device model - including the kinematic description - is generated +automatically from `[TRAJ]`, `[KINS]`, `[AXIS_]` and `[JOINT_]`. + +By default the HTTP server listens on `127.0.0.1` (loopback) only. To let other +machines on the network read it, set `HTTP_BIND = 0.0.0.0`. + +Once running: + +---- +curl http://localhost:5000/probe +curl http://localhost:5000/current +curl http://localhost:5000/assets +---- + +== HAL pins + +Loaded from a HAL file, the component exposes pins you can link: + +* `mtconnect-agent.enable` (bit, in) - gate polling and publishing. +* `mtconnect-agent.active` (bit, out) - TRUE while polling and serving. +* `mtconnect-agent.connected` (bit, out) - TRUE while the MQTT broker link is up. +* `mtconnect-agent.heartbeat` (u32, out) - increments each poll. + +== Exposing custom HAL pins + +A lot of useful machine data lives in HAL pins that are not part of +`linuxcnc.stat()` - spindle load, drive or board temperatures, air pressure, +vacuum, custom sensors. Any HAL pin, signal or parameter can be published as an +MTConnect data item with a `HAL_ITEM` line; the agent reads it each poll with +`hal.get_value` (no HAL-file wiring needed) and it then appears in `/probe`, +`/current`, `/sample` and over MQTT and SHDR like any other item. + +---- +[MTCONNECT] +HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle +HAL_ITEM = pin=hm2_5i25.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS +---- + +Fields: `pin=` (the HAL name), `id=` (the data item id, also its MQTT/SHDR key), +`type=` (a standard MTConnect SAMPLE type), and optionally `units=`, `name=`, +`subType=`, and `component=`. `component=` chooses the host: the default +`sensors` (a generic Sensor component), or an existing one - `spindle`, +`controller`, `path`, or an axis letter. + +Only standard MTConnect SAMPLE types are accepted (for example `LOAD`, +`TEMPERATURE`, `PRESSURE`, `VOLTAGE`, `AMPERAGE`, `WATTAGE`, `FREQUENCY`, +`ANGLE`, `VELOCITY`, `ACCELERATION`, `TORQUE`, `FILL_LEVEL`, `FLOW`). A custom +type would need the LinuxCNC extension schema and cannot be represented in the +base MTConnectStreams schema, so it is rejected with a warning listing the +supported types; the agent still starts. + +== The kinematic description + +Standard MTConnect models each axis as a `Linear` or `Rotary` component with a +`Motion` element (`PRISMATIC`/`REVOLUTE` plus a direction vector), and describes +frames with `CoordinateSystems`. Travel limits are published as `Specifications`. + +LinuxCNC specifics that have no standard representation - the kinematics module +name, the `coordinates=` string, and the joint-to-axis map - are carried in a +compact extension block, `x:Kinematics`, in the LinuxCNC extension namespace +`urn:linuxcnc:mtconnect:1`. This is the primary contract for a consumer (for +example a FreeCAD machine-configuration plugin) that wants to reconstruct the +machine automatically. + +== Digital twin + +The agent ships a browser digital-twin viewer at `/twin`. It reads `/probe` +for the geometry references and animates the machine from `/current`. Geometry +is *referenced, never streamed*: the device model points to mesh files with +`SolidModel` elements that the viewer fetches once. + +The twin is *off by default*; enable it with `ENABLE_TWIN = 1` in the +`[MTCONNECT]` section. Its 3D rendering uses three.js, which LinuxCNC does not +bundle - it uses the distribution's `libjs-three` package (a `Recommends`), and +the agent serves it from `/three/`. With that package installed the viewer +works fully offline; no internet access or CDN is required. + +With `MODEL_AUTO = 1` the agent generates simple placeholder box geometry from the +travel limits, so any machine gets a working twin with no mesh files. Supply +STL/OBJ/glTF meshes (authored in millimetres) with `MODEL_BASE`, `MODEL_X`, etc. +for higher fidelity. Because a trivial-kinematics INI does not describe the +mechanical assembly, `MODEL_CHAIN`, `MODEL_PARENT_` and `MODEL_INVERT` +describe the nesting, branching (e.g. a quill that carries the tool) and which +links physically move opposite the reported tool-relative coordinate. + +== MQTT + +With `mqtt` in the `TRANSPORT` list and the `python3-paho-mqtt` package installed, +the agent also publishes to the standard MTConnect MQTT topics +(`/Probe/` retained, plus `Current`, `Sample` and `Asset`). A +consumer discovers the whole device from the retained Probe topic. + +=== Home Assistant (optional) + +Home Assistant's MQTT Discovery is an HA-specific convention, not an open +standard, so it is deliberately not part of the core agent. An optional bridge, +`mtconnect-ha-bridge` (in the MTConnect example config's `contrib/` directory), +reuses the agent's device model to publish Home Assistant discovery so HA +auto-creates a device with one sensor per value - no YAML required. It is +configured entirely with command-line arguments, so no Home Assistant setting is +placed in the machine INI: + +---- +loadusr -W mtconnect-ha-bridge \ + --broker=[HA]BROKER --username=[HA]USER --password=[HA]PASSWORD +---- + +== Using an external MTConnect agent (SHDR) + +Instead of (or in addition to) the embedded agent, LinuxCNC can act as an *SHDR +adapter* and feed a standard external MTConnect agent such as the reference +cppagent. SHDR is the MTConnect adapter protocol: a line-oriented TCP stream the +agent connects to and reads. Add `shdr` to the `TRANSPORT` list; the adapter +listens on `SHDR_PORT` (default 7878): + +---- +[MTCONNECT] +TRANSPORT = http, shdr +SHDR_PORT = 7878 +---- + +The device *model* is not sent over SHDR - only data values, as +`||` lines. Configure the external agent with a +`Devices.xml`, which the generated probe document doubles as: + +---- +mtconnect-agent --dump-probe my_machine.ini > Devices.xml +---- + +The `dataItemId` values in the SHDR stream match the ids in that `Devices.xml`. +(The SHDR port is reachable from other hosts by design, since an external agent +usually runs elsewhere.) + +== The LinuxCNC extension schema + +A few data items have no standard MTConnect type: coolant flood/mist state and +the active XY work-coordinate rotation (`G10 L2 R`). These are published in the +`urn:linuxcnc:mtconnect:1` namespace and defined by the extension schema +`mtconnect-linuxcnc-1.xsd`, installed under `share/linuxcnc/mtconnect` and served +by the agent at `/mtconnect-linuxcnc-1.xsd`. The streaming documents validate +against the official MTConnect schemas together with this extension schema. + +// vim: set syntax=asciidoc: diff --git a/docs/src/man/man1/mtconnect-agent.1.adoc b/docs/src/man/man1/mtconnect-agent.1.adoc new file mode 100644 index 00000000000..c7a5c17d1b9 --- /dev/null +++ b/docs/src/man/man1/mtconnect-agent.1.adoc @@ -0,0 +1,126 @@ += mtconnect-agent(1) + +== NAME + +mtconnect-agent - expose LinuxCNC machine status and kinematics over MTConnect + +== SYNOPSIS + +*loadusr -W mtconnect-agent* + +*mtconnect-agent* [_INI_] [*--dump-probe*] [*--port* _N_] + +== DESCRIPTION + +*mtconnect-agent* is a non-realtime userspace HAL component that reads machine +status, kinematics and tool data via the *linuxcnc* Python module and the INI +file, and publishes them using the MTConnect standard. It provides an embedded +HTTP agent and, optionally, the standard MTConnect MQTT binding. No external +MTConnect agent (cppagent) is required. + +The device model, including the kinematic description, is generated automatically +from the *[TRAJ]*, *[KINS]*, *[AXIS_n]* and *[JOINT_n]* sections, so only a small +*[MTCONNECT]* section is needed to enable the feature. + +It is normally started from a HAL file so its status pins are available: + +---- +loadusr -W mtconnect-agent +---- + +The INI file is taken from the *INI_FILE_NAME* environment variable that LinuxCNC +sets, so no argument is required. + +== HTTP ENDPOINTS + +When the HTTP transport is enabled the following are served (default port 5000): + +*/probe*:: MTConnectDevices - the device model and kinematics. +*/current*:: MTConnectStreams - the latest value of every data item. +*/sample*:: MTConnectStreams - a sequence range (`?from=&count=`). +*/assets*:: MTConnectAssets - the tool table as CuttingTool assets. +*/twin*:: A browser digital-twin viewer. Off by default; enable with +*[MTCONNECT]ENABLE_TWIN = 1*. 3D rendering uses the distribution's *libjs-three* +package (served from `/three/`), so it works offline once that is installed. +*/models/*:: A referenced or auto-generated geometry mesh. +*/mtconnect-linuxcnc-1.xsd*:: The LinuxCNC MTConnect extension schema. + +By default the HTTP server binds to 127.0.0.1 (loopback only). Set +*[MTCONNECT]HTTP_BIND = 0.0.0.0* to expose it on the network. + +== OPTIONS + +*--dump-probe*:: + Print the MTConnectDevices (/probe) document to standard output and exit. + +*--port* _N_:: + Override *[MTCONNECT]HTTP_PORT*. + +== INI CONFIGURATION + +All configuration is read from the *[MTCONNECT]* section: + +*ENABLE*:: 1 to enable the agent (default), 0 to disable. +*DEVICE_NAME*:: MTConnect device name (default: *[EMC]MACHINE*). +*UUID*:: MTConnect device uuid. +*TRANSPORT*:: Comma-separated list of *http*, *mqtt*, *shdr* (default *http*). +*HTTP_PORT*:: Embedded HTTP agent port (default 5000). +*HTTP_BIND*:: Interface to bind (default 127.0.0.1; use 0.0.0.0 for the LAN). +*SHDR_PORT*:: Port for the SHDR adapter when *shdr* is in *TRANSPORT* (default + 7878). SHDR feeds an external MTConnect agent (e.g. cppagent) `|id|value` + lines; configure that agent with a Devices.xml from *--dump-probe*. +*SAMPLE_HZ*:: Poll/publish rate in Hz (default 10). +*MQTT_BROKER*, *MQTT_PORT*, *MQTT_PREFIX*, *MQTT_USERNAME*, *MQTT_PASSWORD*:: + Standard MTConnect MQTT binding settings (requires the *python3-paho-mqtt* + package). + +Spindle speed limits from *[SPINDLE_0]MIN_FORWARD_VELOCITY* and +*MAX_FORWARD_VELOCITY* are advertised as an MTConnect Specification. + +*HAL_ITEM*:: + Expose a HAL pin/signal as an MTConnect data item. Repeatable; each is a + comma-separated list of fields: *pin=* (HAL name, read via `hal.get_value`), + *id=* (data item id), *type=* (a standard MTConnect SAMPLE type such as LOAD, + TEMPERATURE, PRESSURE, VOLTAGE, AMPERAGE, FREQUENCY, ANGLE, VELOCITY, TORQUE), + and optionally *units=*, *name=*, *subType=*, and *component=* (the host + component: the default *sensors* generic Sensor, or *spindle*, *controller*, + *path*, or an axis letter). Example: ++ +---- +HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle +---- ++ +Non-standard types and non-SAMPLE categories are skipped with a warning. + +== HAL PINS + +*mtconnect-agent.enable* (bit, in):: Gate polling and publishing (default TRUE). +*mtconnect-agent.sample-hz* (u32, in):: Override *SAMPLE_HZ*. +*mtconnect-agent.active* (bit, out):: TRUE while polling and serving. +*mtconnect-agent.connected* (bit, out):: TRUE while the MQTT broker link is up. +*mtconnect-agent.heartbeat* (u32, out):: Increments on each poll. + +== EXTENSION SCHEMA + +Machine data with no standard MTConnect type - coolant flood/mist and the active +XY work-offset rotation (G10 L2 R) - is published in the *urn:linuxcnc:mtconnect:1* +namespace and defined by the extension schema *mtconnect-linuxcnc-1.xsd* +(installed under *share/linuxcnc/mtconnect*). The streaming documents validate +against the official MTConnect 1.7 schemas together with this extension schema. + +== SEE ALSO + +*mqtt-publisher*(1), *halcmd*(1) + +The MTConnect standard: https://www.mtconnect.org/ + +== AUTHOR + +This man page was written as part of the LinuxCNC MTConnect feature. + +== COPYRIGHT + +Copyright \(C) 2026 LinuxCNC contributors. + +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. diff --git a/lib/python/mtc/__init__.py b/lib/python/mtc/__init__.py new file mode 100644 index 00000000000..0ee8af22d72 --- /dev/null +++ b/lib/python/mtc/__init__.py @@ -0,0 +1,21 @@ +# 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 feature that exposes LinuxCNC machine status, +# a rich kinematic description, and tool data over MTConnect (HTTP and, +# optionally, MQTT). See the mtconnect-agent(1) man page. diff --git a/lib/python/mtc/agent.py b/lib/python/mtc/agent.py new file mode 100644 index 00000000000..d6d8991071f --- /dev/null +++ b/lib/python/mtc/agent.py @@ -0,0 +1,188 @@ +# 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 + +# Agent core: holds the machine model, the observation buffer and the live +# source, and produces the four MTConnect documents. Transport-agnostic so it +# can be driven by the embedded HTTP server (http_agent) or an MQTT publisher. + +import os +import threading +from datetime import datetime, timezone + +from .ini_reader import IniReader +from .observations import build_dataitems +from .device_model import DeviceConfig, probe_xml +from .streams import (ObservationBuffer, current_xml, sample_xml, assets_xml) +from .lcnc_source import LcncSource +from .hal_items import HalSource +from .models import build_models +from . import kinematics as kin + + +def now_iso(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def asset_dir(): + """Directory holding runtime data assets (twin.html, the extension schema). + + Installed / RIP layout: $EMC2_HOME/share/linuxcnc/mtconnect. Falls back to a + 'mtconnect' dir beside this package for an uninstalled source checkout. + """ + home = os.environ.get("EMC2_HOME") + if home: + d = os.path.join(home, "share", "linuxcnc", "mtconnect") + if os.path.isdir(d): + return d + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "mtconnect") + + +def three_dir(): + """Directory of the system three.js (Debian's libjs-three package). + + The digital twin loads three.js from here rather than bundling a copy, so + the library is vetted and security-updated by the distribution. Override + with $MTC_THREE_DIR (e.g. for testing). + """ + return os.environ.get("MTC_THREE_DIR", "/usr/share/javascript/three") + + +class OutOfRange(Exception): + pass + + +class AgentState: + def __init__(self, ini_path, buffer_size=131072): + self.ini = IniReader(ini_path) + self.model = kin.build_model(self.ini) + self.config = DeviceConfig.from_ini(self.ini) + self.dataitems = build_dataitems(self.model, self.config) + self.buffer = ObservationBuffer(self.dataitems, max_size=buffer_size) + self.source = LcncSource(self.model, self.config) + self.hal_source = HalSource(self.config.hal_items) + self.models = build_models(self.ini, self.model, self.config) + self._assets = [] + self._last_in_spindle = None + self._last_values = {} + self._lock = threading.Lock() + + # -- data collection ----------------------------------------------------- + + def poll_once(self): + """Poll LinuxCNC once and fold new values into the buffer.""" + self.source.poll() + values = self.source.sample_values() + values.update(self.hal_source.sample_values()) # user HAL_ITEM pins + assets = self.source.tool_assets() + ts = now_iso() + with self._lock: + self._detect_asset_change(assets, values) + self.buffer.ingest(values, ts) + self._assets = assets + self._last_values = values + + def latest_values(self): + """Flat {dataitem_id: value} snapshot from the last poll (for HA).""" + with self._lock: + return dict(self._last_values) + + def _detect_asset_change(self, assets, values): + in_spindle = values.get("toolnum") + if in_spindle is not None and in_spindle != self._last_in_spindle: + if in_spindle and in_spindle > 0: + values["assetchg"] = "tool-%d" % in_spindle + self._last_in_spindle = in_spindle + + # -- document builders --------------------------------------------------- + + def probe_document(self): + with self._lock: + asset_count = len(self._assets) + return probe_xml(self.model, self.config, models=self.models, + creation_time=now_iso(), asset_count=asset_count) + + def twin_html(self): + """Return the bundled three.js twin viewer HTML, or None if absent.""" + path = os.path.join(asset_dir(), "twin.html") + if not os.path.isfile(path): + return None + with open(path, "r") as fh: + return fh.read() + + def extension_schema(self): + """Return (bytes, content_type) for the LinuxCNC extension XSD, or None.""" + path = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd") + if not os.path.isfile(path): + return None + with open(path, "rb") as fh: + return fh.read(), "application/xml" + + def model_file(self, name): + """Return (bytes, content_type) for a served mesh, or None.""" + ref = self.models.served.get(name) + if ref is None: + return None + if name in self.models.generated: # auto-generated in memory + return self.models.generated[name].encode("utf-8"), ref.content_type + if ref.path and os.path.isfile(ref.path): + with open(ref.path, "rb") as fh: + return fh.read(), ref.content_type + return None + + def three_file(self, relpath): + """Return (bytes, content_type) for a file from the system three.js, or None. + + Serves the distribution's libjs-three (three_dir()) for the digital twin. + Rejects path traversal: no absolute paths and no '..' components, so only + files inside the three.js tree can be reached. + """ + relpath = (relpath or "").lstrip("/") + if not relpath: + return None + parts = relpath.split("/") + if any(p in ("", ".", "..") for p in parts): + return None + root = three_dir() + path = os.path.normpath(os.path.join(root, *parts)) + # Defense in depth: ensure the resolved path stays under the three.js root. + if os.path.commonpath([os.path.abspath(root), os.path.abspath(path)]) \ + != os.path.abspath(root): + return None + if not os.path.isfile(path): + return None + content_type = ("text/javascript" if path.endswith(".js") + else "application/octet-stream") + with open(path, "rb") as fh: + return fh.read(), content_type + + def current_document(self): + with self._lock: + return current_xml(self.buffer, self.config, now_iso()) + + def sample_document(self, from_seq=None, count=100): + with self._lock: + first, nxt = self.buffer.first_sequence, self.buffer.next_sequence + if from_seq is None: + from_seq = first + if from_seq < first or from_seq > nxt: + raise OutOfRange("from=%s out of range [%d,%d]" + % (from_seq, first, nxt)) + return sample_xml(self.buffer, self.config, now_iso(), from_seq, count) + + def assets_document(self): + with self._lock: + assets = list(self._assets) + return assets_xml(assets, self.config, now_iso()) diff --git a/lib/python/mtc/device_model.py b/lib/python/mtc/device_model.py new file mode 100644 index 00000000000..e4ab5eb2e14 --- /dev/null +++ b/lib/python/mtc/device_model.py @@ -0,0 +1,439 @@ +# 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 + +# Build the MTConnectDevices (/probe) document for a LinuxCNC machine. +# +# The document is "hybrid": a standard MTConnect component tree (Controller, +# Path, Axes with Linear/Rotary components and Motion elements) plus a compact +# LinuxCNC extension block () carrying the kins module name, the +# coordinates string and the joint<->axis map -- the primary contract for a +# FreeCAD auto-configuration plugin. +# +# The DataItems themselves come from the shared registry (observations.py) so +# the probe and the /current and /sample streams cannot drift apart. +# +# Run standalone to dump a probe document from an INI file: +# python3 -m mtc.device_model path/to/machine.ini + +import re +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field + +from .ini_reader import IniReader +from .observations import build_dataitems, EXT_NS +from .hal_items import parse_hal_items, resolve_component +from . import kinematics as kin + +MTC_NS = "urn:mtconnect.org:MTConnectDevices:1.7" +XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" +SCHEMA_VERSION = "1.7" + +_LINEAR_UNITS = { + "mm": "MILLIMETER", "metric": "MILLIMETER", "millimeter": "MILLIMETER", + "inch": "INCH", "imperial": "INCH", "in": "INCH", "cm": "CENTIMETER", +} +_ANGULAR_UNITS = { + "degree": "DEGREE", "degrees": "DEGREE", "deg": "DEGREE", + "radian": "RADIAN", "grad": "DEGREE", +} +# Factor that converts a native length value to millimetres (the MTConnect +# canonical length unit). Everything emitted is metric; nativeUnits records the +# source so a consumer can recover the original. +_LINEAR_SCALE = {"MILLIMETER": 1.0, "INCH": 25.4, "CENTIMETER": 10.0} + + +@dataclass +class DeviceConfig: + name: str = "linuxcnc" + uuid: str = "linuxcnc-0001" + manufacturer: str = "LinuxCNC" + linear_units: str = "MILLIMETER" # canonical emitted unit (always mm) + native_linear_units: str = "MILLIMETER" # the machine's own unit (INCH/...) + linear_scale: float = 1.0 # native length * scale -> millimetres + angular_units: str = "DEGREE" + instance_id: str = "1" + spindle_speed_min: float = None # [SPINDLE_0] MIN_FORWARD_VELOCITY (RPM) + spindle_speed_max: float = None # [SPINDLE_0] MAX_FORWARD_VELOCITY (RPM) + hal_items: list = field(default_factory=list) # [MTCONNECT]HAL_ITEM data items + + @property + def device_id(self): + """NCName-safe id for the Device component (name may have spaces).""" + return "dev_%s" % _nc(self.name) + + @classmethod + def from_ini(cls, ini): + lin = (ini.find("TRAJ", "LINEAR_UNITS", "mm") or "mm").strip().lower() + ang = (ini.find("TRAJ", "ANGULAR_UNITS", "degree") or "degree").strip().lower() + native_lin = _LINEAR_UNITS.get(lin, "MILLIMETER") + # Spindle 0 usable speed band (RPM); modern configs use [SPINDLE_0], + # older ones an unnumbered [SPINDLE]. Only carried through if present. + sp = "SPINDLE_0" if ini.has_section("SPINDLE_0") else "SPINDLE" + return cls( + name=ini.find("MTCONNECT", "DEVICE_NAME", + ini.find("EMC", "MACHINE", "linuxcnc")) or "linuxcnc", + uuid=ini.find("MTCONNECT", "UUID", "linuxcnc-0001") or "linuxcnc-0001", + linear_units="MILLIMETER", + native_linear_units=native_lin, + linear_scale=_LINEAR_SCALE.get(native_lin, 1.0), + angular_units=_ANGULAR_UNITS.get(ang, "DEGREE"), + spindle_speed_min=ini.find_float(sp, "MIN_FORWARD_VELOCITY"), + spindle_speed_max=ini.find_float(sp, "MAX_FORWARD_VELOCITY"), + hal_items=parse_hal_items(ini), + ) + + +# Namespaces are declared as literal xmlns attributes on the root (below) and +# tags are written with plain / prefixed names. This avoids ElementTree's +# global register_namespace() state, which mis-handles several default +# namespaces in one process. Serialized output round-trips through any +# namespace-aware parser exactly as if {uri}Tag had been used. +def _t(tag): + return tag + + +def _x(tag): + return "x:" + tag + + +def _fmt_vec(vec): + return " ".join(("%g" % (c if c != 0 else 0.0)) for c in vec) # avoid -0 + + +def _nc(s): + """Coerce an arbitrary string into a valid XML NCName for id attributes. + + Machine names may contain spaces or punctuation (e.g. 'xyzac (switchkins)') + which are legal in the MTConnect name attribute but not in an id/NCName. + """ + s = re.sub(r"[^A-Za-z0-9_.-]", "_", s or "") + if not s or not (s[0].isalpha() or s[0] == "_"): + s = "_" + s + return s + + +def build_device_element(model, config, models=None): + """Build the element (standard tree + kinematics extension).""" + dev = ET.Element(_t("Device"), + {"id": config.device_id, "name": config.name, + "uuid": config.uuid}) + # The compact LinuxCNC kinematics block is an extension element. The only + # schema-valid host for foreign-namespace elements is a Description (its + # content model is a lax xs:any); putting it here keeps the whole document + # valid against the standard MTConnectDevices schema. + desc = ET.SubElement(dev, _t("Description"), {"manufacturer": config.manufacturer}) + _build_kinematics_extension(desc, model, config) + + # Device-level Configuration: the WORLD/MACHINE coordinate systems are always + # emitted so every Motion/SolidModel coordinateSystemIdRef='machine' resolves; + # the static frame SolidModel is added only when geometry is configured. + cfg = ET.SubElement(dev, _t("Configuration")) + _build_coordinate_systems(cfg) + if models and models.enabled() and models.base: + _solid_model(cfg, "dev_base_model", models.base, models) + + # containers maps a component id to its (created, empty) element; + # the registry loop below fills them so probe and streams stay in lockstep. + containers = {} + containers[config.device_id] = ET.SubElement(dev, _t("DataItems")) + + components = ET.SubElement(dev, _t("Components")) + _build_controller(components, containers) + _build_axes(components, model, config, containers, models) + _build_systems(components, containers) + _build_auxiliaries(components, containers, model, config) + + for di in build_dataitems(model, config): + parent = containers.get(di.comp_id) + if parent is not None: + _emit_dataitem(parent, di) + + return dev + + +def _emit_dataitem(parent, di): + type_ = ("x:" + di.type) if di.ext else di.type + attrs = {"category": di.category, "type": type_, "id": di.id} + if di.subType: + attrs["subType"] = di.subType + if di.units: + attrs["units"] = di.units + if di.native_units: + attrs["nativeUnits"] = di.native_units + if di.representation: + attrs["representation"] = di.representation + item = ET.SubElement(parent, _t("DataItem"), attrs) + if di.constraints: + constraints = ET.SubElement(item, _t("Constraints")) + if di.constraints.get("minimum") is not None: + ET.SubElement(constraints, _t("Minimum")).text = "%g" % di.constraints["minimum"] + if di.constraints.get("maximum") is not None: + ET.SubElement(constraints, _t("Maximum")).text = "%g" % di.constraints["maximum"] + + +def _build_controller(parent, containers): + ctrl = ET.SubElement(parent, _t("Controller"), {"id": "ctrl", "name": "controller"}) + containers["ctrl"] = ET.SubElement(ctrl, _t("DataItems")) + paths = ET.SubElement(ctrl, _t("Components")) + path = ET.SubElement(paths, _t("Path"), {"id": "path", "name": "path"}) + containers["path"] = ET.SubElement(path, _t("DataItems")) + + +def _build_axes(parent, model, config, containers, models=None): + axes = ET.SubElement(parent, _t("Axes"), {"id": "axes", "name": "axes"}) + comps = ET.SubElement(axes, _t("Components")) + for axis in model.axes: + _build_motion_axis(comps, axis, containers, models, config) + _build_spindle(comps, containers, models, config) + + +def _build_motion_axis(parent, axis, containers, models=None, config=None): + is_linear = axis.kind == "LINEAR" + aid = axis.letter.lower() + comp = ET.SubElement(parent, _t("Linear" if is_linear else "Rotary"), + {"id": "axis_%s" % aid, "name": axis.letter}) + cfg = ET.SubElement(comp, _t("Configuration")) + motion = ET.SubElement(cfg, _t("Motion"), { + "id": "motion_%s" % aid, + "type": "PRISMATIC" if is_linear else "REVOLUTE", + "actuation": "DIRECT", + "coordinateSystemIdRef": "machine", + }) + # Chain this link to its parent link's motion so the twin nests transforms. + if models and models.enabled(): + parent_id = models.parent_of("axis_%s" % aid) + if parent_id and parent_id.startswith("axis_"): + motion.set("parentIdRef", "motion_%s" % parent_id.split("_", 1)[1]) + vec = axis.vector + if models and axis.letter in models.invert: + vec = tuple(-c for c in vec) # work-carrying axis moves opposite + ET.SubElement(motion, _t("Axis")).text = _fmt_vec(vec) + if models and axis.letter in models.axis: + _solid_model(cfg, "model_%s" % aid, models.axis[axis.letter], models) + # Mirror the travel limits as a standard Specification so non-LinuxCNC + # consumers get the work envelope without reading the x:Kinematics block. + _axis_specification(cfg, axis, config) + containers["axis_%s" % aid] = ET.SubElement(comp, _t("DataItems")) + + +def _axis_specification(cfg, axis, config): + if axis.min_limit is None and axis.max_limit is None: + return + is_linear = axis.kind == "LINEAR" + scale = (config.linear_scale if (config and is_linear) else 1.0) + units = ((config.linear_units if config else "MILLIMETER") if is_linear + else (config.angular_units if config else "DEGREE")) + dtype = "POSITION" if is_linear else "ANGLE" + specs = ET.SubElement(cfg, _t("Specifications")) + # NB: SpecificationType has no nativeUnits attribute; values are canonical. + attrs = {"id": "axis_%s_travel" % axis.letter.lower(), "type": dtype, + "units": units, "name": "%s travel" % axis.letter} + spec = ET.SubElement(specs, _t("Specification"), attrs) + if axis.max_limit is not None: + ET.SubElement(spec, _t("Maximum")).text = "%g" % (axis.max_limit * scale) + if axis.min_limit is not None: + ET.SubElement(spec, _t("Minimum")).text = "%g" % (axis.min_limit * scale) + + +def _build_spindle(parent, containers, models=None, config=None): + comp = ET.SubElement(parent, _t("Rotary"), {"id": "spindle", "name": "S"}) + has_spec = config is not None and (config.spindle_speed_min is not None + or config.spindle_speed_max is not None) + if (models and models.spindle) or has_spec: + cfg = ET.SubElement(comp, _t("Configuration")) + if has_spec: + _spindle_specifications(cfg, config) + if models and models.spindle: + _solid_model(cfg, "model_spindle", models.spindle, models) + containers["spindle"] = ET.SubElement(comp, _t("DataItems")) + + +def _spindle_specifications(cfg, config): + """Spindle usable speed band as a standard MTConnect Specification.""" + specs = ET.SubElement(cfg, _t("Specifications")) + spec = ET.SubElement(specs, _t("Specification"), { + "id": "spdl_speed_spec", "type": "ROTARY_VELOCITY", + "units": "REVOLUTION/MINUTE", "name": "spindle speed", + }) + if config.spindle_speed_max is not None: + ET.SubElement(spec, _t("Maximum")).text = "%g" % config.spindle_speed_max + if config.spindle_speed_min is not None: + ET.SubElement(spec, _t("Minimum")).text = "%g" % config.spindle_speed_min + + +def _build_systems(parent, containers): + """Systems container with a Coolant component (flood/mist events).""" + systems = ET.SubElement(parent, _t("Systems"), {"id": "systems", "name": "systems"}) + comps = ET.SubElement(systems, _t("Components")) + coolant = ET.SubElement(comps, _t("Coolant"), {"id": "coolant", "name": "coolant"}) + containers["coolant"] = ET.SubElement(coolant, _t("DataItems")) + + +def _build_auxiliaries(parent, containers, model, config): + """Host generic user HAL items ([MTCONNECT]HAL_ITEM) that target the default + 'sensors' component, in an Auxiliaries > Sensor container. Items that target + an existing component (spindle, controller, path, an axis) land there instead + and need nothing here. + """ + wants_sensor = any(resolve_component(it.component, model)[0] == "sensors" + for it in getattr(config, "hal_items", None) or []) + if not wants_sensor: + return + aux = ET.SubElement(parent, _t("Auxiliaries"), {"id": "aux", "name": "aux"}) + comps = ET.SubElement(aux, _t("Components")) + sensor = ET.SubElement(comps, _t("Sensor"), {"id": "sensors", "name": "sensors"}) + containers["sensors"] = ET.SubElement(sensor, _t("DataItems")) + + +def _build_coordinate_systems(cfg): + cs = ET.SubElement(cfg, _t("CoordinateSystems")) + ET.SubElement(cs, _t("CoordinateSystem"), + {"id": "world", "type": "WORLD", "name": "world"}) + machine = ET.SubElement(cs, _t("CoordinateSystem"), + {"id": "machine", "type": "MACHINE", "name": "machine", + "parentIdRef": "world"}) + ET.SubElement(machine, _t("Origin")).text = "0 0 0" + + +def _solid_model(cfg, sid, ref, models): + # NB: the MTConnect SolidModel element has no units/nativeUnits attributes; + # served geometry is expected in the canonical millimetre coordinate space. + ET.SubElement(cfg, _t("SolidModel"), { + "id": sid, + "href": "/models/%s" % ref.name, + "mediaType": ref.media, + "coordinateSystemIdRef": "machine", + }) + + +def _build_kinematics_extension(parent, model, config): + """Compact LinuxCNC-specific kinematic block for auto-configuration. + + Linear limits are converted to millimetres (matching every other emitted + length); linearUnits / nativeLinearUnits record the canonical and source + units so a consumer can recover the machine's native values. + """ + lin = getattr(config, "linear_scale", 1.0) + k = ET.SubElement(parent, _x("Kinematics"), { + "module": model.kins_module, + "coordinates": model.coordinates, + "joints": str(model.joints_count), + "linearUnits": config.linear_units, + "nativeLinearUnits": config.native_linear_units, + }) + if model.kins_params: + k.set("params", model.kins_params) + if model.kinematics_type: + k.set("type", model.kinematics_type) + + jmap = ET.SubElement(k, _x("JointMap")) + for joint in model.joints: + js = lin if joint.kind == "LINEAR" else 1.0 + attrs = {"number": str(joint.number), "kind": joint.kind} + if joint.axis: + attrs["axis"] = joint.axis + _set_num(attrs, "min", joint.min_limit, js) + _set_num(attrs, "max", joint.max_limit, js) + _set_num(attrs, "home", joint.home, js) + _set_num(attrs, "homeOffset", joint.home_offset, js) + ET.SubElement(jmap, _x("Joint"), attrs) + + for axis in model.axes: + axs = lin if axis.kind == "LINEAR" else 1.0 + attrs = {"name": axis.letter, "kind": axis.kind, "vector": _fmt_vec(axis.vector)} + _set_num(attrs, "min", axis.min_limit, axs) + _set_num(attrs, "max", axis.max_limit, axs) + ET.SubElement(k, _x("Axis"), attrs) + + +def _set_num(attrs, key, value, scale=1.0): + if value is not None: + attrs[key] = "%g" % (value * scale) + + +def build_probe_tree(model, config, creation_time="1970-01-01T00:00:00Z", + asset_count=0, models=None): + """Build the full ElementTree root.""" + root = ET.Element("MTConnectDevices", { + "xmlns": MTC_NS, + "xmlns:xsi": XSI_NS, + "xmlns:x": EXT_NS, + "xsi:schemaLocation": + "urn:mtconnect.org:MTConnectDevices:%s " + "http://schemas.mtconnect.org/schemas/MTConnectDevices_%s.xsd" + % (SCHEMA_VERSION, SCHEMA_VERSION), + }) + ET.SubElement(root, _t("Header"), { + "creationTime": creation_time, + "sender": "linuxcnc-mtconnect", + "instanceId": config.instance_id, + "version": SCHEMA_VERSION, + "deviceModelChangeTime": creation_time, + "assetCount": str(asset_count), + "assetBufferSize": "1024", + "bufferSize": "131072", + }) + devices = ET.SubElement(root, _t("Devices")) + # The schema requires an Agent device alongside the machine Device(s); it + # is the self-description of this agent process. + _build_agent_element(devices, config) + devices.append(build_device_element(model, config, models)) + return root + + +def _build_agent_element(devices, config): + agent = ET.SubElement(devices, _t("Agent"), { + "id": "agent_%s" % _nc(config.name), "name": "%s_agent" % config.name, + "uuid": "%s_agent" % config.uuid, "mtconnectVersion": SCHEMA_VERSION, + }) + ET.SubElement(agent, _t("Description"), + {"manufacturer": "LinuxCNC"}).text = "LinuxCNC MTConnect agent" + items = ET.SubElement(agent, _t("DataItems")) + ET.SubElement(items, _t("DataItem"), + {"category": "EVENT", "type": "AVAILABILITY", "id": "agent_avail"}) + for t in ("DEVICE_ADDED", "DEVICE_REMOVED", "DEVICE_CHANGED"): + ET.SubElement(items, _t("DataItem"), + {"category": "EVENT", "type": t, "id": "agent_%s" % t.lower()}) + + +def probe_xml(model, config, models=None, **kwargs): + """Return the pretty-printed MTConnectDevices document as a string.""" + root = build_probe_tree(model, config, models=models, **kwargs) + ET.indent(root, space=" ") + body = ET.tostring(root, encoding="unicode") + return '\n' + body + "\n" + + +def probe_from_ini(ini_path, **kwargs): + """Convenience: build a probe document straight from an INI file path.""" + from .models import build_models + ini = IniReader(ini_path) + model = kin.build_model(ini) + config = DeviceConfig.from_ini(ini) + return probe_xml(model, config, models=build_models(ini, model, config), **kwargs) + + +def _main(argv): + if len(argv) != 2: + sys.stderr.write("usage: python3 -m mtc.device_model MACHINE.ini\n") + return 2 + sys.stdout.write(probe_from_ini(argv[1])) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv)) diff --git a/lib/python/mtc/hal_items.py b/lib/python/mtc/hal_items.py new file mode 100644 index 00000000000..bd7cfdb5907 --- /dev/null +++ b/lib/python/mtc/hal_items.py @@ -0,0 +1,179 @@ +# 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 + +# Expose arbitrary HAL pins/signals as MTConnect data items. +# +# Users declare one HAL_ITEM line per data item in the [MTCONNECT] section; each +# maps a HAL pin, signal or parameter (read with hal.get_value) to a standard +# MTConnect SAMPLE data item. Because the values are folded into the shared +# observation registry, they appear in /probe, /current, /sample and over MQTT +# and SHDR automatically. +# +# [MTCONNECT] +# HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle +# HAL_ITEM = pin=hm2.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS +# +# Only standard MTConnect SAMPLE types are accepted (custom types would need the +# LinuxCNC extension schema and cannot be represented in the base MTConnectStreams +# schema); category is SAMPLE only in this release. Invalid declarations are +# skipped with a warning -- they never abort the agent. + +import re +import sys +from dataclasses import dataclass + +# Standard MTConnect 1.7 SAMPLE data-item types accepted for HAL items +# (verified against the MTConnectDevices_1.7 schema enumeration). +SAMPLE_TYPES = frozenset({ + "LOAD", "TEMPERATURE", "PRESSURE", "VOLTAGE", "VOLT_AMPERE", "AMPERAGE", + "WATTAGE", "FREQUENCY", "DISPLACEMENT", "VELOCITY", "ACCELERATION", "ANGLE", + "ANGULAR_VELOCITY", "ANGULAR_ACCELERATION", "TORQUE", "POWER_FACTOR", + "FILL_LEVEL", "HUMIDITY_RELATIVE", "CONCENTRATION", "FLOW", "MASS", + "RESISTANCE", "SOUND_LEVEL", "STRAIN", "TILT", "VISCOSITY", "PH", + "CAPACITY_FLUID", "LINEAR_FORCE", "VOLTAGE_DC", "VOLTAGE_AC", "AMPERAGE_DC", + "AMPERAGE_AC", "PROCESS_TIMER", "POSITION", "ROTARY_VELOCITY", +}) + +_NCNAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") + + +@dataclass +class HalItem: + pin: str # HAL pin/signal/param name for hal.get_value + id: str # MTConnect DataItem id (also the MQTT/SHDR key) + type: str # standard MTConnect SAMPLE type + units: str = None + name: str = None + subType: str = None + component: str = "sensors" # host component (default: a generic Sensor) + + +def _warn(msg, log): + (log or (lambda m: sys.stderr.write("warning: " + m + "\n")))(msg) + + +def parse_hal_items(ini, log=None): + """Parse [MTCONNECT] HAL_ITEM declarations into validated HalItems. + + Invalid or unsupported declarations are skipped with a warning so a config + typo never prevents the agent from starting. + """ + items, seen = [], set() + for raw in ini.findall("MTCONNECT", "HAL_ITEM"): + item = _parse_one(raw, log) + if item is None: + continue + if item.id in seen: + _warn("HAL_ITEM id=%s duplicated; keeping the first" % item.id, log) + continue + seen.add(item.id) + items.append(item) + return items + + +def _parse_one(raw, log): + fields = {} + for part in str(raw).split(","): + if "=" in part: + key, value = part.split("=", 1) + fields[key.strip().lower()] = value.strip() + + pin, did, typ = fields.get("pin"), fields.get("id"), fields.get("type") + if not (pin and did and typ): + _warn("HAL_ITEM ignored (need pin=, id= and type=): %r" % raw, log) + return None + if not _NCNAME.match(did): + _warn("HAL_ITEM id=%r is not a valid name (letters, digits, _.- ; " + "must not start with a digit)" % did, log) + return None + cat = (fields.get("category") or "SAMPLE").upper() + if cat != "SAMPLE": + _warn("HAL_ITEM id=%s: only category=SAMPLE is supported; ignored" % did, log) + return None + typ = typ.upper() + if typ not in SAMPLE_TYPES: + _warn("HAL_ITEM id=%s: type=%s is not a supported standard MTConnect " + "SAMPLE type; ignored. Supported types: %s" + % (did, typ, ", ".join(sorted(SAMPLE_TYPES))), log) + return None + return HalItem(pin=pin, id=did, type=typ, units=(fields.get("units") or None), + name=(fields.get("name") or None), + subType=(fields.get("subtype") or None), + component=(fields.get("component") or "sensors")) + + +def resolve_component(component, model): + """Map a HAL_ITEM component= value to (comp_id, comp_type, comp_name). + + Defaults to a generic Sensor component; also targets the spindle, the + controller, the path, or a configured axis by letter. Unknown targets fall + back to the generic Sensor. + """ + key = (component or "sensors").strip().lower() + if key in ("", "sensors", "sensor"): + return ("sensors", "Sensor", "sensors") + if key == "spindle": + return ("spindle", "Rotary", "S") + if key in ("controller", "ctrl"): + return ("ctrl", "Controller", "controller") + if key == "path": + return ("path", "Path", "path") + letter = key[-1].upper() if key.startswith("axis_") else key.upper() + for axis in model.axes: + if axis.letter == letter: + kind = "Linear" if axis.kind == "LINEAR" else "Rotary" + return ("axis_%s" % letter.lower(), kind, letter) + return ("sensors", "Sensor", "sensors") + + +class HalSource: + """Read the declared HAL items each poll via hal.get_value. + + Import-safe: without the hal extension (offline tests) it yields nothing. + A pin that is not yet present (e.g. loaded after us) is skipped and warned + about once, then picked up automatically once it appears. + """ + + def __init__(self, items): + self.items = items or [] + self._hal = None + self._warned = set() + if self.items: + try: + import hal + self._hal = hal + except Exception: + self._hal = None + + def sample_values(self): + if not (self._hal and self.items): + return {} + out = {} + for it in self.items: + try: + value = self._hal.get_value(it.pin) + except Exception: + if it.pin not in self._warned: + self._warned.add(it.pin) + sys.stderr.write("warning: HAL_ITEM pin %r not readable " + "(not present yet?)\n" % it.pin) + continue + if isinstance(value, bool): + value = 1 if value else 0 + elif isinstance(value, float): + value = round(value, 6) + out[it.id] = value + return out diff --git a/lib/python/mtc/http_agent.py b/lib/python/mtc/http_agent.py new file mode 100644 index 00000000000..a78aaac181f --- /dev/null +++ b/lib/python/mtc/http_agent.py @@ -0,0 +1,163 @@ +# 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 + +# Embedded lightweight MTConnect HTTP agent. +# +# Serves the four MTConnect endpoints directly from an AgentState, with no +# dependency on the official cppagent: +# GET /probe -> MTConnectDevices +# GET /current -> MTConnectStreams (latest value of each DataItem) +# GET /sample?from=&count= -> MTConnectStreams (sequence range) +# GET /assets -> MTConnectAssets (CuttingTool assets) +# +# Runs in a background daemon thread so the agent's poll loop keeps the main +# thread. + +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs +from xml.sax.saxutils import escape, quoteattr + +from .agent import OutOfRange, now_iso + +_XML = "application/xml; charset=utf-8" + + +def _error_document(code, message): + # code and message are escaped: message embeds the raw request route, so an + # unescaped '<'/'&'/'"' would otherwise break well-formedness or let a + # crafted route forge sibling elements. quoteattr() supplies the attribute + # quotes itself. + return ( + '\n' + '\n' + '
\n' + ' %s\n' + '\n' + % (now_iso(), quoteattr(str(code)), escape(str(message))) + ) + + +def make_handler(agent, enable_twin=False): + class Handler(BaseHTTPRequestHandler): + server_version = "linuxcnc-mtconnect/0.1" + + def _send(self, body, status=200, content_type=_XML): + data = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + parsed = urlparse(self.path) + route = parsed.path.rstrip("/") or "/" + try: + if route in ("/", "/probe"): + self._send(agent.probe_document()) + elif route == "/current": + self._send(agent.current_document()) + elif route == "/sample": + q = parse_qs(parsed.query) + from_seq = _int(q.get("from"), None) + count = _int(q.get("count"), 100) + self._send(agent.sample_document(from_seq, count)) + elif route == "/assets": + self._send(agent.assets_document()) + elif route in ("/mtconnect-linuxcnc-1.xsd", + "/schemas/mtconnect-linuxcnc-1.xsd"): + result = agent.extension_schema() + if result is None: + self._send(_error_document("NOT_FOUND", + "extension schema not bundled"), status=404) + else: + data, content_type = result + self._send(data, content_type=content_type) + elif route == "/twin" or route.startswith("/three/"): + # The digital twin (viewer + its three.js) is opt-in. + if not enable_twin: + self._send(_error_document("UNSUPPORTED", + "digital twin disabled; enable with " + "[MTCONNECT]ENABLE_TWIN=1"), status=404) + elif route == "/twin": + html = agent.twin_html() + if html is None: + self._send(_error_document("NOT_FOUND", + "twin viewer not bundled"), status=404) + else: + self._send(html, content_type="text/html; charset=utf-8") + else: # /three/ -> the system three.js (libjs-three) + result = agent.three_file(route[len("/three/"):]) + if result is None: + self._send(_error_document("NOT_FOUND", + "no such three.js file: %s (is libjs-three " + "installed?)" % route), status=404) + else: + data, content_type = result + self._send(data, content_type=content_type) + elif route.startswith("/models/"): + result = agent.model_file(route[len("/models/"):]) + if result is None: + self._send(_error_document("NOT_FOUND", + "no such model: %s" % route), status=404) + else: + data, content_type = result + self._send(data, content_type=content_type) + else: + self._send(_error_document("UNSUPPORTED", + "unsupported request: %s" % route), status=404) + except OutOfRange as exc: + self._send(_error_document("OUT_OF_RANGE", str(exc)), status=406) + except Exception as exc: # keep the server alive on any handler bug + self._send(_error_document("INTERNAL_ERROR", str(exc)), status=500) + + def log_message(self, fmt, *args): + # Quieter than the default stderr access log. + return + + return Handler + + +def _int(values, default): + if not values: + return default + try: + return int(values[0]) + except (ValueError, TypeError): + return default + + +class HttpAgent: + # Default to loopback; exposing the agent on the LAN is an explicit opt-in + # (INI [MTCONNECT]HTTP_BIND) so a machine isn't published by accident. + def __init__(self, agent, host="127.0.0.1", port=5000, enable_twin=False): + self.httpd = ThreadingHTTPServer((host, port), + make_handler(agent, enable_twin)) + self.thread = threading.Thread(target=self.httpd.serve_forever, + name="mtconnect-http", daemon=True) + + @property + def port(self): + return self.httpd.server_address[1] + + def start(self): + self.thread.start() + + def stop(self): + self.httpd.shutdown() + self.httpd.server_close() diff --git a/lib/python/mtc/ini_reader.py b/lib/python/mtc/ini_reader.py new file mode 100644 index 00000000000..41b1f109b32 --- /dev/null +++ b/lib/python/mtc/ini_reader.py @@ -0,0 +1,55 @@ +# 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 + +# INI access for the MTConnect agent. +# +# A thin wrapper over LinuxCNC's own INI parser (linuxcnc.ini) so semantics match +# the running machine exactly -- in particular, LinuxCNC treats everything after +# '=' as the value (inline ';'/'#' are NOT comment delimiters). Adds only typed +# convenience helpers and default handling. + + +class IniReader: + def __init__(self, path): + import linuxcnc + self.path = path + self._ini = linuxcnc.ini(path) + + def find(self, section, key, default=None): + """Return the first value for section/key as a str, or default.""" + val = self._ini.find(section, key) + return default if val is None else val + + def findall(self, section, key): + """Return every value for a (possibly repeated) section/key.""" + return list(self._ini.findall(section, key)) + + def has_section(self, section): + return bool(self._ini.hassection(section)) + + # -- typed helpers ------------------------------------------------------- + + def find_float(self, section, key, default=None): + try: + return float(self.find(section, key)) + except (TypeError, ValueError): + return default + + def find_int(self, section, key, default=None): + try: + return int(float(self.find(section, key))) + except (TypeError, ValueError): + return default diff --git a/lib/python/mtc/kinematics.py b/lib/python/mtc/kinematics.py new file mode 100644 index 00000000000..866facfa08d --- /dev/null +++ b/lib/python/mtc/kinematics.py @@ -0,0 +1,166 @@ +# 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 + +# Build a kinematic description of a LinuxCNC machine from its INI file. +# +# The model captures everything a downstream consumer (e.g. a FreeCAD Path +# plugin) needs to auto-configure a machine: axis list and type, travel limits, +# home positions, kinematics module/type, and the joint <-> axis mapping. +# +# LinuxCNC distinguishes AXES (Cartesian DOF, letters X Y Z A B C U V W, +# configured in [AXIS_*]) from JOINTS (physical motors, configured in +# [JOINT_n]). The kins module's coordinates= string ties joint N to an axis +# letter; for identity/trivial kinematics this mirrors [TRAJ]COORDINATES. The +# joint->axis mapping here follows map_coordinates_to_jnumbers() in +# src/emc/kinematics/kins_util.c. + +from dataclasses import dataclass, field + +AXIS_LETTERS = "XYZABCUVW" +LINEAR_LETTERS = "XYZUVW" +ANGULAR_LETTERS = "ABC" + +# Unit direction vector for each axis in the machine coordinate frame. +# Linear axes translate along the vector; rotary axes rotate about it. +AXIS_VECTORS = { + "X": (1.0, 0.0, 0.0), "Y": (0.0, 1.0, 0.0), "Z": (0.0, 0.0, 1.0), + "A": (1.0, 0.0, 0.0), "B": (0.0, 1.0, 0.0), "C": (0.0, 0.0, 1.0), + "U": (1.0, 0.0, 0.0), "V": (0.0, 1.0, 0.0), "W": (0.0, 0.0, 1.0), +} + +# Human-readable name for LinuxCNC's KINEMATICS_TYPE enum +# (src/emc/kinematics/kinematics.h). +KINEMATICS_TYPE_NAMES = { + 1: "IDENTITY", + 2: "FORWARD_ONLY", + 3: "INVERSE_ONLY", + 4: "BOTH", +} + + +@dataclass +class Axis: + letter: str # X..W + kind: str # "LINEAR" or "ANGULAR" + vector: tuple # unit direction / rotation axis + min_limit: float = None # soft travel limit (machine units / deg) + max_limit: float = None + + +@dataclass +class Joint: + number: int + kind: str # "LINEAR" or "ANGULAR" + axis: str = None # mapped axis letter, if known + min_limit: float = None + max_limit: float = None + home: float = None + home_offset: float = None + + +@dataclass +class KinematicModel: + kins_module: str # e.g. "trivkins", "xyzac-trt-kins" + kins_params: str # remainder of the [KINS]KINEMATICS line + coordinates: str # packed axis letters, e.g. "XYZAC" + joints_count: int + axes: list = field(default_factory=list) + joints: list = field(default_factory=list) + kinematics_type: str = None # IDENTITY/BOTH/... filled from stat when live + + def joint_axis_map(self): + """Return {joint_number: axis_letter} for mapped joints.""" + return {j.number: j.axis for j in self.joints if j.axis} + + +def _dedupe(seq): + seen = set() + out = [] + for item in seq: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +def _axis_kind(letter): + return "ANGULAR" if letter in ANGULAR_LETTERS else "LINEAR" + + +def _packed_coordinates(raw): + """Normalize a COORDINATES value ('X Y Z' or 'XYZAC') to 'XYZ...'.""" + if not raw: + return "" + return "".join(ch for ch in raw.upper() if ch in AXIS_LETTERS) + + +def build_model(ini): + """Build a KinematicModel from an IniReader.""" + kins_line = (ini.find("KINS", "KINEMATICS", "trivkins") or "trivkins").strip() + parts = kins_line.split() + kins_module = parts[0] if parts else "trivkins" + kins_params = " ".join(parts[1:]) + + traj_coords = _packed_coordinates(ini.find("TRAJ", "COORDINATES", "XYZ")) + + # The joint mapping is driven by the kins coordinates= param when present + # (e.g. gantry "XYZZ"); otherwise it mirrors the trajectory coordinates. + coord_map = _coordinates_param(kins_params) or traj_coords + + joints_count = ini.find_int("KINS", "JOINTS", len(traj_coords)) or len(traj_coords) + + axes = [] + for letter in _dedupe(traj_coords): + section = "AXIS_%s" % letter + axes.append(Axis( + letter=letter, + kind=_axis_kind(letter), + vector=AXIS_VECTORS.get(letter, (0.0, 0.0, 0.0)), + min_limit=ini.find_float(section, "MIN_LIMIT"), + max_limit=ini.find_float(section, "MAX_LIMIT"), + )) + + joints = [] + for jnum in range(joints_count): + section = "JOINT_%d" % jnum + letter = coord_map[jnum] if jnum < len(coord_map) else None + kind = (ini.find(section, "TYPE", "LINEAR") or "LINEAR").strip().upper() + joints.append(Joint( + number=jnum, + kind=kind, + axis=letter, + min_limit=ini.find_float(section, "MIN_LIMIT"), + max_limit=ini.find_float(section, "MAX_LIMIT"), + home=ini.find_float(section, "HOME"), + home_offset=ini.find_float(section, "HOME_OFFSET"), + )) + + return KinematicModel( + kins_module=kins_module, + kins_params=kins_params, + coordinates=coord_map, + joints_count=joints_count, + axes=axes, + joints=joints, + ) + + +def _coordinates_param(params): + """Extract a 'coordinates=XYZ' value from a kins parameter string.""" + for token in params.split(): + if token.lower().startswith("coordinates="): + return _packed_coordinates(token.split("=", 1)[1]) + return "" diff --git a/lib/python/mtc/lcnc_source.py b/lib/python/mtc/lcnc_source.py new file mode 100644 index 00000000000..7ec93b19d72 --- /dev/null +++ b/lib/python/mtc/lcnc_source.py @@ -0,0 +1,262 @@ +# 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 + +# Read live machine state from LinuxCNC and normalize it for MTConnect. +# +# Wraps linuxcnc.stat() (poll-on-demand) and produces: +# * sample_values(): {dataitem_id: value} for the streaming DataItems +# * tool_assets(): list of CuttingToolAsset for /assets +# +# The module is import-safe without the linuxcnc extension so the rest of the +# agent (and its tests) can be exercised offline. The value/enum mappings +# mirror src/emc/usr_intf/axis/extensions/emcmodule.cc. + +import os +from dataclasses import dataclass + +from .observations import axis_index +from .kinematics import LINEAR_LETTERS + +UNAVAILABLE = "UNAVAILABLE" + +# linuxcnc task_state / task_mode / interp_state enum values. Hardcoded so +# this module stays importable without the extension; they are stable ABI. +_STATE_ESTOP = 1 +_MODE_MANUAL, _MODE_AUTO, _MODE_MDI = 1, 2, 3 +_INTERP_IDLE, _INTERP_READING, _INTERP_PAUSED, _INTERP_WAITING = 1, 2, 3, 4 + + +@dataclass +class ToolAsset: + tool_no: int + pocket: int + in_spindle: bool + diameter: float = 0.0 + length_z: float = 0.0 # Z length offset + length_x: float = 0.0 # X length offset (lathe) + orientation: int = 0 + comment: str = "" + + @property + def asset_id(self): + return "tool-%d" % self.tool_no + + +class LcncSource: + def __init__(self, model, config): + self.model = model + self.config = config + # Native-length -> millimetre factor; every emitted length is canonical. + self._lin = getattr(config, "linear_scale", 1.0) + self.stat = None + self._live = False # True once a poll has succeeded against a running instance + try: + import linuxcnc + self.stat = linuxcnc.stat() + except Exception as exc: # no extension installed + self._import_error = exc + + def available(self): + return self.stat is not None and self._live + + def poll(self): + # stat() constructs even with no running LinuxCNC; poll() is what fails + # ("emcStatusBuffer invalid"). Tolerate it so the agent can start before + # task is up (and so offline tooling works) -- data appears once it does. + if self.stat is None: + return + try: + self.stat.poll() + self._live = True + except Exception: + self._live = False + + # -- streaming values ---------------------------------------------------- + + def sample_values(self): + """Return {dataitem_id: value}; empty (-> UNAVAILABLE) when not live.""" + if not self.available(): + return {} + s = self.stat + vals = {} + + vals["avail"] = "AVAILABLE" + vals["estop"] = "TRIGGERED" if _get(s, "task_state") == _STATE_ESTOP else "ARMED" + vals["mode"] = _MODE_NAMES.get(_get(s, "task_mode"), UNAVAILABLE) + vals["execution"] = _execution(s) + vals["program"] = _basename(_get(s, "file")) or UNAVAILABLE + # LINE_NUMBER subType=ACTUAL is the line being *executed*, so use + # motion_line (the motion controller's current segment, + # motion.traj.id) -- current_line is the interpreter read-ahead position, + # which races to the end of the program while the machine lags behind. + vals["line"] = _get(s, "motion_line", 0) + vals["pathfeed"] = round(_get(s, "current_vel", 0.0) * self._lin, 6) + vals["coolant_flood"] = "ON" if _get(s, "flood", 0) else "OFF" + vals["coolant_mist"] = "ON" if _get(s, "mist", 0) else "OFF" + vals["feedovr"] = round(_get(s, "feedrate", 1.0) * 100.0, 1) + vals["toolnum"] = _get(s, "tool_in_spindle", 0) + tool_no = _get(s, "tool_in_spindle", 0) + vals["toolasset"] = "tool-%d" % tool_no if tool_no and tool_no > 0 else UNAVAILABLE + + vals["workoffset"] = self._work_offsets(s) + vals["tooloffset"] = self._tool_offset(s) + vals["xyrotation"] = round(_get(s, "rotation_xy", 0.0) or 0.0, 6) + + actual = _get(s, "actual_position") or () + commanded = _get(s, "position") or () + for axis in self.model.axes: + idx = axis_index(axis.letter) + aid = axis.letter.lower() + scale = self._lin if axis.kind == "LINEAR" else 1.0 # angles stay deg + if idx < len(actual): + vals["pos_%s" % aid] = round(actual[idx] * scale, 6) + if idx < len(commanded): + vals["poscmd_%s" % aid] = round(commanded[idx] * scale, 6) + + spindles = _get(s, "spindle") or () + if spindles: + sp = spindles[0] + speed = sp.get("speed", 0.0) + override = sp.get("override", 1.0) if sp.get("override_enabled", True) else 1.0 + vals["spdl_speed_cmd"] = round(speed, 3) + vals["spdl_speed"] = round(speed * override, 3) + direction = sp.get("direction", 0) + vals["spdl_dir"] = ("CLOCKWISE" if direction > 0 + else "COUNTER_CLOCKWISE" if direction < 0 else "UNAVAILABLE") + vals["spdl_mode"] = "SPINDLE" + return vals + + def _work_offsets(self, s): + """Active G5x work offset (+ G92) as {name: {axis: value}}.""" + letters = [a.letter for a in self.model.axes] + table = {} + name = _G5X_NAMES.get(_get(s, "g5x_index", 1), "G54") + g5x = _get(s, "g5x_offset") or () + table[name] = _pose_cells(g5x, letters, self._lin) + g92 = _get(s, "g92_offset") or () + g92_cells = _pose_cells(g92, letters, self._lin) + if any(v != 0.0 for v in g92_cells.values()): + table["G92"] = g92_cells + return table + + def _tool_offset(self, s): + """Applied tool length offset (G43) as {tool_key: {axis: value}}.""" + letters = [a.letter for a in self.model.axes] + cells = _pose_cells(_get(s, "tool_offset") or (), letters, self._lin) + tool = _get(s, "tool_in_spindle", 0) + key = "T%d" % tool if tool and tool > 0 else "G43" + return {key: cells} + + # -- assets -------------------------------------------------------------- + + def tool_assets(self): + """Return the current tool table as ToolAsset entries.""" + if not self.available(): + return [] + s = self.stat + in_spindle = _get(s, "tool_in_spindle", 0) + assets = [] + for entry in (_get(s, "tool_table") or ()): + tool_no = getattr(entry, "id", 0) + if tool_no <= 0: + continue # index 0 is the "fake pocket" spindle mirror + info = self._toolinfo(tool_no) + assets.append(ToolAsset( + tool_no=tool_no, + # tool_table entries carry no real pocket; only toolinfo() does. + # On a random toolchanger the pocket != the tool number. + pocket=_as_int(info.get("pocketno"), tool_no), + in_spindle=(tool_no == in_spindle), + diameter=getattr(entry, "diameter", 0.0) * self._lin, + length_z=getattr(entry, "zoffset", 0.0) * self._lin, + length_x=getattr(entry, "xoffset", 0.0) * self._lin, + orientation=getattr(entry, "orientation", 0), + comment=(info.get("comment", "") or "").strip(), + )) + return assets + + def _toolinfo(self, tool_no): + """Return stat.toolinfo(tool_no) as a dict, or {} if unavailable. + + stat.tool_table entries omit the comment (the struct-sequence binding + drops it) and carry no real pocket number; stat.toolinfo(toolno) returns + a dict with both. toolinfo rejects toolno==0 and may raise before + tooldata is ready. + """ + info = getattr(self.stat, "toolinfo", None) + if info is None or tool_no <= 0: + return {} + try: + return info(tool_no) or {} + except Exception: + return {} + + +_MODE_NAMES = { + _MODE_MANUAL: "MANUAL", + _MODE_AUTO: "AUTOMATIC", + _MODE_MDI: "MANUAL_DATA_INPUT", +} + + +def _execution(stat): + interp = _get(stat, "interp_state", _INTERP_IDLE) + if _get(stat, "task_paused", 0): + return "INTERRUPTED" + if interp == _INTERP_IDLE: + return "READY" + if interp == _INTERP_WAITING: + return "ACTIVE" + if interp in (_INTERP_READING, _INTERP_PAUSED): + return "ACTIVE" if interp == _INTERP_READING else "INTERRUPTED" + return "ACTIVE" + + +_G5X_NAMES = {1: "G54", 2: "G55", 3: "G56", 4: "G57", 5: "G58", + 6: "G59", 7: "G59.1", 8: "G59.2", 9: "G59.3"} + + +def _pose_cells(pose, letters, lin_scale=1.0): + """Map an EmcPose 9-tuple to {axis_letter: value} for configured axes. + + Linear components (X Y Z U V W) are converted to millimetres; angular + components (A B C) are left in degrees. + """ + cells = {} + for letter in letters: + idx = axis_index(letter) + if idx < len(pose): + scale = lin_scale if letter in LINEAR_LETTERS else 1.0 + cells[letter] = round(pose[idx] * scale, 6) + return cells + + +def _get(stat, attr, default=None): + try: + return getattr(stat, attr) + except Exception: + return default + + +def _basename(path): + return os.path.basename(path) if path else "" + + +def _as_int(value, default): + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/lib/python/mtc/models.py b/lib/python/mtc/models.py new file mode 100644 index 00000000000..ec4575d86c2 --- /dev/null +++ b/lib/python/mtc/models.py @@ -0,0 +1,285 @@ +# 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 + +# Solid-model (3D geometry) configuration for the MTConnect twin. +# +# The MTConnect standard references geometry from the /probe document via +# pointing to an external mesh file (OBJ/STL/glTF); the +# mesh is fetched once by the viewer and animated using the streamed positions +# plus the / kinematics. Geometry is NEVER streamed. +# +# This module reads user-supplied mesh paths from the [MTCONNECT] section and +# builds the registry the embedded agent serves under /models/: +# +# [MTCONNECT] +# MODEL_DIR = models ; base dir for relative paths (default: INI dir) +# MODEL_BASE = frame.glb ; static machine frame / column +# MODEL_X = x_saddle.glb ; link that moves with X +# MODEL_Y = y_table.glb +# MODEL_Z = z_head.glb +# MODEL_SPINDLE = spindle.glb +# MODEL_CHAIN = X Y Z ; nesting order (default: COORDINATES order) +# +# Geometry is served in the MTConnect canonical length unit (millimetre): the +# MTConnect SolidModel element carries no per-mesh unit, and all streamed +# positions are millimetres, so user-supplied meshes must be authored in mm. +# +# Alternatively, MODEL_AUTO = 1 generates simple placeholder box meshes for the +# base and each axis directly from the travel limits, so any machine gets a +# functional twin with no mesh files at all. Auto meshes are generated in mm +# (travel limits are scaled from the machine's native unit) and served from +# memory. Files and MODEL_AUTO can be mixed: an explicit MODEL_ overrides +# the generated box for that link. + +import os +from dataclasses import dataclass, field + +# file extension -> (MTConnect mediaType, HTTP content-type) +_EXT = { + ".stl": ("STL", "model/stl"), + ".obj": ("OBJ", "model/obj"), + ".gltf": ("GLTF", "model/gltf+json"), + ".glb": ("GLTF", "model/gltf-binary"), + ".ply": ("PLY", "application/octet-stream"), + ".step": ("STEP", "application/step"), + ".stp": ("STEP", "application/step"), +} + + +@dataclass +class MeshRef: + name: str # served basename, e.g. "z_head.glb" + path: str # absolute path on disk + media: str # MTConnect mediaType, e.g. "GLTF" + content_type: str + exists: bool + + +@dataclass +class MachineModels: + units: str = "MILLIMETER" + base: MeshRef = None + spindle: MeshRef = None + axis: dict = field(default_factory=dict) # axis letter -> MeshRef + chain: list = field(default_factory=list) # ordered component ids (base->tip) + parents: dict = field(default_factory=dict) # comp_id -> parent comp_id + invert: set = field(default_factory=set) # axis letters that move opposite + served: dict = field(default_factory=dict) # served name -> MeshRef + generated: dict = field(default_factory=dict) # served name -> STL text (auto) + + def enabled(self): + return bool(self.base or self.spindle or self.axis) + + def parent_of(self, comp_id): + """Component id whose motion the given link hangs off ('dev_base' = root).""" + return self.parents.get(comp_id) + + +def _media_for(path): + return _EXT.get(os.path.splitext(path)[1].lower(), ("OBJ", "model/obj")) + + +def build_models(ini, model, config=None): + """Build MachineModels from the [MTCONNECT] section and kinematic model.""" + model_dir = ini.find("MTCONNECT", "MODEL_DIR") or os.path.dirname( + os.path.abspath(ini.path)) + # Geometry is served in the MTConnect canonical length unit (millimetre); + # the MTConnect SolidModel element carries no per-mesh unit, so served + # meshes must already be in millimetres. Auto-generated boxes (below) are + # derived from the travel limits, which are in the machine's native unit, so + # they are scaled to millimetres by lin_scale. + lin_scale = getattr(config, "linear_scale", 1.0) if config else 1.0 + mm = MachineModels(units="MILLIMETER") + auto = _truthy(ini.find("MTCONNECT", "MODEL_AUTO", "0")) + env = _envelope(model) + + def register(key): + raw = ini.find("MTCONNECT", key) + if not raw: + return None + raw = raw.strip() + path = raw if os.path.isabs(raw) else os.path.join(model_dir, raw) + media, content = _media_for(path) + name = os.path.basename(path) + ref = MeshRef(name=name, path=path, media=media, content_type=content, + exists=os.path.isfile(path)) + mm.served[name] = ref + return ref + + def auto_ref(comp_id, box): + name = "%s.stl" % comp_id + ref = MeshRef(name=name, path=None, media="STL", + content_type="model/stl", exists=True) + mm.served[name] = ref + mm.generated[name] = _box_stl(_scale_box(box, lin_scale)) + return ref + + mm.base = register("MODEL_BASE") or register("MODEL_DEVICE") + if not mm.base and auto: + mm.base = auto_ref("dev_base", _base_box(env)) + mm.spindle = register("MODEL_SPINDLE") + for axis in model.axes: + ref = register("MODEL_%s" % axis.letter) + if not ref and auto: + ref = auto_ref("axis_%s" % axis.letter.lower(), _axis_box(axis, env)) + if ref: + mm.axis[axis.letter] = ref + + # Nesting order: base frame, then each moving axis, then the spindle tip. + chain_letters = _chain_letters(ini, model) + mm.chain = ["dev_base"] + for letter in chain_letters: + if letter in mm.axis: + mm.chain.append("axis_%s" % letter.lower()) + if mm.spindle: + mm.chain.append("spindle") + + # Default parent = predecessor in the chain (serial); overridable per link + # with MODEL_PARENT_/MODEL_PARENT_SPINDLE for branched machines (e.g. a + # knee mill: X/Y carry the work, Z carries the tool, both rooted at the base). + for i, cid in enumerate(mm.chain): + if i > 0: + mm.parents[cid] = mm.chain[i - 1] + for axis in model.axes: + tok = ini.find("MTCONNECT", "MODEL_PARENT_%s" % axis.letter) + cid = "axis_%s" % axis.letter.lower() + if tok and cid in mm.parents: + mm.parents[cid] = _resolve_parent(tok) + stok = ini.find("MTCONNECT", "MODEL_PARENT_SPINDLE") + if stok and "spindle" in mm.parents: + mm.parents["spindle"] = _resolve_parent(stok) + + # Axes whose link physically moves opposite the reported coordinate (a moving + # table/saddle: LinuxCNC reports tool-relative-to-work, so +X moves table -X). + inv = ini.find("MTCONNECT", "MODEL_INVERT") + if inv: + mm.invert = {c for c in inv.upper() if c.isalpha()} + return mm + + +def _resolve_parent(token): + """Map a MODEL_PARENT_* value to a component id ('dev_base' = machine root).""" + t = token.strip().upper() + if t in ("BASE", "FRAME", "DEVICE", "ROOT", "NONE", ""): + return "dev_base" + if len(t) == 1 and t.isalpha(): + return "axis_%s" % t.lower() + return "dev_base" + + +def _chain_letters(ini, model): + raw = ini.find("MTCONNECT", "MODEL_CHAIN") + if raw: + return [c for c in raw.upper() if c.isalpha()] + # default: order the axes appear in COORDINATES + seen, out = set(), [] + for letter in model.coordinates: + if letter not in seen: + seen.add(letter) + out.append(letter) + return out + + +def _truthy(v): + return str(v).strip().lower() in ("1", "true", "yes", "on") + + +# ---- auto-generated placeholder geometry ---------------------------------- +# +# Boxes are authored in the machine coordinate frame at the all-axes-zero pose +# (the agent's chain then translates/rotates each link). Sizes come +# from the travel limits so the twin roughly matches the machine's proportions. + +def _envelope(model): + ax = {} + for a in model.axes: + lo = a.min_limit if a.min_limit is not None else -50.0 + hi = a.max_limit if a.max_limit is not None else 50.0 + ax[a.letter] = (lo, hi) + + def span(letter, default=100.0): + if letter in ax: + lo, hi = ax[letter] + return max(hi - lo, 1e-6) + return default + + def mid(letter, default=0.0): + if letter in ax: + lo, hi = ax[letter] + return (lo + hi) / 2.0 + return default + + return {"ax": ax, "span": span, "mid": mid} + + +def _base_box(env): + sx, sy = env["span"]("X"), env["span"]("Y") + sz = env["span"]("Z", max(sx, sy)) + zlo = env["ax"].get("Z", (-sz / 2, sz / 2))[0] + t = max(sx, sy, sz) * 0.06 + return (env["mid"]("X"), env["mid"]("Y"), zlo - t / 2, sx * 1.2, sy * 1.2, t) + + +def _axis_box(axis, env): + sx, sy = env["span"]("X"), env["span"]("Y") + sz = env["span"]("Z", max(sx, sy)) + xm, ym = env["mid"]("X"), env["mid"]("Y") + zlo = env["ax"].get("Z", (-sz / 2, sz / 2))[0] + zmid = env["mid"]("Z") + t = max(sx, sy, sz) * 0.04 + L = axis.letter + if axis.kind != "LINEAR": # rotary: a squat disc-ish box + d = min(sx, sy) * 0.5 + return (xm, ym, zmid, d, d, t * 1.5) + if L == "Z" or abs(axis.vector[2]) > 0.5: # vertical linear = quill/tool + w = min(sx, sy) * 0.1 + return (xm, ym, zmid + sz * 0.15, w, w, sz * 0.6) + if L == "X": # table plate + return (xm, ym, zlo + t * 1.6, sx * 0.7, sy * 0.85, t) + if L == "Y": # saddle plate + return (xm, ym, zlo + t * 0.5, sx * 0.85, sy * 0.7, t) + return (xm, ym, zlo + t, sx * 0.6, sy * 0.6, t) # U/V/W + + +def _scale_box(box, scale): + """Scale a (cx,cy,cz,dx,dy,dz) box from native units to millimetres.""" + return tuple(c * scale for c in box) + + +def _box_stl(box, name="link"): + """ASCII STL for an axis-aligned box (cx,cy,cz,dx,dy,dz).""" + cx, cy, cz, dx, dy, dz = box + x0, x1 = cx - dx / 2, cx + dx / 2 + y0, y1 = cy - dy / 2, cy + dy / 2 + z0, z1 = cz - dz / 2, cz + dz / 2 + v = [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), + (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)] + faces = [(0, 2, 1, (0, 0, -1)), (0, 3, 2, (0, 0, -1)), + (4, 5, 6, (0, 0, 1)), (4, 6, 7, (0, 0, 1)), + (0, 1, 5, (0, -1, 0)), (0, 5, 4, (0, -1, 0)), + (3, 7, 6, (0, 1, 0)), (3, 6, 2, (0, 1, 0)), + (0, 4, 7, (-1, 0, 0)), (0, 7, 3, (-1, 0, 0)), + (1, 2, 6, (1, 0, 0)), (1, 6, 5, (1, 0, 0))] + out = ["solid %s" % name] + for a, b, c, n in faces: + out.append("facet normal %g %g %g" % n) + out.append("outer loop") + for i in (a, b, c): + out.append("vertex %g %g %g" % v[i]) + out.append("endloop") + out.append("endfacet") + out.append("endsolid %s" % name) + return "\n".join(out) + "\n" diff --git a/lib/python/mtc/mqtt_agent.py b/lib/python/mtc/mqtt_agent.py new file mode 100644 index 00000000000..17fd46d8ba5 --- /dev/null +++ b/lib/python/mtc/mqtt_agent.py @@ -0,0 +1,118 @@ +# 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 + +# Optional MQTT transport following the standard MTConnect MQTT binding. +# +# Publishes the standard MTConnect response documents to the standard topics: +# /Probe/ (retained) +# /Current/ (at the sample interval) +# /Sample/ (when new observations arrive) +# /Asset// (retained, on asset change) +# +# Reuses paho-mqtt, already used by src/hal/user_comps/mqtt-publisher.py. +# Works with both paho-mqtt 1.x and 2.x. +# +# This is the vendor-neutral MTConnect binding only. A retained Probe on +# /Probe/ is the standard discovery mechanism: an MTConnect +# consumer subscribes and reconstructs the whole device from it. (Home +# Assistant support is a separate, optional bridge -- see the mtconnect-ha-bridge +# contrib -- so that no other project's schema is baked into the core agent.) + + +class MqttAgent: + def __init__(self, agent, broker="localhost", port=1883, prefix="MTConnect", + username=None, password=None, client_id="linuxcnc-mtconnect"): + try: + import paho.mqtt.client as mqtt + except ModuleNotFoundError: + print("error: Missing Python module paho.mqtt.") + print("error: Arch: 'sudo pacman -S python-paho-mqtt'; " + "Debian: 'sudo apt install python3-paho-mqtt'.") + raise + self.agent = agent + self.prefix = prefix.rstrip("/") + self.uuid = agent.config.uuid + self.connected = False # broker link state, mirrored to a HAL pin + self._last_sample_seq = 1 + self._last_asset_sig = None + + # paho 2.x requires an explicit callback API version; 1.x has no such arg. + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, + client_id=client_id) + except AttributeError: + self.client = mqtt.Client(client_id=client_id) + if username: + self.client.username_pw_set(username, password) + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.connect_async(broker, port, keepalive=60) + self.client.loop_start() + print("info: MQTT connecting to %s:%d as '%s' (prefix '%s')" + % (broker, port, username or "anonymous", self.prefix)) + + def _topic(self, kind, suffix=None): + base = "%s/%s/%s" % (self.prefix, kind, self.uuid) + return "%s/%s" % (base, suffix) if suffix else base + + def _on_connect(self, client, userdata, flags, rc, *args): + self.connected = (rc == 0) + if rc == 0: + print("info: MQTT connected; publishing retained Probe to %s" + % self._topic("Probe")) + self.publish_probe() + self.publish_assets() + else: + hint = {1: "unacceptable protocol version", 2: "identifier rejected", + 3: "broker unavailable", 4: "bad username or password", + 5: "not authorized (anonymous refused / bad credentials)"} + print("error: MQTT connect failed (rc=%s: %s)" + % (rc, hint.get(int(rc) if str(rc).isdigit() else -1, "see broker log"))) + + def _on_disconnect(self, client, userdata, rc, *args): + self.connected = False + print("warning: MQTT disconnected (rc=%s)" % rc) + + def publish_probe(self): + self.client.publish(self._topic("Probe"), self.agent.probe_document(), + retain=True) + + def publish_current(self): + self.client.publish(self._topic("Current"), self.agent.current_document()) + + def publish_sample(self): + first, nxt = self.agent.buffer.first_sequence, self.agent.buffer.next_sequence + if nxt <= self._last_sample_seq: + return + start = max(self._last_sample_seq, first) + self.client.publish(self._topic("Sample"), + self.agent.sample_document(start, nxt - start)) + self._last_sample_seq = nxt + + def publish_assets(self): + assets = self.agent.source.tool_assets() + sig = tuple((a.asset_id, a.pocket, a.in_spindle) for a in assets) + if sig == self._last_asset_sig: + return + self._last_asset_sig = sig + doc = self.agent.assets_document() + for asset in assets: + self.client.publish(self._topic("Asset", asset.asset_id), doc, + retain=True) + + def stop(self): + self.client.loop_stop() + self.client.disconnect() diff --git a/lib/python/mtc/observations.py b/lib/python/mtc/observations.py new file mode 100644 index 00000000000..b14a6d5f56e --- /dev/null +++ b/lib/python/mtc/observations.py @@ -0,0 +1,155 @@ +# 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 + +# Shared DataItem registry. +# +# One source of truth for the DataItems exposed by the agent so the /probe +# document (device_model) and the /current and /sample streams (streams) cannot +# drift apart. Each DataItemDef records enough to emit both the +# definition and its streamed observation (element name + component grouping). + +from dataclasses import dataclass + +from .kinematics import AXIS_LETTERS +from .hal_items import resolve_component + +# LinuxCNC extension namespace for DataItems with no standard MTConnect type +# (declared as xmlns:x on the probe and stream roots; types use "x:" prefix). +EXT_NS = "urn:linuxcnc:mtconnect:1" + + +@dataclass +class DataItemDef: + id: str + category: str # SAMPLE / EVENT / CONDITION + type: str # MTConnect type, e.g. POSITION, EXECUTION + comp_id: str # component id this item lives under + comp_type: str # component element, e.g. Linear, Controller, Path + comp_name: str + subType: str = None + units: str = None + name: str = None + representation: str = None # e.g. "TABLE" for WORK_OFFSET + ext: bool = False # extension type: emit as "x:" / + constraints: dict = None # {"minimum": float, "maximum": float} on the probe + native_units: str = None # nativeUnits when the machine unit != canonical + + @property + def element(self): + """CamelCase observation element name, e.g. POSITION -> Position.""" + return "".join(p.capitalize() for p in self.type.split("_")) + + +def build_dataitems(model, config): + """Return the full ordered list of DataItemDefs for a machine model.""" + dev = config.name + dev_id = config.device_id # NCName-safe; matches probe Device id + stream componentId + items = [ + DataItemDef("avail", "EVENT", "AVAILABILITY", dev_id, "Device", dev), + DataItemDef("assetchg", "EVENT", "ASSET_CHANGED", dev_id, "Device", dev), + DataItemDef("assetrm", "EVENT", "ASSET_REMOVED", dev_id, "Device", dev), + DataItemDef("estop", "EVENT", "EMERGENCY_STOP", "ctrl", "Controller", "controller"), + DataItemDef("mode", "EVENT", "CONTROLLER_MODE", "ctrl", "Controller", "controller"), + DataItemDef("execution", "EVENT", "EXECUTION", "path", "Path", "path"), + DataItemDef("program", "EVENT", "PROGRAM", "path", "Path", "path"), + DataItemDef("line", "EVENT", "LINE_NUMBER", "path", "Path", "path", subType="ACTUAL"), + DataItemDef("pathfeed", "SAMPLE", "PATH_FEEDRATE", "path", "Path", "path", + units="MILLIMETER/SECOND", + native_units=("INCH/SECOND" + if config.native_linear_units == "INCH" else None)), + DataItemDef("feedovr", "SAMPLE", "PATH_FEEDRATE", "path", "Path", "path", + subType="OVERRIDE", units="PERCENT"), + DataItemDef("toolnum", "EVENT", "TOOL_NUMBER", "path", "Path", "path"), + DataItemDef("toolasset", "EVENT", "TOOL_ASSET_ID", "path", "Path", "path"), + # Active work coordinate system (G54..G59.3) + G92, as a TABLE keyed by + # the offset name with per-axis Cells. Mirrors g5x_index/g5x_offset. + # WORK_OFFSET is a standard EVENT type; TABLE representation streams as + # . + DataItemDef("workoffset", "EVENT", "WORK_OFFSET", "path", "Path", "path", + representation="TABLE"), + # Applied tool length offset (G43), keyed by active tool. TOOL_OFFSET is + # a standard EVENT type; TABLE representation streams as . + DataItemDef("tooloffset", "EVENT", "TOOL_OFFSET", "path", "Path", "path", + representation="TABLE"), + # Active XY coordinate-system rotation (G10 L2 R). No standard MTConnect + # type exists, so this is a LinuxCNC extension (x:COORDINATE_ROTATION). + # NOTE: extension observations are not representable in the base + # MTConnectStreams schema (it has no extension point) -- see PR plan. + DataItemDef("xyrotation", "SAMPLE", "COORDINATE_ROTATION", "path", "Path", + "path", units="DEGREE", ext=True), + ] + + lin_native = (config.native_linear_units + if config.native_linear_units != config.linear_units else None) + for axis in model.axes: + aid = axis.letter.lower() + cid = "axis_%s" % aid + if axis.kind == "LINEAR": + comp_type, dtype, units, native = "Linear", "POSITION", config.linear_units, lin_native + else: + comp_type, dtype, units, native = "Rotary", "ANGLE", config.angular_units, None + items.append(DataItemDef("pos_%s" % aid, "SAMPLE", dtype, cid, comp_type, + axis.letter, subType="ACTUAL", units=units, + native_units=native)) + items.append(DataItemDef("poscmd_%s" % aid, "SAMPLE", dtype, cid, comp_type, + axis.letter, subType="COMMANDED", units=units, + native_units=native)) + + # Advertise the spindle's usable speed band ([SPINDLE_0] forward velocity + # limits, in RPM) as MTConnect Constraints on the commanded velocity, but + # only when the INI actually sets them (LinuxCNC's default max is ~2.1e9). + spdl_constraints = None + lo, hi = config.spindle_speed_min, config.spindle_speed_max + if lo is not None or hi is not None: + spdl_constraints = {} + if lo is not None: + spdl_constraints["minimum"] = lo + if hi is not None: + spdl_constraints["maximum"] = hi + + items += [ + DataItemDef("spdl_speed", "SAMPLE", "ROTARY_VELOCITY", "spindle", "Rotary", "S", + subType="ACTUAL", units="REVOLUTION/MINUTE"), + DataItemDef("spdl_speed_cmd", "SAMPLE", "ROTARY_VELOCITY", "spindle", "Rotary", "S", + subType="COMMANDED", units="REVOLUTION/MINUTE", + constraints=spdl_constraints), + DataItemDef("spdl_mode", "EVENT", "ROTARY_MODE", "spindle", "Rotary", "S"), + DataItemDef("spdl_dir", "EVENT", "DIRECTION", "spindle", "Rotary", "S", subType="ROTARY"), + ] + + # Coolant system (iocontrol flood/mist). LinuxCNC exposes plain on/off with + # no standard MTConnect enum, so these are extension events (x:FLOOD/x:MIST). + items += [ + DataItemDef("coolant_flood", "EVENT", "FLOOD", "coolant", "Coolant", + "coolant", ext=True), + DataItemDef("coolant_mist", "EVENT", "MIST", "coolant", "Coolant", + "coolant", ext=True), + ] + + # User-declared HAL pins ([MTCONNECT]HAL_ITEM), hosted on the component each + # names (default: a generic Sensor). Same registry -> they flow to /probe, + # /current, /sample, MQTT and SHDR automatically. + for it in getattr(config, "hal_items", None) or []: + comp_id, comp_type, comp_name = resolve_component(it.component, model) + items.append(DataItemDef(it.id, "SAMPLE", it.type, comp_id, comp_type, + comp_name, subType=it.subType, units=it.units, + name=it.name)) + return items + + +def axis_index(letter): + """Index of an axis letter into a 9-element (XYZABCUVW) position tuple.""" + return AXIS_LETTERS.index(letter) diff --git a/lib/python/mtc/shdr_agent.py b/lib/python/mtc/shdr_agent.py new file mode 100644 index 00000000000..188f6539756 --- /dev/null +++ b/lib/python/mtc/shdr_agent.py @@ -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 +# ||[||...] +# 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: '|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() diff --git a/lib/python/mtc/streams.py b/lib/python/mtc/streams.py new file mode 100644 index 00000000000..56b3207a9f3 --- /dev/null +++ b/lib/python/mtc/streams.py @@ -0,0 +1,278 @@ +# 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 + +# MTConnectStreams (/current, /sample) and MTConnectAssets (/assets) builders, +# backed by a sequence-numbered circular observation buffer. +# +# The buffer mirrors the MTConnect agent contract: every value change is +# assigned a monotonic sequence number and timestamp; /current returns the +# latest value of each DataItem, /sample returns a contiguous sequence range. + +import xml.etree.ElementTree as ET +from collections import deque, OrderedDict + +from .observations import EXT_NS + +STREAMS_NS = "urn:mtconnect.org:MTConnectStreams:1.7" +ASSETS_NS = "urn:mtconnect.org:MTConnectAssets:1.7" +SCHEMA_VERSION = "1.7" + +# Namespaces are declared as literal xmlns attributes on each document root +# (see _streams_doc / assets_xml) with plain tag names, avoiding ElementTree's +# global register_namespace() state which cannot hold several distinct default +# namespaces at once. + + +class Observation: + __slots__ = ("seq", "timestamp", "di", "value") + + def __init__(self, seq, timestamp, di, value): + self.seq = seq + self.timestamp = timestamp + self.di = di # DataItemDef + self.value = value + + +class ObservationBuffer: + def __init__(self, dataitems, max_size=131072): + self.dataitems = dataitems + self._by_id = {di.id: di for di in dataitems} + self._next_seq = 1 + self._ring = deque(maxlen=max_size) + self._latest = {} # id -> Observation + self._last_value = {} # id -> value (change detection) + + @property + def next_sequence(self): + return self._next_seq + + @property + def first_sequence(self): + return self._ring[0].seq if self._ring else self._next_seq + + @property + def last_sequence(self): + return self._next_seq - 1 + + def ingest(self, values, timestamp): + """Record changed values; returns count of new observations.""" + count = 0 + for di in self.dataitems: + if di.id not in values: + continue + value = values[di.id] + if self._last_value.get(di.id, _MISSING) == value: + continue + obs = Observation(self._next_seq, timestamp, di, value) + self._next_seq += 1 + self._ring.append(obs) + self._latest[di.id] = obs + self._last_value[di.id] = value + count += 1 + return count + + def current(self): + """Latest observation per DataItem (UNAVAILABLE if never set).""" + out = [] + # Synthesized UNAVAILABLE observations still need a positive sequence + # (>= 1) to satisfy the schema; anchor them at the buffer's first. + unavail_seq = max(1, self.first_sequence) + for di in self.dataitems: + obs = self._latest.get(di.id) + if obs is None: + obs = Observation(unavail_seq, _UNAVAIL_TS, di, "UNAVAILABLE") + out.append(obs) + return out + + def sample(self, from_seq, count): + """Observations with from_seq <= seq < from_seq+count, in order.""" + end = from_seq + count + return [o for o in self._ring if from_seq <= o.seq < end] + + +_MISSING = object() +_UNAVAIL_TS = "1970-01-01T00:00:00Z" + + +# -- streams XML ------------------------------------------------------------- + +def _st(tag): + return tag + + +def _streams_doc(observations, config, timestamp, buffer): + # Declare the LinuxCNC extension schema alongside the standard one so a + # validating consumer can resolve the extension observations (x:Flood, + # x:Mist, x:CoordinateRotation). + root = ET.Element("MTConnectStreams", { + "xmlns": STREAMS_NS, + "xmlns:x": EXT_NS, + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": + "%s http://schemas.mtconnect.org/schemas/MTConnectStreams_%s.xsd " + "%s mtconnect-linuxcnc-1.xsd" % (STREAMS_NS, SCHEMA_VERSION, EXT_NS), + }) + # first/last/nextSequence + deviceModelChangeTime are required by the schema. + # Sequences are positive integers; clamp the empty-buffer edge to >= 1. + ET.SubElement(root, _st("Header"), { + "creationTime": timestamp, + "sender": "linuxcnc-mtconnect", + "instanceId": config.instance_id, + "version": SCHEMA_VERSION, + "deviceModelChangeTime": timestamp, + "bufferSize": "131072", + "firstSequence": str(max(1, buffer.first_sequence)), + "lastSequence": str(max(1, buffer.last_sequence)), + "nextSequence": str(buffer.next_sequence), + }) + streams = ET.SubElement(root, _st("Streams")) + dev = ET.SubElement(streams, _st("DeviceStream"), + {"name": config.name, "uuid": config.uuid}) + + # Group observations by component, preserving first-seen order. + groups = OrderedDict() + for obs in observations: + groups.setdefault(obs.di.comp_id, []).append(obs) + + for comp_id, obs_list in groups.items(): + di0 = obs_list[0].di + cs = ET.SubElement(dev, _st("ComponentStream"), { + "component": di0.comp_type, + "name": di0.comp_name, + "componentId": comp_id, + }) + samples = [o for o in obs_list if o.di.category == "SAMPLE"] + events = [o for o in obs_list if o.di.category == "EVENT"] + if samples: + _emit_group(cs, "Samples", samples) + if events: + _emit_group(cs, "Events", events) + return root + + +def _emit_group(parent, wrapper, obs_list): + grp = ET.SubElement(parent, _st(wrapper)) + for obs in obs_list: + attrs = {"dataItemId": obs.di.id, "timestamp": obs.timestamp, + "sequence": str(obs.seq)} + if obs.di.subType: + attrs["subType"] = obs.di.subType + if obs.di.name: + attrs["name"] = obs.di.name + prefix = "x:" if obs.di.ext else "" + # TABLE representation streams under a "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())