Import LinuxCNC source tree

This commit is contained in:
Wang Dequan
2026-08-20 06:17:39 +08:00
commit 2c814634c3
9429 changed files with 10556013 additions and 0 deletions

View File

@@ -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.

144
lib/python/mtc/agent.py Normal file
View File

@@ -0,0 +1,144 @@
# 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 (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")
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 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 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())

View File

@@ -0,0 +1,446 @@
# 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 (<x:Kinematics>) 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 <Device> 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) <DataItems> 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, model.is_serial)
_build_spindle(comps, containers, models, config)
def _build_motion_axis(parent, axis, containers, models=None, config=None,
serial=True):
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])
# The direction vector is only meaningful when the axis is an orthogonal
# Cartesian DOF; for non-serial kinematics it is not INI-derivable, so the
# Motion element carries type/actuation but no (misleading) direction.
if serial:
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 and model.is_serial:
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}
if model.is_serial:
attrs["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 <MTConnectDevices> 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 '<?xml version="1.0" encoding="UTF-8"?>\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))

179
lib/python/mtc/hal_items.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,141 @@
# 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=<seq>&count=<n> -> 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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.7">\n'
' <Header creationTime="%s" sender="linuxcnc-mtconnect" '
'instanceId="1" version="1.7" bufferSize="131072"/>\n'
' <Errors><Error errorCode=%s>%s</Error></Errors>\n'
'</MTConnectError>\n'
% (now_iso(), quoteattr(str(code)), escape(str(message)))
)
def make_handler(agent):
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.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):
self.httpd = ThreadingHTTPServer((host, port),
make_handler(agent))
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()

View File

@@ -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 -------------------------------------------------------
#
# Delegate to linuxcnc.ini's own typed getters so value parsing (int/float/
# bool) is identical to the running machine; we never re-implement it here.
def find_float(self, section, key, default=None):
return self._ini.getfloat(section, key, fallback=default)
def find_int(self, section, key, default=None):
return self._ini.getint(section, key, fallback=default)
def find_bool(self, section, key, default=None):
return self._ini.getbool(section, key, fallback=default)

View File

@@ -0,0 +1,189 @@
# 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.
#
# The axis set, limits, home data and kins module name are reported for every
# machine. The joint<->axis map and per-axis direction vectors are only trusted
# for serial, orthogonal-axis kinematics (SERIAL_ORTHOGONAL_KINS); for parallel,
# articulated or non-orthogonal machines that geometry is not INI-derivable and
# is omitted rather than guessed (see is_serial).
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",
}
# Kins modules whose joints map 1:1 to orthogonal Cartesian axes. For these the
# joint<->axis map and the canonical AXIS_VECTORS are correct. For anything else
# -- CoreXY, parallel (hexapod/delta/tripod/penta), articulated (scara/puma/
# genser), non-orthogonal or runtime-switched kinematics -- the true geometry
# lives in the compiled kins module, not the INI, so it is omitted rather than
# guessed (the module name still travels in x:Kinematics for consumers to branch
# on).
SERIAL_ORTHOGONAL_KINS = frozenset({
"trivkins", "5axiskins", "xyzac-trt-kins", "xyzbc-trt-kins",
})
@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
@property
def is_serial(self):
"""True when joints map 1:1 to orthogonal Cartesian axes, so the
joint<->axis map and the canonical axis vectors are trustworthy."""
return self.kins_module in SERIAL_ORTHOGONAL_KINS
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 ""

View File

@@ -0,0 +1,279 @@
# 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. Read from the
# linuxcnc module so they track the running build rather than a hardcoded ABI
# assumption; the literals are only a fallback that keeps this module importable
# without the extension (offline tooling and tests).
try:
import linuxcnc as _lc
except Exception:
_lc = None
def _enum(name, fallback):
return getattr(_lc, name, fallback) if _lc is not None else fallback
_STATE_ESTOP = _enum("STATE_ESTOP", 1)
_MODE_MANUAL = _enum("MODE_MANUAL", 1)
_MODE_AUTO = _enum("MODE_AUTO", 2)
_MODE_MDI = _enum("MODE_MDI", 3)
_INTERP_IDLE = _enum("INTERP_IDLE", 1)
_INTERP_READING = _enum("INTERP_READING", 2)
_INTERP_PAUSED = _enum("INTERP_PAUSED", 3)
_INTERP_WAITING = _enum("INTERP_WAITING", 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

281
lib/python/mtc/models.py Normal file
View File

@@ -0,0 +1,281 @@
# 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
# <SolidModel href="..."> 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 <Motion>/<CoordinateSystems> 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/<name>:
#
# [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_<axis> 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 = ini.find_bool("MTCONNECT", "MODEL_AUTO", False)
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_<AXIS>/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
# ---- auto-generated placeholder geometry ----------------------------------
#
# Boxes are authored in the machine coordinate frame at the all-axes-zero pose
# (the agent's <Motion> 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"

View File

@@ -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:
# <prefix>/Probe/<uuid> (retained)
# <prefix>/Current/<uuid> (at the sample interval)
# <prefix>/Sample/<uuid> (when new observations arrive)
# <prefix>/Asset/<uuid>/<assetId> (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
# <prefix>/Probe/<uuid> 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()

View File

@@ -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 <DataItem>
# 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:<TYPE>" / <x:Element>
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
# <WorkOffsetTable>.
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 <ToolOffsetTable>.
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)

View File

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

278
lib/python/mtc/streams.py Normal file
View File

@@ -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 "<Type>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 <Entry key=..><Cell key=..>v</Cell>...</Entry>."""
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 '<?xml version="1.0" encoding="UTF-8"?>\n' + body + "\n"