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